commit df869819ce01181389b376ce41343b762a6385e2 Author: Ludarium release export Date: Thu Sep 3 02:08:58 2026 +0200 Publish Ludarium source diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..d18c13d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,39 @@ +# Keep the build context to the sources the images actually copy. +# +# Without this every local `docker compose build` ships the whole working tree — +# node_modules, build output, the Git history and any local candidate data — which +# is slow, non-reproducible and can push unrelated working-tree content into a layer. +# The release gate builds from `git archive`; this keeps a direct build equivalent. + +.git/ +.gitattributes +.gitleaks.toml + +**/node_modules/ +**/bin/ +**/obj/ +**/dist/ +**/__pycache__/ +*.pyc +**/playwright-report/ +**/test-results/ +.playwright-mcp/ +.stitch-reference/ +artifacts/ +deploy/data/ + +# Documentation and evidence are not part of any image. +docs/ +checklists/ +prompts/ +samples/ +specs/ +templates/ +*.md +!deploy/README.md + +.env +.idea/ +.vs/ +*.user +*.log diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..f17f241 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,6 @@ +*.sh text eol=lf +*.py text eol=lf +Dockerfile text eol=lf +Dockerfile.* text eol=lf +*.yml text eol=lf +*.yaml text eol=lf diff --git a/.gitea/workflows/public-validation.yml b/.gitea/workflows/public-validation.yml new file mode 100644 index 0000000..561ca1f --- /dev/null +++ b/.gitea/workflows/public-validation.yml @@ -0,0 +1,33 @@ +name: Public source validation + +on: + pull_request: + push: + +permissions: + contents: read + +jobs: + source: + name: source + if: ${{ gitea.event_name != 'pull_request' || gitea.event.pull_request.head.repo.full_name == gitea.repository }} + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - name: Set up .NET SDK + uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1 + with: + global-json-file: global.json + - name: Set up Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 24.18.1 + cache: npm + cache-dependency-path: src/Ludarium.Web/package-lock.json + - name: Export reviewed public source + run: node scripts/export-public-source.mjs "${RUNNER_TEMP}/ludarium-public" + - name: Verify exported manifest + run: cd "${RUNNER_TEMP}/ludarium-public" && node scripts/validate-public-source.mjs + - name: Validate source without privileged services + run: cd "${RUNNER_TEMP}/ludarium-public" && sh deploy/run-managed-validation.sh source diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f40fe1d --- /dev/null +++ b/.gitignore @@ -0,0 +1,23 @@ +**/bin/ +**/obj/ +**/node_modules/ +**/dist/ +**/playwright-report/ +**/test-results/ +.playwright-mcp/ +**/__pycache__/ +*.pyc +.env +.idea/ +.vs/ +*.user +*.log +artifacts/ +deploy/data/ +.stitch-reference/ + +# Produced by the Docker build (web bundle plus the emulator payloads). +src/Ludarium.Api/wwwroot/ + +# Ephemeral, checksum-verified inputs materialized by the managed image gate. +.build-inputs/emulatorjs/*.7z diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 0000000..978a327 --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,6 @@ +[extend] +useDefault = true + +[[allowlists]] +description = "Ignore the deployment host's untracked runtime environment file" +paths = ['''(^|/)deploy/\.env$'''] diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..5237be5 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,641 @@ +# Changelog + +## 0.4.19-rc.8 — populated Home height closeout + +- Removes unused desktop-only bottom whitespace so the complete populated Home stays within its + 1,700 px release budget without compressing content, controls or mobile navigation space. + +## 0.4.19-rc.7 — settled populated Home guard + +- Keeps the fully settled five-signal production Home hero below its fixed 440 px visual budget, + including after asynchronous playability data arrives. + +## 0.4.19-rc.6 — populated Home visual closeout + +- Keeps the cinematic populated Home hero within its 440 px desktop height budget by tightening + its responsive padding and display scale without changing archive content or controls. + +## 0.4.19-rc.5 — release truth and fail-closed qualification + +- Makes Home, navigation badges and repair views use exact review, artwork, source, hash and + collection-playability signals, so an incomplete archive can no longer be reported as healthy. +- Adds a bounded SQL playability summary and progressive Library/Attention loading while retaining + the stricter evidence-bound runtime check for every actual launch. +- Refuses zero-item integrity snapshots with HTTP 409 and identifies historical empty snapshots as + unusable legacy baselines. +- Separates Attention decisions from findings, uses action-specific verbs and adds uncapped + server-side grouped-review paging with an honest warning for the legacy 500-group response. +- Bundles Inter and Manrope locally, fixes light-theme hero and heading contrast, removes overlapping + generated-cover layers, improves empty Wishlist focus and extends responsive validation to 880 px. +- Adds one canonical `VERSION` identity for .NET runtime surfaces and guards the required Compose, + Unraid, runbook and changelog copies against drift. +- Replaces generic repository guessing in Gitea Actions with Ludarium's locked .NET, nested frontend, + controller and exact-image security gates. +- Makes production autodeploy depend on a successful full validation of the same source revision. +- Keeps SNES `.smc`, PSX `.pbp`, PSP `.iso`/`.cso` and Genesis `.gen`/`.smd`/`.bin` recognised but + fail-closed until an exact deterministic runtime fixture qualifies each format. +- Makes an empty candidate-platform qualification request fail instead of silently re-running the + already-qualified browser matrix. +- Opens threaded PPSSPP sessions in their required isolated top-level page instead of leaving users + in a non-isolated embedded player that can never expose `SharedArrayBuffer`. +- Records the release contract in ADR 0067. Local exact-image security passes; isolated Unraid + candidate backup/restore, browser and live active-scan evidence remain required before promotion. + +## 0.4.19-rc.4 — Sega qualification and three faults a live archive found + +- Makes accessibility-gate startup semantic and diagnosable: it now waits for Ludarium's visible + application landmark and, after login, its page heading instead of a Home-specific CSS selector. +- Binds the candidate backup filename and restored-database assertion to the application's declared + schema version, eliminating the final stale schema-23 release assumption. +- Makes the Home workflow height budget deterministic and diagnostic, and gives that workflow the + same semantic application-readiness boundary as the accessibility gate. +- Recalibrates Home's total height allowance for the qualified Living Archive hero while adding + tighter independent hero and metric-grid caps to prevent local layout regressions. +- Keeps game-drawer header titles and controls high-contrast on the Living Archive dark-glass + header in both light and dark themes. +- Aligns every runtime, export, support, gate and Unraid release identifier with `0.4.19-rc.4`. +- Binds the candidate gate and support bundle to the application's declared schema version. +- Qualifies Genesis, Master System and Game Gear on the bundled `genesis_plus_gx` core, each proven + by its own deterministic fixture in a browser. +- Marks a file that left the archive as missing again, instead of leaving it Present and playable. +- Reads a game's creation date from the record, so sorted and filtered library requests work. +- Lets a confirmation be answered with a pointer instead of rendering behind the surface that + raised it. +- Stops leaving a file extension in a derived title, without splitting existing games in two. +- Finishes a cheat lookup that finds nothing. +- Keeps a resolved review readable, and repairs any an earlier version made unreadable. +- Answers an unrecognised scan mode with the modes that exist instead of an empty 400. +- Lets the browser gates run against any archive rather than one operator's, and proves bulk + organising against a real catalogue. +- Names the stylesheet's colours, and gives the light theme the hairlines and lifts it never had. +- Lets the library filters show the option they are set to, and counts the catalog once. +- Gives a recently played game room for its name. +- Stops a critical finding with no decision beside it from reading as nothing to do. +- Says when nothing is blocked, and prints a one-sentence cause once. + +## 0.4.19-rc.3 — navigation parity, bulk organising and session choices + +- Gives desktop and mobile the same destinations, and surfaces Playability & naming from the + navigation and from any game that cannot start. +- Adds selection and bulk favourite, play status, collection and tag actions to the library grid. +- Makes staying signed in an explicit choice on a trusted device instead of a per-tab default. +- Makes controller navigation discoverable in Settings and possible to switch off. +- Follows the operating system theme while the application is open. +- Gives every overlay a unique title id. + +## 0.4.19-rc.2 — library query, first run and interface correctness + +- Moves library sorting and filtering into the catalog query, so ordering describes the collection + rather than the page on screen. +- Adds filtering by favourites, play status, minimum rating, collection, tag and whether a playable + copy exists, and sorting by last played, rating and recently updated. +- Scopes `GET /releases` to named games, so browsing no longer loads every release in the catalog. +- Tells a fresh install to add a library instead of reporting that the empty archive looks good. +- Replaces every `window.confirm` with the application's own focus-trapped confirmation. +- Makes the game details one tab set with one selected tab, a real tab panel and arrow-key movement. +- Makes global search a combobox: focus stays in the input and the whole result list is reachable. +- Runs the accessibility gate in both themes, which it never did, and fails if the theme it asked + for was not applied. +- Declares five colour and layout tokens that were used but never defined, gives the light theme its + missing navigation colour, and guards the stylesheet so hard-coded colours cannot increase. + +## 0.4.19-rc.1 — emulation completion + +- Gives every isolated native player one shared contract: one registry describes a player, and its + routes, cookies, sessions, proxying, control and capability vocabulary follow from that + description. Adding a player is a registry entry plus a launch resolver. +- Moves the sidecar control surface, path validation, Xwayland display resolution, process + supervision and save-data transfer into one shared module. The hardened display resolution added + for Eden had never reached Dolphin, which shipped with the bug that fix removed. +- Stops the running title when a player session is revoked, so a closed player cannot leave an + emulator holding a render device and a disc image. +- Reports a native player as available only when its sidecar answers a live status probe, instead of + trusting configuration alone until the operator clicks Play. +- Targets player hotkeys at the launched process rather than a window name, so a state hotkey cannot + reach an emulator's own front-end window. +- Brings native save data into the Game Data Vault: Switch, GameCube and Wii save directories can be + captured as immutable SHA-256 evidenced revisions and restored from them. +- Classifies every container format a player accepts, including `.gcm`, `.gcz`, `.wia`, `.isz`, + `.3ds`, `.cia`, `.wud`, `.wux`, `.cdi` and `.vpk`. +- Identifies GameCube and Wii discs by their on-media magic word instead of by a folder name, and + records a conflict when the directory disagrees. +- Identifies Sega hardware from the cartridge header, separating Mega Drive from 32X and Sega CD and + Master System from Game Gear, and resolves every common Sega directory spelling. +- Fixes a classifier defect where `MediaType.Rom` equalling the default enum value made every ROM + extension without a matching header signature classify as `Unknown`. +- Delivers CUE/BIN PlayStation discs and multi-disc M3U games by resolving a linked descriptor to + every disc and track it names and streaming them to the browser player as one archive built while + it is read. +- Scopes a native save capture to the directories that hold saves rather than to the whole emulator + profile, which on Dolphin also carries shader caches far larger than one vault revision, and + reports the resolved directories so a wrong path is visible before it matters. +- Gives the player both halves of the vault: a capture control and the stored restore points, with a + capture taken while a title runs marked as such. +- Adds a `.dockerignore`, so a local image build no longer ships the working tree as build context. +- Separates container formats Ludarium can deliver from formats a fixture has proven, so an + implemented delivery path never reports a game as playable before its qualification pass has run. +- Binds the image's EmulatorJS core retention list to the platform matrix, drops the unused + `mupen64plus_next` core and bundles `genesis_plus_gx`. +- Adds deterministic Genesis, Master System and Game Gear fixtures and their qualification pass. The + three platforms fail closed until that pass has completed the real bundled core. +- Gives every native player one embedded shell: delayed-load recovery, a reload control, the session + expiry, assertive failure announcements and state controls only where the runtime exposes them. + Recently Played can now resume a GameCube or Wii title, which it never could. +- Covers the sidecar security boundary with a stdlib-only test suite that runs in the candidate gate. + +## 0.4.17-rc.1 — stable navigation and contextual wayfinding + +- Replaces the expanding global platform/collection tree with a stable Play, Organize and + Operations hierarchy that never pushes core destinations below the fold. +- Adds compact breadcrumbs for page, section, platform and detail context. +- Moves console browsing into a hardware-led, horizontally scrollable Library platform rail with + exact counts, active state and a direct overview path. +- Replaces the six-way mobile dock with Home, Library, central Search, Wishlist and More; the + accessible More sheet contains platform, collection, operational and advanced archive routes. +- Keeps pinned collections meaningful by ordering them first inside the Collections workspace. + +## 0.4.16-rc.1 — hardened Switch runtime and premium entry experience + +- Replaces the sparse administrator-token screen with a responsive, console-led welcome flow, + clear privacy/source-safety promises, a deliberate token reveal control and mobile-first layout. +- Adds deterministic login interaction, accessibility, reflow and production-build coverage. +- Removes unused container, build, SSH, cron, mail and scripting stacks from the isolated Eden + runtime and health-gates it before the main application becomes ready. +- Adds an in-container runtime audit and version-bound OpenVEX evidence so every inherited Critical + scanner match is either reachable and blocking or explicitly justified from the actual runtime. +- Makes release archives preserve Unix line endings and executable bits when produced on Windows. +- Publishes Switch sessions through the dedicated HTTPS player origin and closes an Nginx Proxy + Manager HTTP/2 POST-stream stall while retaining WebSockets on the isolated player host. +- Makes the public export workflow wait for the exact requested artifact with a bounded production- + sized timeout instead of accepting an unrelated status message. +- Gives both Unraid containers stable DockerMan ownership, HTTPS WebUI links and the persisted SVG + icon, groups them as one Folder View application and removes verified stopped release candidates + plus their obsolete images without touching appdata or read-only source libraries. + +## 0.4.15-rc.1 — reliable resume and self-healing runtime + +- Makes every visible game detail route independent of catalog pagination and transient browser + cache, with canonical history, reload, Back, Close, loading and recovery semantics. +- Turns Recently Played into a capability-aware one-click action: Resume only for qualified + automatic restore, Play for another available player, and an honest Details fallback otherwise. +- Reuses one browser player across Home and Library, revokes sessions on exit/unmount and gives the + Switch player a single-modal focus trap plus body scroll lock. +- Makes readiness a read-only PostgreSQL/schema check and makes PID 1 supervise both PostgreSQL and + the API so database loss terminates the broken runtime and activates Docker restart policy. +- Pins the patched SSH.NET 2026.0.0 test dependency after the RC gate rejected the newly disclosed + recursive SCP path-traversal advisory affecting 2025.1.0 and earlier. +- Gives Unraid candidate compilation and browser gates explicit CPU, memory, process and concurrency + budgets so exhaustive validation cannot claim unbounded production-host resources. +- Keeps the Play!.js supply-chain boundary fail-closed after upstream artifact drift: both images + now record the reviewed upstream commit and exact refreshed hashes as in-image provenance, with + real PS2 fixture qualification still mandatory before promotion. +- Extends the live browser workflow through the new Recently Played Play/Resume action and replaces + a fixed 80 ms controller-focus assumption with a bounded observable focus contract. +- Gives the playable Recently Played Details action a real secondary-button treatment and + theme-aware text token, restoring WCAG AA contrast in every gated light-theme viewport. +- Replaces the security-rejected PostgreSQL 16.14/Alpine final image with a unified Ubuntu Noble + runtime containing patched PostgreSQL 16.15 and OpenSSL 3.0, removing the reported database and + runtime vulnerabilities without suppressions while retaining non-root API execution via `gosu`. +- Makes UTF-8 an explicit image and candidate-gate invariant, so a fresh database cannot fall back + to SQL_ASCII and reject Unicode platform metadata during bootstrap or after restart. +- Removes EmulatorJS's hidden runtime version-check request by resolving its pinned metadata from + the bundled same-origin file, with build-time residue checks and a real-browser assertion that + every qualified EmulatorJS core stays entirely on the Ludarium origin. +- Makes exact-title Switch launch follow the active PixelFlux Xwayland display instead of assuming + `:0`, with active-process discovery, a bounded newest-socket fallback and explicit early-exit + diagnostics in the isolated Eden controller. +- Repairs libc collation-provider upgrades before the API can seed data: unversioned legacy indexes + are rebuilt and versioned, while only semantically identical non-custom seed duplicates may be + removed; every ambiguous or manual record fails closed for operator review. + +## 0.4.14-rc.1 — controller-first couch navigation + +- Extends standard gamepad support from the isolated players to the complete Ludarium shell. +- Adds spatial D-pad and left-stick focus across responsive grids, navigation, filters and actions, + with horizontal select changes, A to activate, B to close a modal or return Home, and Menu search. +- Keeps controller focus trapped inside active dialogs and visibly identifies the selected control. +- Suspends shell polling while browser or Switch playback owns the controller, avoiding double input. +- Runs no animation loop until a controller is actually connected and cleans up every listener, + frame and timer on disconnect or unmount. + +## 0.4.13-rc.1 — durable recently played flow + +- Records successful browser and Switch launches as auditable personal play history while preserving + manual status, favorites, notes, rating, difficulty and completion progress. +- Adds a bounded recently-played API ordered by the actual last launch time rather than UI visits. +- Adds a responsive cover-led Jump back in section on Home with relative play time, session count, + completion progress and direct game-detail navigation. +- Makes every Home support request cancellable as one parallel batch so navigation cannot update an + unmounted page. + +## 0.4.12-rc.1 — recoverable operational UX + +- Replaces permanent spinners with consistent first-load failure and retry states across Attention, + Activity, scan history, snapshots and data-backed Settings sections. +- Keeps already loaded operational information visible when a refresh fails and shows a focused + reconnect action instead of blanking the screen. +- Makes Activity polling cancellable and overlap-free so slow refreshes cannot accumulate or emit + repeated unhandled failures. +- Adds visible busy states and duplicate-click protection for all-library scans, individual library + scans and integrity snapshot creation. +- Adds deterministic regressions for failed and recovered Activity, Attention and Settings loads. + +## 0.4.11-rc.1 — resilient browse and detail UX + +- Debounces library search, cancels superseded requests and prevents stale cards from appearing + under a newly selected query or platform. +- Adds explicit initial, refresh-error and retry states instead of leaving a permanent spinner or + silently hiding a failed refresh. +- Keeps game browsing available when an optional play-capability service is down, fails Play closed + and provides a focused readiness retry. +- Loads fourteen game-detail resources independently so one failed secondary source no longer + empties the complete drawer; partial results remain usable with a bounded retry action. +- Prioritizes Overview, My library, Saves & states and Releases while moving six maintenance views + behind a clear More details disclosure. +- Separates Attention's open-decision count from the number of currently shown evidence items. + +## 0.4.10-rc.2 — truthful per-game Switch readiness + +- Added single-game and bounded bulk Switch play-capability APIs that validate the exact title, + runtime/controller, selected production keys and read-only source before advertising Play. +- Library cards and the page-ready count now use the per-game result instead of global Eden + availability; ambiguous or unavailable titles show a concrete reason before launch. +- Game details use the same capability and preserve independent session-time revalidation so a + stale or changed catalog still fails closed. +- Distinguishes a missing base game from genuine ambiguity: update/DLC-only folders now explain + that their base XCI/NSP is required instead of incorrectly suggesting multiple matches. +- Added deterministic policy and frontend regressions for available, missing-prerequisite and + ambiguous base-game states. + +## 0.4.9-rc.1 — dormant sources and descriptor truth + +- Added an explicit dormant-library control. Disabled roots preserve their catalog, cannot be + scanned, and no longer generate false archive-health attention while their mounts are empty. +- Recognizes an exact `.bin.ecm` companion referenced by a PlayStation CUE as a present legacy + encoded track. The bundle remains visibly `CompleteWithWarnings` until a fixture-qualified, + app-owned conversion/playback path exists; source content is never changed. +- Corrected Home and Settings states so only active empty sources affect the archive headline. +- Added deterministic integration coverage for ECM sidecars, source immutability and dormant scan + rejection. +- Updated the shipped .NET runtime to 10.0.11 and the build SDK to 10.0.302 after the RC security + gate identified the fixed High-severity runtime advisory `GHSA-m93f-wj8c-rp8p` in 10.0.10. + +## 0.4.8-rc.1 — platform hardware identity + +- Replaced generic platform monograms with real, locally bundled hardware imagery for all sixteen + active console, handheld and PC platforms. +- Added a reusable platform presentation model with device category, generation/era and restrained + platform color identity. +- Reworked platform cards for stronger visual hierarchy, responsive behavior, focus states and + reduced-motion support. +- Reused compact hardware portraits in Home collection distribution and Library navigation. +- Added asset provenance and component coverage; no runtime image-host dependency was introduced. + +## 0.4.7-rc.1 — deterministic Switch title launch + +- Resolves a selected Switch game to one unique read-only base XCI or NSP and launches that exact + path through Eden's fullscreen command-line interface instead of opening Eden's library menu. +- Adds a private, separately authenticated Eden controller that rejects traversal, non-game files, + missing files and any path outside the fixed `/games` mount. +- Adds Ludarium-native remote controls for pause/resume, Eden fullscreen and stop, alongside the + existing shell fullscreen, connection state, reload and explicit session exit controls. +- Reports native persistent in-game saves accurately and does not present unsupported portable + Eden savestates as a working capability. +- Fails closed when a directory contains multiple plausible base titles or catalog evidence is not + sufficient to select one deterministically. + +## 0.4.6-rc.1 — embedded Switch remote player + +- Embeds the isolated Eden desktop inside a dedicated full-screen Ludarium player instead of + sending operators to a separate application, while retaining a secure standalone fallback. +- Adds bounded, expiring Switch-player capabilities stored only as SHA-256 digests and delivered + through an HttpOnly cookie on a separate port origin. +- Adds a certificate-pinned WebSocket/streaming reverse proxy with server-side Eden credentials; + neither credentials nor the Ludarium administrator token enter the player frame. +- Hardens Eden's web desktop by disabling sharing, collaboration, file transfer, command execution + and file/app sidebars, and keeps the game library strictly read-only. +- Adds a Settings readiness panel and game-specific play controls while remaining explicit that + the operator chooses the exact title inside Eden. +- Streams the common single-ROM N64 ZIP layout directly from read-only sources without extracting + it, while rejecting ambiguous archives, invalid N64 headers and decompression sizes above 512 MiB. +- Disables Eden clipboard and microphone bridging and permits only the screen wake lock needed by + the embedded player. + +## 0.4.5-rc.1 — play-first library experience + +- Reworks the game library around premium, responsive tiles with clearer hierarchy, stronger + artwork presentation and direct play actions for every currently available browser or Eden title. +- Adds a bounded 50-game capability query so cards show honest live play readiness without an + unbounded request waterfall; unsupported or absent content remains fail-closed. +- Gives the game drawer a dedicated play panel, separates playback from artwork administration, + localizes release dates and corrects read-only messaging around explicit operator playback. +- Qualifies dark/light themes and the complete responsive surface across eight viewports with + WCAG 2.1 AA checks and horizontal-overflow detection. + +## 0.4.4-rc.2 — native N64 and isolated Switch playback + +- Release-qualifies a checksum-pinned N64Wasm WebGL2 player with deterministic `.z64`, + `.v64` and `.n64` recognition plus evidence-bound savestate restore. +- Adds an optional digest-pinned Eden remote-player profile for Switch, verified personal-key + provisioning and a strictly read-only game-library mount. +- Preserves automatic browser savestate evidence across rescans when byte-identical content + is rebound to a new artifact version. + +## 0.4.4-rc.1 — personal firmware foundation + +- Added schema-23 app-owned BIOS, firmware and key metadata with selected-version provenance, SHA-256 evidence and audited upload/delete operations. +- Added a private streaming firmware vault under persistent Ludarium appdata. Uploads are bounded per allowlisted slot and reject host executables, scripts, archives, unsafe names and unknown extensions. +- Added Settings UI for personal PSX BIOS, PS2 BIOS and Switch production/title keys with explicit configured-versus-runtime readiness. +- Added capability-scoped PSX BIOS delivery to the native browser player; the BIOS URL is emitted only for an operator-selected asset and there is no general firmware or key download endpoint. +- Release-qualified checksum-pinned PSX/`pcsx_rearmed`, PSP/`ppsspp` and PS2/Play!.js playback with original, deterministic SDK-built fixtures and real canvas, scoped Range and cancellation gates. PSX and PSP additionally prove automatic state restore; Play!.js does not expose a fixture-proven state API. Switch remains fail-closed without a browser runtime. +- Added the locally bundled, checksum-pinned official Play!.js runtime, cross-origin-isolated PS2 player and asynchronous 8 GiB bounded disc Range device. Play!.js uses its built-in HLE BIOS; personal PS2 BIOS bytes are never delivered to the player. +- Fixed hardened container startup on correctly pre-owned appdata by avoiding redundant recursive `chown` calls, and aligned both Unraid templates with Compose's read-only root, `no-new-privileges` and six-capability startup policy. +- Added bounded deterministic inventory recognition for PSX/PS2/PSP ISO9660 layouts, PSP CSO, Switch XCI and Switch NSP/PFS0 without extracting or executing source content. + +## 0.4.3-rc.1 — deferred functionality closure + +- Added schema-22 game-origin provenance and safe full deletion for manually created games, including dependent app-owned catalog, media, artwork and Vault records while preserving linked wishlist intent as a standalone item. +- Release-qualified SNES, GB, GBC, GBA and NDS browser play with deterministic original fixtures and real local-core canvas, scoped Range and savestate gates; N64 remains fail-closed after its legal homebrew fixture failed Mupen startup. +- Added explicit live metadata-provider credential/endpoint validation with persisted Ready, Degraded or Offline health snapshots and secret-safe responses. +- Fixed EmulatorJS startup in non-English browser locales by using the bundled `en-US` fallback without a missing locale request. +- Qualified evidence-bound automatic savestate restore for every playable core: fceumm, snes9x, gambatte, mgba and melonds. +- Disabled unused Npgsql 10 GSS negotiation for the bundled loopback PostgreSQL connection after a crash dump identified its cancellation path, and made unexpected candidate restarts a hard release failure. + +## 0.4.2-rc.1 — capability honesty and safe catalog lifecycle + +- Separated bundled EmulatorJS cores from release-qualified playback; only deterministic live-fixture-backed NES/fceumm is currently startable, while unverified mappings fail closed and remain visible as deferred capabilities. +- Added schema-21 provenance for scan-derived versus manual releases, with audited, optimistic-concurrency create/update/delete for manual records and immutable scanner-owned records. +- Added the complete manual release editor to game details, including origin labels and explicit destructive confirmation. +- Hardened administrator bearer-token comparison with strict parsing and fixed-time digest equality. +- Updated maintained patch-level dependencies: Npgsql 10.0.3, OpenMcdf 3.2.0, SharpCompress 0.50.4, React 19.2.8, Playwright 1.62.1 and Vite 8.2.1. +- Promoted the exact fully gated schema-21 candidate to production after source-manifest, backup/restore, security, real-core, responsive UX and accessibility validation. + +## 0.4.1 — evidence-bound browser restore + +- Added automatic Ludarium-owned save and savestate restore for the deterministically verified `fceumm` browser core, with no RomM runtime or external service. +- Bound every automatically restorable revision to the exact artifact UUID/version, emulator core and optional ROM SHA-256 so changed, relinked, manual, legacy and malformed data fails closed. +- Added capability-scoped integrity-checked Vault reads and player-side restore through EmulatorJS's in-memory game-manager interface before play continues. +- Added startup cleanup for interrupted uploads and database-orphaned app-owned revisions, while skipping reparse points, unrelated files and every source-library path. +- Extended the live NROM gate to capture a real savestate, cancel the session, launch a new session and prove automatic restore with no console or request failures. +- Promoted the exact fully gated candidate to production with unchanged read-only source manifests, controlled restart, backup restore and post-deployment UX/accessibility evidence. + +## 0.4.0 — browser play candidate + +- Added the native Game Data Vault for stand-alone save and savestate management, including immutable revisions, SHA-256 evidence, metadata, bounded streaming uploads, range downloads and full-history deletion. +- Added schema-20 audited PostgreSQL persistence, retry-safe client revision IDs, optimistic concurrency, duplicate-content suppression and restart-safe history without requiring a RomM runtime or database. +- Added a responsive Saves & states workspace with evidence cards, lazy revision history, metadata editing, safe downloads and explicit app-owned/source-read-only language. +- Added executable/signature rejection, path containment, corruption detection and deterministic vault policy/filesystem/PostgreSQL/frontend regressions. +- Added a dedicated live Playwright vault gate with desktop/mobile WCAG checks and corrected light-theme composer/evidence contrast discovered by that gate. +- Kept personal vault notes and device labels out of request URLs by moving bounded upload metadata into an encoded internal header while preserving raw streamed file bodies. +- Added a native, stand-alone library experience inspired by RomM's product surface: static/smart/virtual collections, tags, personal state, game relationships, rich media/manuals, achievements, editable custom platform definitions and catalog exports. +- Added direct opt-in IGDB, MobyGames, ScreenScraper and RetroAchievements providers with bounded network reads, exact-match safeguards, provenance-bearing claims and review items for uncertain conflicts. +- Added schema-18 persistence and audited APIs for all native library data, plus app-owned content-addressed media and streaming JSON, EmulationStation and Pegasus export generation without source-library mutation. +- Added premium collection navigation and game-detail workflows for state, tags, media, relationships and achievements; provider and platform management now live in Settings. +- Added deterministic provider fixtures, unsafe-media rejection tests and a real PostgreSQL restart/scale regression covering 250,000 catalog records. +- Added self-contained, fail-closed browser play with checksum-pinned EmulatorJS 4.2.3 assets, six allowlisted cores, scoped expiring sessions and exact read-only ROM streaming; RomM is not a runtime dependency. +- Added schema-19 scan-derived game/artifact identity links, bounded expiring play sessions and redacted start/exit audit events. +- Added a responsive game-detail player with loading, unavailable, missing-core, error, fullscreen, separate-window and explicit exit states. +- Added deterministic synthetic iNES evidence plus executable, script, archive, oversized, missing, ambiguous, unavailable, arbitrary-ID and cancellation regressions. +- Replaced hanging Syft/Grype/Gitleaks Docker pulls with checksum-pinned cached release binaries and bounded the remaining pinned Playwright image pull with retries/timeouts. +- Added an original deterministic playable NROM fixture and an isolated live gate that proves scan linkage, capability authorization, Range delivery and real local fceumm startup without touching source libraries. +- Fixed ASP.NET static delivery for EmulatorJS `.data` core payloads so a missing content-type mapping cannot silently trigger the upstream CDN failsafe. +- Promoted the fully gated exact candidate image to production 0.4.0/schema 20 with read-only manifests, backup/restore, restart, DockerMan and post-promotion browser/accessibility evidence. + +## [Unreleased] + +- Reworked Home around the current operational state rather than an alarmist aggregate review count, with direct access to prioritized review work and supporting critical/decision context. +- Clarified desktop navigation into Workspace, Collection and Operations; the Attention badge now exposes critical work before an unhelpful high total. +- Made empty Library filter responses recoverable with a direct clear action, while retaining truthful zero-result pagination behavior. +- Turned the cover-quality queue into a visual review surface with lazy selected-cover previews, source/quality/verification evidence and resilient refresh feedback. +- Added a concise, safe grouped-work summary to Attention, direct platform collection links from Home, stronger game-detail framing and responsive/reduced-motion-safe finish work. + +## 0.3.18 — catalog health and operational focus + +- Expired provider snapshots now report an honest `Expired` state across the API and interface instead of appearing ready. +- Added per-library scan-freshness guidance, including empty-source protection and clear full-scan visibility. +- Attention can now prioritize recommended work, storage impact or recency; historical failed scans are separated from current operations without deleting audit evidence. +- Added direct, durable actions to refresh metadata and cover-quality candidates from catalog administration. + +## 0.3.17 — integrity and operational truth + +- Integrity scans now hash OnDemand libraries, making exact duplicate detection, SHA-256 manifests and snapshots operational without changing source files. +- Empty mounted roots with retained catalog records now count as attention-required and are clearly marked as historical on Home; bulk scan actions skip them safely. +- Attention now opens the actionable decision inbox by default, while unknown content remains a separate review backlog. +- Artwork candidates are content-addressed and duplicate candidate records are collapsed by the schema-17 migration; review metrics are per game rather than misleading raw candidate totals. +- Provider snapshots report refresh-due status after expiry; diagnostics include expired providers as degraded. +- Wishlist export is no longer capped at 100 records; JSON and CSV exports can be restored, store search works, acquired items no longer inflate waiting summaries, uploads/deletes handle artwork safely and binary API failures no longer masquerade as missing covers. + +## [Unreleased] + +- Expanded Wishlist 2.0 with lifecycle statuses, release dates, editions/regions, manual target/current prices, price-target highlighting, sorting, summary metrics, authenticated CSV export, bounded text import, standalone cover uploads and Home/Library integration. +- Added exact, ambiguity-safe scan reconciliation: a standalone wish becomes Acquired only when its title resolves to one unique catalog game; uncertain duplicates are never silently linked. +- Added optional RAWG-powered discovery with normalized platform identifiers and honest runtime capability flags. The feature is disabled without `RAWG_API_KEY`; retail price automation is explicitly unsupported rather than simulated. +- Fixed artwork candidate selection so PostgreSQL never observes a transient second selected cover, eliminating enrichment-job uniqueness conflicts and scan-queue delays. +- Fixed Wishlist reflow at 320–360 px by collapsing filters and constraining hero statistics without hiding functionality. +- Added a persistent, searchable Wishlist with local-library suggestions, standalone future titles, cover reuse, platform and priority filters, personal notes, optimistic-concurrency editing and audited removal. +- Added a dedicated premium Wishlist experience to desktop, mobile and widescreen navigation, including a responsive editorial summary, artwork cards, polished empty states and reduced-motion-safe ambient illustration. +- Added a premium visual pass across the live application shell: atmospheric depth, refined glass surfaces, luminous navigation hierarchy, artwork-first gallery elevation, restrained page and panel transitions, responsive mobile dock treatment and an explicit reduced-motion path. +- Added a production visual audit covering desktop and mobile hierarchy, real-cover presentation, widescreen density, interaction feedback and motion guardrails. +- Added a live empty-source preflight to mount verification and scanning: an unexpectedly empty remounted share can no longer reconcile an existing catalog to missing, and the UI disables that source until content is restored and safety is verified. +- Fixed Library platform deep links so sidebar and platform-card navigation immediately apply the requested platform instead of changing only the URL. +- Fixed inventory export feedback and the SHA-256 manifest request format; exports now show progress, errors, the generated filename and record count. +- Made scan history operationally useful with library names, completion timestamps, duration and retained failure details. +- Corrected the live Unraid PS4 and PS5 source mappings from temporary `/mnt/disks` directories to their durable `/mnt/user` shares while preserving read-only container destinations and retained inventory. +- Reworked the second-pass UX hierarchy for 0.3.12: truthful Attention routing, stable singular status copy, a responsive library control grid, compact mobile view labels, wider ultrawide collection canvases and explicit result context. +- Reduced operational noise by showing active and recent background work first, limiting the scan dashboard to the latest eight runs and keeping the complete scan timeline one action away. +- Added targeted workflow regressions for 1440px toolbar containment, mobile tab containment, the actionable Attention route and singular dashboard grammar. +- Pinned both Linux Lightning CSS native variants as direct optional dependencies so Ubuntu browser gates and Alpine production builds cannot intermittently omit the required platform binary. +- Reworked Attention into a truthful actionable inbox: missing/incomplete and integrity work now appears in `Needs action`, while informational unknown inventory remains available without inflating the priority flow. +- Replaced premature generated-cover fallbacks with progressive artwork skeletons, so off-screen real covers are no longer presented as missing before their bounded lazy lookup runs. +- Added route scroll restoration, scan-staleness messaging, correct singular status language, compact mobile Home composition and a clearer mobile navigation state. +- Removed duplicate mobile Settings navigation and made the desktop platform rail independently scrollable so its protected-library footer cannot overlap controls on short displays. +- Added the live 0.3.11 platform/UX audit and E2E regressions for scroll restoration and responsive Settings navigation. +- Updated the locked transitive `nanoid` dependency to 3.3.18 after its prior version became subject to a high-severity availability advisory; the frontend audit is clean again. +- Isolated candidate container naming from the fixed production DockerMan identity so release gates can run beside the single live `Ludarium` container without a name collision. +- Made candidate .NET and browser gates consume an exact `git archive` instead of copying the dirty server worktree, excluding build caches and `.env` secrets from ephemeral test workspaces. +- Made the mobile Settings regression selector exact so its label cannot be confused with the similarly named navigation landmark. +- Added an explicit accessible name to the responsive Settings selector and wait for its route paint before asserting visibility in the headless mobile workflow. +- Deduplicated deterministic health findings already represented by an actionable review group and kept resolved history free from current health summaries. + +- Consolidated the Unraid deployment into one supervised `Ludarium` container containing the application and its private PostgreSQL process, with a functional DockerMan WebUI and persisted 256px PNG icon. +- Promoted 0.3.9 to production on port 1230 with schema 14, 371 canonical visible games across 16 platform collections, 371/371 visible cover coverage and all three source roots read-only. +- Made the schema-13 data reconciliation genuinely one-shot so readiness remains fast on large inventories, and added regression coverage that preserves words such as `Legends` and `Darkness` during platform-suffix cleanup. +- Prepared 0.3.9/schema 13 to turn Attention into an actionable decision inbox while retaining unknown inventory as separately inspectable evidence. +- Expanded deterministic support-content recognition for Steam, emulator, tool, redistributable, unlocker and related non-game paths; the repeatable migration resolves matching legacy unknown reviews without touching source libraries. +- Tightened canonical title cleanup for scene, platform, region and product-code noise while preserving manual locks. +- Restricted automatically verified covers to portrait box-front proportions and made generated artwork visibly pending rather than presenting it as equivalent to real cover art. +- Replaced false-zero game-detail states and stale search states with truthful loading skeletons, added cover thumbnails to search, and improved mobile filters, Settings navigation and ultrawide Library density. + +- Adopted the official .NET 10 Azure Linux 3 distroless runtime after comparative security testing; the final image has no shell/package manager and zero Grype findings at every severity. +- Stopped the isolated candidate stack after successful promotion while retaining all candidate appdata, databases and backups for rollback. +- Made candidate SBOM version naming derive from the requested image tag instead of a hardcoded development label. + +- Reconciled current production evidence with historical UX documentation and added a gated post-0.3.8 optional roadmap. +- Added a dedicated bounded .NET health probe and moved the runtime experiment to an official .NET 10 distroless image, removing the shell, package manager and auxiliary network tools; the final distribution is selected by comparative candidate security gates. + +- Promoted 0.3.8 to production on port 1230 with schema 12, 389 visible games, complete visible cover coverage, read-only source mounts and passing post-deployment workflow/accessibility/restart gates. + +- Finalized the production identity as 0.3.8 across status, exports and support bundles, including the correct schema-12 capability report. +- Corrected the live system-status capability flag to report schema 12 after the forward migration. + +- Passed the complete isolated Unraid 0.3.8-dev gate on port 1232: real PostgreSQL tests, clean Compose bootstrap, three read-only library scans, browser/axe/reflow stories, restart, backup/restore, invariant source manifest, SBOM, secret scan and zero Critical/High container findings. +- Proved the production upgrade path on an isolated copy from schema 6 to schema 12 while preserving exactly 538 games and 13,856 artifacts. +- Verified selected cached artwork for every visible candidate game (372/372); the sole raw record without artwork is deterministically hidden as non-game DLC-unlocker support content. +- Made the containerized secret gate scan the materialized source tree so linked Git worktrees cannot yield a misleading zero-commit pass. + +- Added a gated post-0.3.7 improvement roadmap covering catalog consistency, review-noise reduction, artwork quality, offline metadata, premium navigation, operations and full-story release hardening. +- Centralized visible-game filtering across dashboard, search and platform totals; status now distinguishes excluded support titles and full/incremental scan recency. +- Added deterministic platform-suffix and possessive cleanup while retaining manual title locks and source evidence. +- Added server-side review grouping plus exact-scope transactional bulk resolution and audited undo. +- Added persistent artwork quality evidence, deterministic portrait scoring and an artwork review query. +- Replaced synchronous full-catalog artwork enrichment with leased PostgreSQL jobs, durable progress and bounded retry. +- Simplified platform navigation, removed the duplicated Library platform strip, added semantic active states and persisted Library layout preference. +- Added rich-metadata claim endpoints and semantic Overview, Releases, Files & Integrity, Artwork and Provenance game-detail tabs. +- Added bounded LaunchBox metadata snapshots, durable metadata-enrichment jobs, provider freshness/health reporting and last-known-good offline imports without overriding manual claims. +- Added Vitest/Testing Library infrastructure, split API rate limits with correct 429 responses, reduced routine artwork log noise and restored favicon compatibility. +- Added actionable Attention filters for cause/path, library, platform, severity and age; library settings now show latest scan health and Activity names metadata jobs in operator language. +- Added a bounded operational metrics endpoint and Settings pulse for job queue depth, scan throughput, review backlog, artwork quality and provider failures. +- Preserved each downloaded/uploaded artwork version in app-owned storage and added game-scoped candidate listing plus conflict-safe manual cover selection in the Artwork detail tab. +- Added persistent card density, removable active-filter chips, reset controls, a collapsible mobile filter sheet and contextual Library actions; expanded frontend preference and browser-flow coverage. +- Added exact-scope bulk defer with audit/undo and removed raw finding diagnostics from support bundles by construction. +- Consolidated `STATUS.md` around current 0.3.8-dev evidence so historical release claims cannot be mistaken for candidate validation. +- Extracted and tested read/mutation/expensive-job rate-limit profiles, including metadata enrichment, while retaining runtime `429` and `Retry-After` behavior. +- Changed artwork enrichment to continue across providers when a candidate is below the verified portrait threshold, preserve each candidate and deterministically select the best non-rejected result; successful reconciliation now queues idempotent artwork and metadata jobs automatically. +- Added durable per-scan new/changed/moved/deleted counters, set-based missing reconciliation for large libraries and operator-readable job duration/running state in Activity. +- Added schema 11 game-to-library ownership links and a bounded library-health aggregate for artifact/game counts, detected topology and last full/quick scans in Settings. +- Parameterized Compose image/appdata isolation and added a fail-closed Unraid candidate gate covering schema, health, read-only mounts, source manifests, Playwright/axe, restart, dump/restore, SBOM, secret and vulnerability scans. +- Added responsive in-context editing for all rich game metadata fields, with durable manual locks, conflict visibility, save feedback and provider-refresh protection explained at the point of use. +- Added schema 12 persistent artifact overrides and exact-scope Attention actions for support classification, platform acceptance and bounded folder exclusions, including rescan survival, evidence, audit and complete artifact/library/rule undo. +- Replaced filename-only exclusion matching with bounded relative `*`, `**` and `?` glob semantics and regression coverage. +- Expanded the candidate browser gate with locked metadata editing/provenance, bulk-classification undo, horizontal-overflow assertions at 320/360/390/430/1280/1440/1920/3440 widths and a 200% zoom check. +- Made the isolated candidate gate run locked .NET restore/format/build/tests with PostgreSQL Testcontainers enabled and frontend unit/build/audit before browser, restart, restore and security evidence. +- Fixed clean non-root appdata ownership and removed the fixed shared Compose network name so candidate and production projects receive isolated networks. +- Made a clean candidate self-bootstrap and verify all three configured roots, complete bounded quick scans before browser gates, and use the empirically detected `/mnt/disks/PS5-Games` archive instead of the empty nominal share. + +### Complete verified cover catalog 0.3.7 + +- Added a bounded, cached and streaming LaunchBox metadata provider without requiring an API key; catalog ZIP size, XML entry size and compression ratio are constrained and source archives are never extracted. +- Added platform-aware exact and conservative fuzzy matching plus deterministic aliases for scene names, edition labels, Roman numerals and common abbreviated titles. +- Excluded Steam launcher, Frosty Mod Tool and DLC-unlocker roots from the visible game catalog while preserving their scan evidence as non-game support content. +- Reached verified live artwork coverage for all 389 active game titles: 533 stored game records processed, 533 covers available, zero unmatched and zero failed. + +### Platform collection rail and Nintendo covers 0.3.6 + +- Replaced the abbreviated Collections submenu with a complete, scrollable collection rail containing all platforms, platform-family badges, file and game counts, selected states and an explicit all-games entry. +- Added high-confidence artwork matching against Nintendo's official European product catalog, including verified Switch packshots. +- Improved lookup normalization for scene-style region, platform and release-group suffixes and added conservative fuzzy fallback within platform-specific Libretro catalogs. +- Increased proven cover coverage on the live dataset candidate from 262 to 328 of 394 visible games without substituting uncertain artwork. + +### Premium widescreen library and verified cover enrichment 0.3.5 + +- Expanded the desktop canvas to 2180 px with a polished 278 px collection rail and an eight-card ultrawide library grid. +- Refined the desktop platform hierarchy, active states, archive safety indicator, mobile bottom navigation and responsive filter layout. +- Added bounded, cancellable and serialized cover enrichment from official Libretro thumbnail catalogs and exact or high-confidence Steam Store matches. +- Persisted artwork source provenance and exposed it in the game drawer; uploaded and read-only library sidecar covers remain authoritative. +- Added an authenticated `Find covers` workflow while retaining deterministic generated artwork whenever external catalogs are unavailable or cannot be matched safely. +- Verified desktop at 2560×1080 and mobile at 390×844 without page overflow, plus production workflow and WCAG A/AA gates. + +### Archive experience and managed artwork 0.3.4 + +- Reworked the application shell, Home, Library, cards, drawers, scan states and empty states with the supplied Stitch Archive Intelligence visual language. +- Added a bounded app-owned artwork store with validated JPEG, PNG and WebP uploads; source libraries remain read-only. +- Added a deterministic artwork cascade: uploaded cover, library sidecar, then a platform-specific generated cover, so every game remains visually identifiable offline. +- Added lazy artwork loading and bounded card rendering for large collections. +- Reduced desktop navigation noise by showing platform shortcuts only inside Library and retained complete platform browsing in the Library topology. +- Added animated archive, scan and empty-state illustrations with reduced-motion fallbacks. +- Corrected mobile filter overflow and completed light-theme WCAG contrast coverage. +- Expanded integration and browser gates for artwork persistence, mobile platform navigation and production UI workflows. + +### Library intelligence 0.3.3 + +- Added live platform subnavigation beneath Library in the desktop navigation rail, including real game counts and selected state. +- Added deterministic non-game content policy for tool, emulator, BIOS, firmware, cache, save, runtime and console-support directories. These records remain auditable as ignored support files but no longer inflate Unknown or Attention. +- Added canonical title derivation for region/revision tags, repack markers, scene-release suffixes, disc suffixes and dot/underscore filename notation while preserving manual title locks. +- Added bounded authenticated delivery of matching local sidecar artwork from read-only libraries, with abstract offline placeholders when no proven artwork exists. +- Automatically resolves obsolete Unknown reviews when the corresponding artifact is deterministically classified as non-game support content. + +### Platform navigation and Stitch integration 0.3.2 + +- Added complete server-side platform filtering before game count, ordering and pagination. +- Added an always-visible platform navigator to Games and a richer, data-backed platform overview that opens filtered collections directly. +- Integrated Stitch's Archive Intelligence tokens, tonal surfaces, typography hierarchy, circular platform motifs and compact mobile collection patterns without importing fictional features or copyrighted artwork. +- Corrected the inventory export application version, which had remained at 0.1.0, and aligned API, support bundles, exports and deployment image on 0.3.2. +- Added PostgreSQL regression coverage for positive and empty platform filters and expanded desktop/mobile browser journeys for platform navigation. + +### UX simplification 0.3.1 + +- Replaced thirteen architecture-oriented destinations with Home, Library, Attention, Activity and Settings. +- Rebuilt Home as a compact operational dashboard with one scan action and four primary metrics. +- Made Games the default collection view and integrated Platforms, Windows installers and Advanced Files into Library. +- Added deterministic local placeholder covers, browse filters, layouts, focused creation and game/file drawers with collapsed technical evidence. +- Unified review and health signals into one prioritized Attention inbox; unified scans, history, snapshots and exports under Activity. +- Moved identification catalogs into sectioned Settings and added a focused library wizard with advanced policies hidden by default. +- Added real grouped global search for games, platforms, files, paths and explicit hashes with Ctrl/Cmd+K and direct detail navigation. +- Split the former 64 KB frontend entry point into feature-oriented app, API, component and workspace modules. +- Fixed persisted integrity-snapshot JSON deserialization and added a deterministic regression test. +- Replaced the old route-count browser tests with five-destination desktop/mobile journeys, overlay focus, screenshots and accessibility checks. + +### Completion pass + +- Added bounded MSI database-table inspection for product properties, media ranges, file relationships and embedded/external cabinets without extraction or installer APIs. +- Added deterministic SNES/NDS recognition and richer ROM header provenance for Nintendo and Sega cartridge families. +- Added persisted operational settings, schema 6 optimistic concurrency, bounded retention/workflow controls and a bounded PostgreSQL pool. +- Added dark/light/system appearance, reusable animated archive illustrations and responsive reduced-motion behavior. +- Expanded the operational Settings workspace with analyzer capabilities, privacy guarantees and server preferences. +- Added correlation-scoped structured HTTP completion logs without credential/header logging. +- Corrected the application/schema capability surfaces to version 0.3.0/schema 6. +- Verified an isolated restored candidate stack, PostgreSQL 16.14 container gates, desktop/mobile Playwright, axe, source immutability, Gitleaks, CycloneDX and Grype gates. + +- Added PostgreSQL 16.14 Testcontainers coverage for repeatable migrations, persisted evidence, restart survival, idempotent scans, audited review undo and optimistic concurrency. +- Added a 250,000-artifact PostgreSQL search gate and a trigram index for bounded contains-search query plans. +- Replaced unsupported Npgsql multi-statement mutations with explicit atomic transactions. +- Added bounded PE version-resource and MSI SummaryInformation inspection without invoking executable code, COM or Windows Installer. +- Added server-side game search/pagination and operational release creation to the game detail workspace. +- Added editable per-root hash, archive-inspection and concurrency policies plus complete new-root recursion controls. +- Added bounded static 7z header enumeration with member/path/size/ratio limits and deterministic synthetic tests; no member is extracted. +- Added reproducible Gitleaks, Syft and Grype gates with pinned scanner image digests and retained JSON/CycloneDX evidence. +- Added desktop/mobile Playwright workflow coverage for metrics, search, review filters, keyboard navigation and responsive overflow. +- Added deterministic 10,000 and 100,000 item classifier scale gates with time and allocation ceilings. + +### Product-audit remediation + +- Replaced the hardcoded archive confidence score with persisted-data metrics. +- Added artifact evidence details, exact-duplicate relationships and archive/bundle context. +- Added operational Storage, Library Health and Windows Archive views. +- Added artifact pagination and bounded searchable game browsing for large collections. +- Added review deferral, scan pause/resume/cancel/retry controls and real progress calculation. +- Added direct root verification, quick/deep scan actions and enable/disable management. +- Improved mobile data density, keyboard artifact inspection and responsive detail layouts. + +### Added + +- Phase 0–2 production-oriented vertical slice: domain model, PostgreSQL schema, safe scanning, streamed hashes, evidence-backed classification, exact duplicate query, durable scan records and responsive web application. +- CUE/M3U bundle validation foundation, review/audit endpoints, capability reporting, inventory exports and synthetic fixtures. +- Non-root container build, hardened Compose stack, Unraid template and operational runbook. +- Rescan-stable CUE/M3U and Windows installer bundles, static PE inspection and additional ROM signatures. +- Streaming Logiqx catalog import, explained exact-hash matching, immutable snapshots, comparison and complete checksum manifests. +- Operational Catalog Sources and Snapshots & Exports screens backed by PostgreSQL APIs. +- Manual games and releases with append-only locked claims, optimistic concurrency, audited review resolutions and compensating undo. +- Stable health-rule findings and sanitized, path-redacted support bundles exposed through the API and UI. +- Bounded ZIP central-directory inspection with persisted virtual members and no archive extraction. +- Bounded-memory JSON, CSV and SHA256SUMS exports with format version headers, escaping and cancellation cleanup. +- Configurable host port defaulting to 1230 and a pinned PostgreSQL 16.14 deployment baseline for verified Unraid compatibility. +- Local administrator-token login with tab-scoped credential storage, explicit sign-out and a publicly loadable application shell. +- Three independent deployment roots for the general Games collection and dedicated PS4/PS5 mounts. +- Evidence-backed platform inference for PC, Nintendo and PlayStation directory taxonomies, including dedicated PS4/PS5 roots. +- Configurable non-root runtime UID/GID for read-only libraries with owner-only directory permissions. +- Honest directory-versus-extension conflict handling prevents multipart volumes from being mislabeled as another platform. +- Idempotent scan-to-game materialization with directory provenance, real platform aggregates and an accessible mobile navigation drawer. + +### Security + +- Alpine production images include the Kerberos/GSS runtime required for stable PostgreSQL connections; Linux read-only verification uses managed mount metadata without unsafe native structs. +- Source filesystem interface exposes read operations only and rejects descriptor path traversal. +- Scanned content is never launched; source mounts are read-only in deployment definitions. +- Support bundles omit library paths, raw scan errors, credentials and source content. +- Production startup fails closed when the PostgreSQL connection string is absent instead of silently targeting localhost. +- Upgraded the vulnerable transitive `Microsoft.OpenApi` 2.0.0 dependency to 2.11.0; current NuGet and npm audits report no known vulnerable packages. + +### Known limitations + +- See `STATUS.md`; intentional unsupported capabilities are reported explicitly. The gated 0.3.0 candidate is awaiting only the controlled live replacement and post-deployment validation. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..2717031 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,12 @@ +# Contributing + +Keep Ludarium changes portable, legally redistributable and safe for private libraries. + +- Branch from the protected default branch. +- Use synthetic metadata and homebrew/public-domain fixtures; do not commit ROMs, firmware, keys, private library databases or account data. +- Document the origin, license and checksum expectations for emulator components, icons, metadata sources and any bundled asset. +- Keep player URLs, bind addresses and storage roots configurable; examples must not contain operator-specific infrastructure. +- Add tests for behavioural changes and run the documented .NET, frontend, container and managed validation gates. +- Explain schema, migration, content, player-isolation, security and deployment impact in the pull request. + +Report vulnerabilities according to `SECURITY.md`. diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..0f25d5e --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,15 @@ + + + $([System.IO.File]::ReadAllText('$(MSBuildThisFileDirectory)VERSION').Trim()) + $(LudariumVersion) + latest + enable + enable + true + latest-recommended + $(NoWarn);CA1050;CA1725;CA1727;CA1848 + true + true + true + + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7a7d914 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,112 @@ +# syntax=docker/dockerfile:1.7 +FROM alpine:3.23@sha256:fd791d74b68913cbb027c6546007b3f0d3bc45125f797758156952bc2d6daf40 AS emulator +ARG EMULATORJS_VERSION=4.2.3 +ARG EMULATORJS_SHA256=07d451bc06fa3ad04ab30d9b94eb63ac34ad0babee52d60357b002bde8f3850b +COPY .build-inputs/emulatorjs/ /tmp/emulatorjs-input/ +RUN fetch() { \ + url="$1"; destination="$2"; attempt=1; \ + while :; do \ + if wget -q -T 60 "$url" -O "${destination}.part"; then mv "${destination}.part" "$destination"; return 0; fi; \ + rm -f "${destination}.part"; \ + [ "$attempt" -lt 5 ] || return 1; \ + sleep "$attempt"; attempt=$((attempt + 1)); \ + done; \ + } \ + && apk add --no-cache p7zip \ + && if [ -f "/tmp/emulatorjs-input/${EMULATORJS_VERSION}.7z" ]; then cp "/tmp/emulatorjs-input/${EMULATORJS_VERSION}.7z" /tmp/emulatorjs.7z; else fetch "https://github.com/EmulatorJS/EmulatorJS/releases/download/v${EMULATORJS_VERSION}/${EMULATORJS_VERSION}.7z" /tmp/emulatorjs.7z; fi \ + && echo "${EMULATORJS_SHA256} /tmp/emulatorjs.7z" | sha256sum -c - \ + && 7z x -y /tmp/emulatorjs.7z -o/emulatorjs >/dev/null \ + && find /emulatorjs/data/cores -type f ! -path '/emulatorjs/data/cores/reports/*' ! -name 'fceumm-*' ! -name 'snes9x-*' ! -name 'gambatte-*' ! -name 'mgba-*' ! -name 'melonds-*' ! -name 'genesis_plus_gx-*' ! -name 'pcsx_rearmed-*' ! -name 'mednafen_psx_hw-*' ! -name 'ppsspp-*' ! -name 'cores.json' ! -name 'core-README.md' -delete \ + && sed -i 's#https://cdn.emulatorjs.org/stable/data/version.json#/emulatorjs/data/version.json#g' /emulatorjs/data/emulator.min.js /emulatorjs/data/src/emulator.js \ + && ! grep -R -F 'https://cdn.emulatorjs.org/stable/data/version.json' /emulatorjs/data \ + && printf '%s\n' 'EmulatorJS 4.2.3 update metadata is resolved from the bundled same-origin version.json; no runtime CDN request is allowed.' > /emulatorjs/LUDARIUM_PATCHES.txt \ + && rm /tmp/emulatorjs.7z + +FROM alpine:3.23@sha256:fd791d74b68913cbb027c6546007b3f0d3bc45125f797758156952bc2d6daf40 AS playjs +ARG PLAYJS_UPSTREAM_COMMIT=04bde0df87ee7c0e2f0151b51bb2cc22c88541da +ARG PLAYJS_JS_SHA256=5599ff16ae5e3534f9b779736aedfa20661821d57be7f03e587e31ef8d090317 +ARG PLAYJS_WASM_SHA256=de3ae0efdd7938d1b5ac754a096ec180094d64702ae84b962c4bb9f79abb1da7 +ARG PLAYJS_LICENSE_SHA256=e9089da5950600267bb657503aba6eb59cb679b455c2997d341d83144144daab +RUN fetch() { \ + url="$1"; destination="$2"; attempt=1; \ + while :; do \ + if wget -q -T 60 "$url" -O "${destination}.part"; then mv "${destination}.part" "$destination"; return 0; fi; \ + rm -f "${destination}.part"; \ + [ "$attempt" -lt 5 ] || return 1; \ + sleep "$attempt"; attempt=$((attempt + 1)); \ + done; \ + } \ + && mkdir -p /playjs \ + && fetch https://playjs.purei.org/Play.js /playjs/Play.js \ + && fetch https://playjs.purei.org/Play.wasm /playjs/Play.wasm \ + && fetch "https://raw.githubusercontent.com/jpd002/Play-/${PLAYJS_UPSTREAM_COMMIT}/License.txt" /playjs/LICENSE.txt \ + && echo "${PLAYJS_JS_SHA256} /playjs/Play.js" | sha256sum -c - \ + && echo "${PLAYJS_WASM_SHA256} /playjs/Play.wasm" | sha256sum -c - \ + && echo "${PLAYJS_LICENSE_SHA256} /playjs/LICENSE.txt" | sha256sum -c - \ + && printf '%s\n' \ + "source=https://github.com/jpd002/Play-" \ + "commit=${PLAYJS_UPSTREAM_COMMIT}" \ + "Play.js.sha256=${PLAYJS_JS_SHA256}" \ + "Play.wasm.sha256=${PLAYJS_WASM_SHA256}" \ + "License.txt.sha256=${PLAYJS_LICENSE_SHA256}" > /playjs/PROVENANCE.txt + +FROM alpine:3.23@sha256:fd791d74b68913cbb027c6546007b3f0d3bc45125f797758156952bc2d6daf40 AS n64wasm +ARG N64WASM_COMMIT=bfac222f8a27287022844b47000328531834e9c1 +ARG N64WASM_JS_SHA256=1db1dce5e356d1a1673d5dab6f73afd30bf4b69523c3292b1d44e33ff8f4d22e +ARG N64WASM_WASM_SHA256=c829ac2ac2a620816a3906af1135c1203f606b92ae7a6e118ea8a90749f028ea +ARG N64WASM_ASSETS_SHA256=72f8b414469d0b40630c82ca193b6f67dba906bd84ccc43991ee1ac3e4b900a2 +ARG N64WASM_LICENSE_SHA256=46f8e130794f92e25e5865be3a363c3fb30210a428303c27262690cfc375c3d4 +RUN fetch() { \ + url="$1"; destination="$2"; attempt=1; \ + while :; do \ + if wget -q -T 60 "$url" -O "${destination}.part"; then mv "${destination}.part" "$destination"; return 0; fi; \ + rm -f "${destination}.part"; \ + [ "$attempt" -lt 5 ] || return 1; \ + sleep "$attempt"; attempt=$((attempt + 1)); \ + done; \ + } \ + && mkdir -p /n64wasm \ + && fetch "https://raw.githubusercontent.com/nbarkhina/N64Wasm/${N64WASM_COMMIT}/dist/n64wasm.js" /n64wasm/n64wasm.js \ + && fetch "https://raw.githubusercontent.com/nbarkhina/N64Wasm/${N64WASM_COMMIT}/dist/n64wasm.wasm" /n64wasm/n64wasm.wasm \ + && fetch "https://raw.githubusercontent.com/nbarkhina/N64Wasm/${N64WASM_COMMIT}/dist/assets.zip" /n64wasm/assets.zip \ + && fetch "https://raw.githubusercontent.com/nbarkhina/N64Wasm/${N64WASM_COMMIT}/LICENSE" /n64wasm/LICENSE \ + && echo "${N64WASM_JS_SHA256} /n64wasm/n64wasm.js" | sha256sum -c - \ + && echo "${N64WASM_WASM_SHA256} /n64wasm/n64wasm.wasm" | sha256sum -c - \ + && echo "${N64WASM_ASSETS_SHA256} /n64wasm/assets.zip" | sha256sum -c - \ + && echo "${N64WASM_LICENSE_SHA256} /n64wasm/LICENSE" | sha256sum -c - + +FROM node:24.4.1-alpine@sha256:820e86612c21d0636580206d802a726f2595366e1b867e564cbc652024151e8a AS web +WORKDIR /src +COPY src/Ludarium.Web/package.json src/Ludarium.Web/package-lock.json ./ +RUN npm ci --ignore-scripts +COPY src/Ludarium.Web/ ./ +RUN npm run build + +FROM mcr.microsoft.com/dotnet/sdk:10.0.302@sha256:72dd743782f2ae7e5476fd64f6a460045e3998dc862218b80e6944cba79a01b0 AS build +WORKDIR /src +COPY NuGet.config global.json Directory.Build.props VERSION Ludarium.slnx ./ +COPY src/ ./src/ +RUN dotnet restore src/Ludarium.Api/Ludarium.Api.csproj --locked-mode \ + && dotnet restore src/Ludarium.HealthCheck/Ludarium.HealthCheck.csproj --locked-mode +COPY --from=web /artifacts/web/ ./src/Ludarium.Api/wwwroot/ +COPY --from=emulator /emulatorjs/ ./src/Ludarium.Api/wwwroot/emulatorjs/ +COPY --from=playjs /playjs/ ./src/Ludarium.Api/wwwroot/playjs/ +COPY --from=n64wasm /n64wasm/ ./src/Ludarium.Api/wwwroot/n64wasm/ +RUN dotnet publish src/Ludarium.Api/Ludarium.Api.csproj -c Release --no-restore -o /app/publish /p:UseAppHost=false \ + && dotnet publish src/Ludarium.HealthCheck/Ludarium.HealthCheck.csproj -c Release --no-restore -o /app/health /p:UseAppHost=false + +FROM mcr.microsoft.com/dotnet/aspnet:10.0.11-azurelinux3.0-distroless@sha256:c2fbd15651452871430ce3fbd0083c128b376dbac786797be1c7dd4091d20a23 AS final +ARG LUDARIUM_SOURCE_REVISION=local +ARG LUDARIUM_RELEASE_VERSION=unversioned +LABEL org.opencontainers.image.revision="${LUDARIUM_SOURCE_REVISION}" +LABEL org.opencontainers.image.version="${LUDARIUM_RELEASE_VERSION}" +WORKDIR /app +ENV ASPNETCORE_URLS=http://0.0.0.0:8734 \ + DOTNET_EnableDiagnostics=0 \ + LUDARIUM_EXPORTS=/app/exports +COPY --from=build --chown=$APP_UID:0 /app/publish ./ +COPY --from=build --chown=$APP_UID:0 /app/health ./health/ +USER $APP_UID +EXPOSE 8734 +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 CMD ["dotnet", "/app/health/Ludarium.HealthCheck.dll"] +ENTRYPOINT ["dotnet", "Ludarium.Api.dll"] diff --git a/Dockerfile.dolphin-controller b/Dockerfile.dolphin-controller new file mode 100644 index 0000000..b353e86 --- /dev/null +++ b/Dockerfile.dolphin-controller @@ -0,0 +1,15 @@ +# syntax=docker/dockerfile:1.7 +FROM lscr.io/linuxserver/dolphin:2606-ls78@sha256:7b916f1c97d591b96525b67128c1bf82ce6ede0eff6816fe1c0a3d70f744506d +ARG DEBIAN_FRONTEND=noninteractive +RUN apt-get update \ + && apt-get install -y --no-install-recommends python3 xdotool \ + && apt-get purge -y --auto-remove \ + bsd-mailx catatonit cmake containerd.io cron curl docker-buildx-plugin docker-ce docker-ce-cli \ + docker-compose-plugin exim4-base exim4-config exim4-daemon-light fuse-overlayfs g++ gcc git make \ + openssh-client perl perl-modules-5.40 \ + && rm -f /etc/s6-overlay/s6-rc.d/user/contents.d/svc-cron /etc/s6-overlay/s6-rc.d/user/contents.d/svc-docker \ + && rm -rf /var/lib/apt/lists/* +COPY --chmod=644 deploy/ludarium_sidecar.py /opt/ludarium/ludarium_sidecar.py +COPY --chmod=755 deploy/dolphin-controller.py /opt/ludarium/dolphin-controller.py +COPY --chmod=755 deploy/dolphin-controller-run /custom-services.d/ludarium-dolphin-controller +RUN sed -i 's/\r$//' /custom-services.d/ludarium-dolphin-controller diff --git a/Dockerfile.eden-controller b/Dockerfile.eden-controller new file mode 100644 index 0000000..bc9b4b2 --- /dev/null +++ b/Dockerfile.eden-controller @@ -0,0 +1,37 @@ +# syntax=docker/dockerfile:1.7 +FROM lscr.io/linuxserver/eden@sha256:f18ec24d55266daab9a440aba5fe3e9587494cf7a9aad63f93806efe4081be83 +ARG DEBIAN_FRONTEND=noninteractive +RUN apt-get update \ + && apt-get install -y --no-install-recommends libexpat1=2.8.3-1~deb13u1 \ + && apt-get purge -y --auto-remove \ + bsd-mailx \ + catatonit \ + cmake \ + containerd.io \ + cron \ + curl \ + docker-buildx-plugin \ + docker-ce \ + docker-ce-cli \ + docker-compose-plugin \ + exim4-base \ + exim4-config \ + exim4-daemon-light \ + fuse-overlayfs \ + g++ \ + gcc \ + git \ + make \ + openssh-client \ + perl \ + perl-modules-5.40 \ + && rm -f \ + /etc/s6-overlay/s6-rc.d/user/contents.d/svc-cron \ + /etc/s6-overlay/s6-rc.d/user/contents.d/svc-docker \ + && rm -rf /var/lib/apt/lists/* +COPY --chmod=644 deploy/ludarium_sidecar.py /opt/ludarium/ludarium_sidecar.py +COPY --chmod=755 deploy/eden-controller.py /opt/ludarium/eden-controller.py +COPY --chmod=755 deploy/eden-controller-run /custom-services.d/ludarium-controller +COPY --chmod=755 deploy/audit-eden-runtime.sh /opt/ludarium/audit-eden-runtime.sh +COPY deploy/security/eden-controller-0.4.9.openvex.json /usr/share/ludarium/security/eden-controller.openvex.json +RUN sed -i 's/\r$//' /custom-services.d/ludarium-controller diff --git a/Dockerfile.unraid b/Dockerfile.unraid new file mode 100644 index 0000000..13b10c6 --- /dev/null +++ b/Dockerfile.unraid @@ -0,0 +1,144 @@ +# syntax=docker/dockerfile:1.7 +FROM alpine:3.23@sha256:fd791d74b68913cbb027c6546007b3f0d3bc45125f797758156952bc2d6daf40 AS emulator +ARG EMULATORJS_VERSION=4.2.3 +ARG EMULATORJS_SHA256=07d451bc06fa3ad04ab30d9b94eb63ac34ad0babee52d60357b002bde8f3850b +COPY .build-inputs/emulatorjs/ /tmp/emulatorjs-input/ +RUN fetch() { \ + url="$1"; destination="$2"; attempt=1; \ + while :; do \ + if wget -q -T 60 "$url" -O "${destination}.part"; then mv "${destination}.part" "$destination"; return 0; fi; \ + rm -f "${destination}.part"; \ + [ "$attempt" -lt 5 ] || return 1; \ + sleep "$attempt"; attempt=$((attempt + 1)); \ + done; \ + } \ + && apk add --no-cache p7zip \ + && if [ -f "/tmp/emulatorjs-input/${EMULATORJS_VERSION}.7z" ]; then cp "/tmp/emulatorjs-input/${EMULATORJS_VERSION}.7z" /tmp/emulatorjs.7z; else fetch "https://github.com/EmulatorJS/EmulatorJS/releases/download/v${EMULATORJS_VERSION}/${EMULATORJS_VERSION}.7z" /tmp/emulatorjs.7z; fi \ + && echo "${EMULATORJS_SHA256} /tmp/emulatorjs.7z" | sha256sum -c - \ + && 7z x -y /tmp/emulatorjs.7z -o/emulatorjs >/dev/null \ + && find /emulatorjs/data/cores -type f ! -path '/emulatorjs/data/cores/reports/*' ! -name 'fceumm-*' ! -name 'snes9x-*' ! -name 'gambatte-*' ! -name 'mgba-*' ! -name 'melonds-*' ! -name 'genesis_plus_gx-*' ! -name 'pcsx_rearmed-*' ! -name 'mednafen_psx_hw-*' ! -name 'ppsspp-*' ! -name 'cores.json' ! -name 'core-README.md' -delete \ + && sed -i 's#https://cdn.emulatorjs.org/stable/data/version.json#/emulatorjs/data/version.json#g' /emulatorjs/data/emulator.min.js /emulatorjs/data/src/emulator.js \ + && ! grep -R -F 'https://cdn.emulatorjs.org/stable/data/version.json' /emulatorjs/data \ + && printf '%s\n' 'EmulatorJS 4.2.3 update metadata is resolved from the bundled same-origin version.json; no runtime CDN request is allowed.' > /emulatorjs/LUDARIUM_PATCHES.txt \ + && rm /tmp/emulatorjs.7z + +FROM alpine:3.23@sha256:fd791d74b68913cbb027c6546007b3f0d3bc45125f797758156952bc2d6daf40 AS playjs +ARG PLAYJS_UPSTREAM_COMMIT=04bde0df87ee7c0e2f0151b51bb2cc22c88541da +ARG PLAYJS_JS_SHA256=5599ff16ae5e3534f9b779736aedfa20661821d57be7f03e587e31ef8d090317 +ARG PLAYJS_WASM_SHA256=de3ae0efdd7938d1b5ac754a096ec180094d64702ae84b962c4bb9f79abb1da7 +ARG PLAYJS_LICENSE_SHA256=e9089da5950600267bb657503aba6eb59cb679b455c2997d341d83144144daab +RUN fetch() { \ + url="$1"; destination="$2"; attempt=1; \ + while :; do \ + if wget -q -T 60 "$url" -O "${destination}.part"; then mv "${destination}.part" "$destination"; return 0; fi; \ + rm -f "${destination}.part"; \ + [ "$attempt" -lt 5 ] || return 1; \ + sleep "$attempt"; attempt=$((attempt + 1)); \ + done; \ + } \ + && mkdir -p /playjs \ + && fetch https://playjs.purei.org/Play.js /playjs/Play.js \ + && fetch https://playjs.purei.org/Play.wasm /playjs/Play.wasm \ + && fetch "https://raw.githubusercontent.com/jpd002/Play-/${PLAYJS_UPSTREAM_COMMIT}/License.txt" /playjs/LICENSE.txt \ + && echo "${PLAYJS_JS_SHA256} /playjs/Play.js" | sha256sum -c - \ + && echo "${PLAYJS_WASM_SHA256} /playjs/Play.wasm" | sha256sum -c - \ + && echo "${PLAYJS_LICENSE_SHA256} /playjs/LICENSE.txt" | sha256sum -c - \ + && printf '%s\n' \ + "source=https://github.com/jpd002/Play-" \ + "commit=${PLAYJS_UPSTREAM_COMMIT}" \ + "Play.js.sha256=${PLAYJS_JS_SHA256}" \ + "Play.wasm.sha256=${PLAYJS_WASM_SHA256}" \ + "License.txt.sha256=${PLAYJS_LICENSE_SHA256}" > /playjs/PROVENANCE.txt + +FROM alpine:3.23@sha256:fd791d74b68913cbb027c6546007b3f0d3bc45125f797758156952bc2d6daf40 AS n64wasm +ARG N64WASM_COMMIT=bfac222f8a27287022844b47000328531834e9c1 +ARG N64WASM_JS_SHA256=1db1dce5e356d1a1673d5dab6f73afd30bf4b69523c3292b1d44e33ff8f4d22e +ARG N64WASM_WASM_SHA256=c829ac2ac2a620816a3906af1135c1203f606b92ae7a6e118ea8a90749f028ea +ARG N64WASM_ASSETS_SHA256=72f8b414469d0b40630c82ca193b6f67dba906bd84ccc43991ee1ac3e4b900a2 +ARG N64WASM_LICENSE_SHA256=46f8e130794f92e25e5865be3a363c3fb30210a428303c27262690cfc375c3d4 +RUN fetch() { \ + url="$1"; destination="$2"; attempt=1; \ + while :; do \ + if wget -q -T 60 "$url" -O "${destination}.part"; then mv "${destination}.part" "$destination"; return 0; fi; \ + rm -f "${destination}.part"; \ + [ "$attempt" -lt 5 ] || return 1; \ + sleep "$attempt"; attempt=$((attempt + 1)); \ + done; \ + } \ + && mkdir -p /n64wasm \ + && fetch "https://raw.githubusercontent.com/nbarkhina/N64Wasm/${N64WASM_COMMIT}/dist/n64wasm.js" /n64wasm/n64wasm.js \ + && fetch "https://raw.githubusercontent.com/nbarkhina/N64Wasm/${N64WASM_COMMIT}/dist/n64wasm.wasm" /n64wasm/n64wasm.wasm \ + && fetch "https://raw.githubusercontent.com/nbarkhina/N64Wasm/${N64WASM_COMMIT}/dist/assets.zip" /n64wasm/assets.zip \ + && fetch "https://raw.githubusercontent.com/nbarkhina/N64Wasm/${N64WASM_COMMIT}/LICENSE" /n64wasm/LICENSE \ + && echo "${N64WASM_JS_SHA256} /n64wasm/n64wasm.js" | sha256sum -c - \ + && echo "${N64WASM_WASM_SHA256} /n64wasm/n64wasm.wasm" | sha256sum -c - \ + && echo "${N64WASM_ASSETS_SHA256} /n64wasm/assets.zip" | sha256sum -c - \ + && echo "${N64WASM_LICENSE_SHA256} /n64wasm/LICENSE" | sha256sum -c - + +FROM node:24.4.1-alpine@sha256:820e86612c21d0636580206d802a726f2595366e1b867e564cbc652024151e8a AS web +WORKDIR /src +COPY src/Ludarium.Web/package.json src/Ludarium.Web/package-lock.json ./ +RUN npm ci --ignore-scripts +COPY src/Ludarium.Web/ ./ +RUN npm run build + +FROM mcr.microsoft.com/dotnet/sdk:10.0.302@sha256:72dd743782f2ae7e5476fd64f6a460045e3998dc862218b80e6944cba79a01b0 AS build +WORKDIR /src +COPY NuGet.config global.json Directory.Build.props VERSION Ludarium.slnx ./ +COPY src/ ./src/ +RUN dotnet restore src/Ludarium.Api/Ludarium.Api.csproj --locked-mode \ + && dotnet restore src/Ludarium.HealthCheck/Ludarium.HealthCheck.csproj --locked-mode +COPY --from=web /artifacts/web/ ./src/Ludarium.Api/wwwroot/ +COPY --from=emulator /emulatorjs/ ./src/Ludarium.Api/wwwroot/emulatorjs/ +COPY --from=playjs /playjs/ ./src/Ludarium.Api/wwwroot/playjs/ +COPY --from=n64wasm /n64wasm/ ./src/Ludarium.Api/wwwroot/n64wasm/ +RUN dotnet publish src/Ludarium.Api/Ludarium.Api.csproj -c Release --no-restore -o /app/publish /p:UseAppHost=false \ + && dotnet publish src/Ludarium.HealthCheck/Ludarium.HealthCheck.csproj -c Release --no-restore -o /app/health /p:UseAppHost=false + +FROM postgres:16.15-bookworm@sha256:60f4761b9035e0b8d5218f701a8c3382f641bf12b1604822574cf5be3baeb537 AS postgres-entrypoint +FROM mcr.microsoft.com/dotnet/aspnet:10.0.11-noble@sha256:a4556ed033fa96f984bb7a8d348851cb2d36b1281dd2420070045f664fbb5f94 AS final +ARG DEBIAN_FRONTEND=noninteractive +ARG POSTGRES_PACKAGE_VERSION=16.15-0ubuntu0.24.04.1 +ARG GOSU_PACKAGE_VERSION=1.17-1ubuntu0.24.04.3 +ARG LOCALES_PACKAGE_VERSION=2.39-0ubuntu8.8 +ARG LUDARIUM_SOURCE_REVISION=local +ARG LUDARIUM_RELEASE_VERSION=unversioned +LABEL org.opencontainers.image.revision="${LUDARIUM_SOURCE_REVISION}" +LABEL org.opencontainers.image.version="${LUDARIUM_RELEASE_VERSION}" +RUN apt-get update \ + && apt-get upgrade -y \ + && apt-get install -y --no-install-recommends \ + "postgresql-16=${POSTGRES_PACKAGE_VERSION}" \ + "gosu=${GOSU_PACKAGE_VERSION}" \ + "locales=${LOCALES_PACKAGE_VERSION}" \ + && sed -i 's/^# *en_US.UTF-8 UTF-8/en_US.UTF-8 UTF-8/' /etc/locale.gen \ + && locale-gen en_US.UTF-8 \ + && locale -a | grep -Fx 'en_US.utf8' \ + && rm -rf /var/lib/apt/lists/* /var/lib/postgresql/16/main +WORKDIR /app +ENV ASPNETCORE_URLS=http://0.0.0.0:8734 \ + DOTNET_ROOT=/usr/share/dotnet \ + DOTNET_EnableDiagnostics=0 \ + DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false \ + LANG=C.UTF-8 \ + LC_ALL=C.UTF-8 \ + LUDARIUM_EXPORTS=/app/exports \ + PG_MAJOR=16 \ + PGDATA=/var/lib/postgresql/data \ + PATH=/usr/lib/postgresql/16/bin:$PATH \ + POSTGRES_DB=ludarium \ + POSTGRES_USER=ludarium +COPY --from=postgres-entrypoint /usr/local/bin/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh +COPY --from=postgres-entrypoint /usr/local/bin/docker-ensure-initdb.sh /usr/local/bin/docker-ensure-initdb.sh +COPY --from=build --chown=1654:1654 /app/publish ./ +COPY --from=build --chown=1654:1654 /app/health ./health/ +COPY --chmod=755 deploy/ludarium-entrypoint.sh /usr/local/bin/ludarium-entrypoint.sh +COPY --chmod=755 deploy/repair-postgres-collation.sh /usr/local/bin/repair-postgres-collation.sh +RUN ln -s docker-ensure-initdb.sh /usr/local/bin/docker-enforce-initdb.sh \ + && mkdir -p "$PGDATA" /docker-entrypoint-initdb.d \ + && chown -R postgres:postgres /var/lib/postgresql \ + && sed -i 's/\r$//' /usr/local/bin/ludarium-entrypoint.sh +EXPOSE 8734 +STOPSIGNAL SIGTERM +HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 CMD ["/usr/share/dotnet/dotnet", "/app/health/Ludarium.HealthCheck.dll"] +ENTRYPOINT ["/usr/local/bin/ludarium-entrypoint.sh"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..2beb9e1 --- /dev/null +++ b/LICENSE @@ -0,0 +1,662 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. + diff --git a/Ludarium.slnx b/Ludarium.slnx new file mode 100644 index 0000000..45fa7c7 --- /dev/null +++ b/Ludarium.slnx @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/NuGet.config b/NuGet.config new file mode 100644 index 0000000..4d736c1 --- /dev/null +++ b/NuGet.config @@ -0,0 +1,7 @@ + + + + + + + diff --git a/PUBLIC_SOURCE_MANIFEST.json b/PUBLIC_SOURCE_MANIFEST.json new file mode 100644 index 0000000..874cac7 --- /dev/null +++ b/PUBLIC_SOURCE_MANIFEST.json @@ -0,0 +1,1697 @@ +{ + "schemaVersion": 1, + "sourceRevision": "b6c55f1f2293ef8df2ea6665bff6868ca8d0ddca", + "generatedAt": "2026-09-03T00:08:10.625Z", + "files": [ + { + "path": ".dockerignore", + "bytes": 811, + "sha256": "5532e637dfc7a405f5e67fdb45ca3293335dd03817ba75c5c8ea609b21170ad2" + }, + { + "path": ".gitattributes", + "bytes": 125, + "sha256": "c3a787c6a4fdb24dfb1dccad62ba6d612e9f0631b5d4f1dc62f89d4dedf738f7" + }, + { + "path": ".gitea/workflows/public-validation.yml", + "bytes": 1214, + "sha256": "ba410837830f434eb9499afacbc035b2aefbb46dd214c9ff3a77be547734eca2" + }, + { + "path": ".gitignore", + "bytes": 424, + "sha256": "6c1242270a5e78722233abea2b1560e7114df9ea05706c25ba102d8bb1e1d42c" + }, + { + "path": ".gitleaks.toml", + "bytes": 164, + "sha256": "0aca986e058352ed5bffdf12a43ea75a35dddbf2143a7a1e99dcc9c291677dee" + }, + { + "path": "CHANGELOG.md", + "bytes": 62047, + "sha256": "f5307e8e4974e30b7fb7881d809fca1f7671fc9392deb9d03f556b9cad5736c2" + }, + { + "path": "CONTRIBUTING.md", + "bytes": 833, + "sha256": "b043333ffc2181745f4a12937f0d9d879b1573186bcdf3e008f94f4c6cfa010e" + }, + { + "path": "Directory.Build.props", + "bytes": 719, + "sha256": "ca93cdfe1c8abc15ca48901ddaff178676176fc08cc910d1fb65b0408c14d039" + }, + { + "path": "Dockerfile", + "bytes": 7185, + "sha256": "494018966786a6c96ca2f1091a627d5996c65da07282a1528b638be924c29f6f" + }, + { + "path": "Dockerfile.dolphin-controller", + "bytes": 1042, + "sha256": "4953632d627a9ea4fcd3b1e646e841ad55cb3160f739451bf903cf19a84a28e0" + }, + { + "path": "Dockerfile.eden-controller", + "bytes": 1410, + "sha256": "707dacfae42b2caba70168ccacd10b366a84025cdd2a1c8ae48ee3ac327896fb" + }, + { + "path": "Dockerfile.unraid", + "bytes": 8886, + "sha256": "51ca983e3826c1792e1c942d74b24b867592ef764055591c056317c7e9d13be8" + }, + { + "path": "LICENSE", + "bytes": 35186, + "sha256": "30226ff1d6775ea93c4b1dbcc5fe8b846f0e09d27fd03da0b6c599a15b017e55" + }, + { + "path": "Ludarium.slnx", + "bytes": 853, + "sha256": "f5abe846759240918eaa9b11f321c974f3f75f53c0e81f5dde53628e97ed88e1" + }, + { + "path": "NuGet.config", + "bytes": 224, + "sha256": "2a60d41e8c9c839a51092416c96e70e0dbff10afe3022a9c063ccb4eb9aacdee" + }, + { + "path": "README.md", + "bytes": 4838, + "sha256": "8a2b5a063881b8e4b9519ef7a5403b2124007030e2c8ea2df82ed02fb4ae79fa" + }, + { + "path": "SECURITY.md", + "bytes": 1750, + "sha256": "8d08b7b9e963347c6b12c5f2c311f0b816290b8a4de54447b0c2afa1348c0ba0" + }, + { + "path": "THIRD_PARTY_NOTICES.md", + "bytes": 1306, + "sha256": "09da1bc34aff15e70a5d4a420396a52f02c1b44671a680b9e4c7b1deb619b2bd" + }, + { + "path": "VERSION", + "bytes": 13, + "sha256": "d85dcce39f43861ff7231aeea09f6a33ff7811bb0930c8499e06fbe647ed397e" + }, + { + "path": "deploy/.env.example", + "bytes": 2197, + "sha256": "4daf35bfa9164aa80654c18709fe0ab6a987bf674b73b38cc2993d488e156fb1" + }, + { + "path": "deploy/README.md", + "bytes": 16798, + "sha256": "d15b46ebbcb29b89d92d08f86243bf4b6bd1b11fdb4fe772682c09b72dcac662" + }, + { + "path": "deploy/audit-eden-runtime.sh", + "bytes": 2350, + "sha256": "76cf7f604da869b015aee93ed5287784b427e230a48ff3771e6018e2fc8c1a8d" + }, + { + "path": "deploy/compose.dolphin.yml", + "bytes": 4051, + "sha256": "5af371b23b9b407f33ec47045bde0752e0047c9e5a8136f3748331656d178a0b" + }, + { + "path": "deploy/compose.switch.yml", + "bytes": 3795, + "sha256": "d5b5c7bfbaa99c3e87637819d4920cf50d52ad6f29fa87a834399b3812d138b3" + }, + { + "path": "deploy/compose.yml", + "bytes": 2460, + "sha256": "b125469a7268575de854c3cbbc8bcc290d0be4bfb0d62cb2e8548e8249ee1f5d" + }, + { + "path": "deploy/dolphin-controller-run", + "bytes": 89, + "sha256": "b03295a6483293dd0619bcdff1bf18ac9b6ddfec6511ce6036265e158580509d" + }, + { + "path": "deploy/dolphin-controller.py", + "bytes": 2167, + "sha256": "03e2ca8fb777cc6491b9914e859c7d047ecf353e25656b43ce5c1e8aeb25e286" + }, + { + "path": "deploy/eden-controller-run", + "bytes": 86, + "sha256": "6157ec6b1d3518b0784826d8771190e67fcb2aaab26de8074291d4bdcff18c85" + }, + { + "path": "deploy/eden-controller.py", + "bytes": 1689, + "sha256": "f6023f49cec7d23d4e595c96929b064f8dda9c47fde93dd885d247ba971a97b2" + }, + { + "path": "deploy/ludarium-entrypoint.sh", + "bytes": 2219, + "sha256": "d36d9dde7c4d5fb3c8218dd5d30b37ff7e1991fc804d5e9cd276bdf7ef40b4c0" + }, + { + "path": "deploy/ludarium-unraid.xml", + "bytes": 4929, + "sha256": "b14e488d0d553b051083cfeb13074fc97b08f46f7b9a9112d6c6277b8cd3447d" + }, + { + "path": "deploy/ludarium_sidecar.py", + "bytes": 21767, + "sha256": "e0c44073f6a19e450129f41e976cc8531154ceb127c522134cacaa27f9072ce4" + }, + { + "path": "deploy/prepare-switch-appdata.sh", + "bytes": 2014, + "sha256": "6d367fe351a7d862273bcd528db10ea2d012994e235afc1e52c4d4a1692a455c" + }, + { + "path": "deploy/repair-postgres-collation.sh", + "bytes": 3103, + "sha256": "260314a10a9acc6def682e09070f70cf2b403076d9c61399c65d9486b63a7bbe" + }, + { + "path": "deploy/run-candidate-gate.sh", + "bytes": 16654, + "sha256": "f700399f66d1686583d832d582a0fb06ab82cfe012347d0bddd0cc2734f67ca2" + }, + { + "path": "deploy/run-eden-security-gate.sh", + "bytes": 4855, + "sha256": "21188047c70875a7a06b075722ce7536e19d1de1d79f62dd76dd5c99ad3686be" + }, + { + "path": "deploy/run-managed-validation.sh", + "bytes": 5289, + "sha256": "c9c6f48a1011fbd79149a59f65481630ebf16f5e5062925d4d105dc13fcf068e" + }, + { + "path": "deploy/run-security-gates.sh", + "bytes": 3329, + "sha256": "6515cdd258cce330dc7ce44b09fb03a141a6aef60051b3ff69ead9b207d16a17" + }, + { + "path": "deploy/unraid/Ludarium-icon.png", + "bytes": 34655, + "sha256": "29862374bd95218010a21722fe82e43c469f7180c41d753421000f71843a49ce" + }, + { + "path": "deploy/unraid/my-Ludarium.xml", + "bytes": 3277, + "sha256": "8137223fafb384619388b9dd379588b0ddbad573d9c7f7681f7c356fb0d52875" + }, + { + "path": "deploy/verify-release-identity.sh", + "bytes": 1587, + "sha256": "dc821b2a12dbb120a613235b490f06967a715370343d979c213fba4725430d19" + }, + { + "path": "deploy/verify-source-manifest.sh", + "bytes": 671, + "sha256": "c4bd15b5e5093c82354c3ba69644b0d09ed4a9fcd0641b58066e8dfc93af31dd" + }, + { + "path": "docs/PUBLIC_SOURCE_BOUNDARY.md", + "bytes": 1640, + "sha256": "adabda82973a76666a354184d59e1bbd9ab35c3c499ce731f59df08d4ef1b6c9" + }, + { + "path": "docs/screenshots/living-archive-audit/audit-home-desktop.png", + "bytes": 920557, + "sha256": "5c04f05cb7e2789d009deeebf2d7cb29f62c33809c89a684cff6076c1500b93d" + }, + { + "path": "fixtures/catalogs/synthetic-logiqx.dat", + "bytes": 405, + "sha256": "d5735c85b0a97c7136c1ffc740d3f1947fabf7e84223c2599eb3e889a8633c97" + }, + { + "path": "fixtures/providers/igdb-game.json", + "bytes": 599, + "sha256": "c2ebf69f5c089451aef7c2334986a86ab370da63e63aeb866ac2f8af65b7a30e" + }, + { + "path": "fixtures/providers/mobygames-game.json", + "bytes": 325, + "sha256": "52d92575d2e452271e85cc95bddb4fa6fff9cadf0d575be417b76703ec914dbd" + }, + { + "path": "fixtures/providers/retroachievements-game.json", + "bytes": 701, + "sha256": "ed85e722612be74eaa4d83a2efccb7609c3b4cab37fec54910a3a8a11bd381d3" + }, + { + "path": "fixtures/providers/screenscraper-game.xml", + "bytes": 555, + "sha256": "e2563fafac2d359f6db63827eb15681333388f2b7cee02a4f0a6227cd56b6ebe" + }, + { + "path": "fixtures/synthetic/README.md", + "bytes": 336, + "sha256": "b7413d854f25f5ef7abb1f5378a86a0cce9d296ed9083217912df19cd205c5e9" + }, + { + "path": "fixtures/synthetic/browserplay/README.md", + "bytes": 2991, + "sha256": "0aa5c12202cc61be85e663173b1fdc82f806fd8673a7a66dd0d93bea294d07f8" + }, + { + "path": "fixtures/synthetic/browserplay/generate-fixtures.mjs", + "bytes": 10816, + "sha256": "942d874054bab187084bd30d44875acc04df040c4eccf66a5bf316f8ff13b4b5" + }, + { + "path": "fixtures/synthetic/browserplay/minimal-ines.base64", + "bytes": 26, + "sha256": "7cdf4dc4f2ecf3800dce7a5c1d08e62d89209c06aaa80f4173a1176271acc20d" + }, + { + "path": "fixtures/synthetic/browserplay/playable-nrom.nes.gz.base64", + "bytes": 190, + "sha256": "98639855de23e8f1a47fa1ca203f153c1c2e63b02f4d78e3563691739bfcf759" + }, + { + "path": "fixtures/synthetic/browserplay/ps2/Makefile", + "bytes": 267, + "sha256": "01902206ac79c3cb6a5b5f9728c28d5012eee6fd04048f64010993ea7e0e3073" + }, + { + "path": "fixtures/synthetic/browserplay/ps2/SYSTEM.CNF", + "bytes": 58, + "sha256": "095a502feed5d312a2aeefcef83e7ed7ecaa16b846910012c8b1bb3acc4eabbb" + }, + { + "path": "fixtures/synthetic/browserplay/ps2/main.c", + "bytes": 1067, + "sha256": "f8cbfb62a78855accaa67e642d26053db8c6c69464a38003af521942c7633f12" + }, + { + "path": "fixtures/synthetic/browserplay/psp/Makefile", + "bytes": 312, + "sha256": "217c4fd84390957399dd922802e3bac837a4a303b612f252618a161a336b2a55" + }, + { + "path": "fixtures/synthetic/browserplay/psp/ludarium-psp-fixture.pbp.gz", + "bytes": 68308, + "sha256": "6e405203a2bab4a7ab3d705e0b937d2ecc8350489f051dbfb58d194e577b02f4" + }, + { + "path": "fixtures/synthetic/browserplay/psp/main.c", + "bytes": 586, + "sha256": "b7a9a13c7058e73af664acde1554ae344142c713d9c33504bcb489f24bcfdd0a" + }, + { + "path": "fixtures/synthetic/browserplay/psx/CMakeLists.txt", + "bytes": 211, + "sha256": "35474589a82268e37c3a93d0d4ede1828004d19537ff31fdf376b0f7dd317cab" + }, + { + "path": "fixtures/synthetic/browserplay/psx/CMakePresets.json", + "bytes": 451, + "sha256": "4d29079efb6f07b4103e9048733144b1a7a8ada3c4e7a7fd42a1ee35c1b13e63" + }, + { + "path": "fixtures/synthetic/browserplay/psx/README.md", + "bytes": 1139, + "sha256": "4b0193ec320d47c48b3a93044e548d5069647252d1bca922579f3f9ecf3dafb5" + }, + { + "path": "fixtures/synthetic/browserplay/psx/iso.xml", + "bytes": 551, + "sha256": "426023a4e135ef3fee27988c2c775165b28142f08ddf2549f15a1a5b9159d0f3" + }, + { + "path": "fixtures/synthetic/browserplay/psx/ludarium-psx-fixture.chd.gz", + "bytes": 6586, + "sha256": "7b1af8fcfcaf052d40fac4696710b1f8b17bc3d6d32f36abd04e4c564d2d0f9f" + }, + { + "path": "fixtures/synthetic/browserplay/psx/main.c", + "bytes": 357, + "sha256": "dd1e75938314bd1be52c8a37b4a586cae0707fbbff29fb5f14ed3f9c261bb5d9" + }, + { + "path": "fixtures/synthetic/browserplay/psx/system.cnf", + "bytes": 61, + "sha256": "344fdb117a7dbe4676d96b764a3f873e37a57330883e69ae675b239e472bddc2" + }, + { + "path": "fixtures/synthetic/cue/complete/game (Track 01).bin", + "bytes": 47, + "sha256": "1a42ea1d03476a6ad2127c2b82e8a6a8a228ac19e3d59afff7c3c67fffb23158" + }, + { + "path": "fixtures/synthetic/cue/complete/game.cue", + "bytes": 81, + "sha256": "0528b914e4db7a532732f8987d51c3b79f7e83cde88f53725ef9279c91b26d2f" + }, + { + "path": "fixtures/synthetic/cue/missing/missing-track.cue", + "bytes": 72, + "sha256": "298fb93053ef6475df80bf3e85930df8498a0db1ae35c06ba6ddcc2bffe45436" + }, + { + "path": "fixtures/synthetic/cue/traversal/escape.cue", + "bytes": 33, + "sha256": "1ea0b7a89bfd4da88d58ca12e4b1d1719d8c9bba4c2a65fcbb0a5aff2ffff3f5" + }, + { + "path": "fixtures/synthetic/m3u/complete/Disc 1.cue", + "bytes": 22, + "sha256": "c3684e9b2868dfafc211df7f7e451be184b6e6138f231acc5efd848bc440cdb8" + }, + { + "path": "fixtures/synthetic/m3u/complete/Disc 2.cue", + "bytes": 22, + "sha256": "aa33739be7eece1868a9b0dd1d202c0332baa1aeb239c56ef313321e2ac1c279" + }, + { + "path": "fixtures/synthetic/m3u/complete/collection.m3u", + "bytes": 33, + "sha256": "542cebff94f80b3be0e7e780bd27028be2e1310c88f0991826b159abcde1dfb5" + }, + { + "path": "fixtures/synthetic/windows/installer/setup-1.bin", + "bytes": 30, + "sha256": "a0e9f4b2d07a8457582281b0efdf426f563cc931639435a7117dafef28762e8c" + }, + { + "path": "fixtures/synthetic/windows/installer/setup-2.bin", + "bytes": 30, + "sha256": "4c7e924a125e01603787df1f17729290bd6c00f5b84269d2669901a14ab640e9" + }, + { + "path": "fixtures/synthetic/windows/installer/setup.exe", + "bytes": 59, + "sha256": "99cbdf55655fb6419b36ddfd1d5de49ed7b1c506f73dd0c1011c339d587a4ff4" + }, + { + "path": "fixtures/synthetic/windows/installer/setup.exe.fixture", + "bytes": 86, + "sha256": "88d4148192ed9a8999aa3c3b95fb066caf67793c06135d413b2845c32d46c48a" + }, + { + "path": "global.json", + "bytes": 116, + "sha256": "e19ae6304f93e71dc33b4de89a3f2978ee723dee2b1c52dc043afe77e402a046" + }, + { + "path": "public-source.allowlist", + "bytes": 1340, + "sha256": "25d46eeb2b73e71b29f23d67cf000a6e80ed5350ae214cb981766a3397a1881b" + }, + { + "path": "scripts/export-public-source.mjs", + "bytes": 3826, + "sha256": "7d1ea47d068cac8e32970f1023bb8e6f8d77dcacd1c44d81c3e6f4696960c1d9" + }, + { + "path": "scripts/validate-public-source.mjs", + "bytes": 1954, + "sha256": "21b68ca38bf13231345b45b5259676748ab2e483db3098cbf54ac3425bfda11e" + }, + { + "path": "src/Ludarium.Api/ApiExceptionHandler.cs", + "bytes": 1834, + "sha256": "ac9e41c2b8fa666cbb5a1e68cf1b92e07b638548f91b0c2f6c32938a3f29a4d3" + }, + { + "path": "src/Ludarium.Api/ApiRateLimits.cs", + "bytes": 817, + "sha256": "3e60be3de280b5053a98b95b39edc1db7f4c7532a4a42d083aac59cd95c8cec8" + }, + { + "path": "src/Ludarium.Api/Ludarium.Api.csproj", + "bytes": 692, + "sha256": "5022958567fbc4583ad348374dad878415ce4670b250e2a5ab5a18af4b36de09" + }, + { + "path": "src/Ludarium.Api/Ludarium.Api.http", + "bytes": 137, + "sha256": "b0cb4693a5c5a48eacc3019df5c949c6d3fcd79d22c0410ec6434b62deb120b2" + }, + { + "path": "src/Ludarium.Api/NativeLaunchResolvers.cs", + "bytes": 6394, + "sha256": "fcd6fbd3381d28d78e128ae2ba047948ac3c71c1b383a810e5a97ed9bf80377d" + }, + { + "path": "src/Ludarium.Api/NativeRemotePlayerEndpoints.cs", + "bytes": 18969, + "sha256": "fae622f6a7a25053bbc609e7128fe0e98946a055062379b6ac7f9e373f702547" + }, + { + "path": "src/Ludarium.Api/NativeRemotePlayerRuntime.cs", + "bytes": 23406, + "sha256": "707e77bc4f4b2ababd6a189ebe7b5f4bbd40938bca16df7f850b3e4fbc2eb06b" + }, + { + "path": "src/Ludarium.Api/Program.cs", + "bytes": 87519, + "sha256": "e10c715c083242a6fce441b07d2dd8bc81a0d7995f693b11ee10630f2eb3433b" + }, + { + "path": "src/Ludarium.Api/Properties/launchSettings.json", + "bytes": 358, + "sha256": "7876040e368f821a19256ed6df65efd5a39bdea02baca1eebccea957cb5de917" + }, + { + "path": "src/Ludarium.Api/appsettings.Development.json", + "bytes": 127, + "sha256": "73f95f9e0ceb205fc1c4dc50c07697fcfa29d7087868c2aef1d504cb38c771ec" + }, + { + "path": "src/Ludarium.Api/appsettings.json", + "bytes": 151, + "sha256": "200332b434757a0054a3d356c91cde01f2c48712be8aa2bcb0f3e973619f06e3" + }, + { + "path": "src/Ludarium.Api/packages.lock.json", + "bytes": 2418, + "sha256": "8774d55f71356ab268fb4ce39906f4cfda21e42cc46cb9cad6442201b9debcc8" + }, + { + "path": "src/Ludarium.Application/Analysis.cs", + "bytes": 36914, + "sha256": "4a256e59c72f3ce6d4d897726984fd6ee96847a83e168c528923c993d9cbee28" + }, + { + "path": "src/Ludarium.Application/ArchiveAnalysis.cs", + "bytes": 5305, + "sha256": "dcf52de2e5f06eabe4593f5266156db1fcc7b3fb10dba6f0e296b80d487356ab" + }, + { + "path": "src/Ludarium.Application/ArtworkQuality.cs", + "bytes": 8269, + "sha256": "f00bacf2e5a2c0552360dcdc702ba86c572bcc5c90a50ea07ada945a090d6ad9" + }, + { + "path": "src/Ludarium.Application/BrowserGameDataPolicy.cs", + "bytes": 3510, + "sha256": "9a8cd4ebe7ad06204661ad8fdd0f80baa5a8155aed736c199627ce92602296fe" + }, + { + "path": "src/Ludarium.Application/BrowserPlayPolicy.cs", + "bytes": 10725, + "sha256": "4cf8728ab760cd58f8046ef96ff637bfa8cf3d108ed4f80b39bf8a69088567a5" + }, + { + "path": "src/Ludarium.Application/CatalogImport.cs", + "bytes": 4273, + "sha256": "0c783eb7055dd477921c51379162f8b44b7e9cfea909d620463f92eff355aada" + }, + { + "path": "src/Ludarium.Application/CatalogVisibility.cs", + "bytes": 850, + "sha256": "cc9bb959026ad70bb81db46506ab7a0ffb047d572e47e6601d3782cdb29b74ce" + }, + { + "path": "src/Ludarium.Application/ClaimPolicy.cs", + "bytes": 621, + "sha256": "81858ae21e4893b39dd4495bd5b76e515d458f1a85d48bc3ad0d716f6865cd02" + }, + { + "path": "src/Ludarium.Application/Contracts.cs", + "bytes": 29427, + "sha256": "a94d61765f1881268187352a0a4d62a90e01daa43aef2c233c17c452533d39bd" + }, + { + "path": "src/Ludarium.Application/DiscImageAnalysis.cs", + "bytes": 7512, + "sha256": "b65fbb31f6bb08d14d6e39b8a299cc382b3bca6a47da2190b332d3a02b522aa6" + }, + { + "path": "src/Ludarium.Application/DolphinLaunchPolicy.cs", + "bytes": 3309, + "sha256": "ec1176b509fc212c2162fde487dc90398cf154165b691dd3161d9ab1d15e8ff4" + }, + { + "path": "src/Ludarium.Application/FirmwarePolicy.cs", + "bytes": 3095, + "sha256": "3ef838d6e9fe3ca7916e86c58646584cc59161acf99942fa28a102bfcff811b4" + }, + { + "path": "src/Ludarium.Application/GameDataVaultPolicy.cs", + "bytes": 2295, + "sha256": "4c3b8488d839db6227ec5848aab34a897378f3a96aab47c3a72c4b575d9972fa" + }, + { + "path": "src/Ludarium.Application/GameQuery.cs", + "bytes": 3361, + "sha256": "39c26b1f99d5982780c6c2f40e969b37088b78b6c22c3ff47554d7360567848e" + }, + { + "path": "src/Ludarium.Application/HealthRules.cs", + "bytes": 1386, + "sha256": "c9003acb1f1bd85881e5309b31af1ef693762ec6898a84b7c47451fd54cdaa03" + }, + { + "path": "src/Ludarium.Application/LibraryContentPolicy.cs", + "bytes": 5934, + "sha256": "940c1ba819c2dea7856d815e948c6d219f733fbc36bdfa991331544fe6f98f94" + }, + { + "path": "src/Ludarium.Application/LibraryExperiencePolicy.cs", + "bytes": 5407, + "sha256": "42baead3208fd83022559aa60b128dd95e5a00f767e66ba91fa871f639e8ad65" + }, + { + "path": "src/Ludarium.Application/Ludarium.Application.csproj", + "bytes": 561, + "sha256": "20e83b0a57241fae6b6fc52a919c0f22c5b36f6006d68d5a2098ed19fc3f1632" + }, + { + "path": "src/Ludarium.Application/MsiDatabaseAnalysis.cs", + "bytes": 12482, + "sha256": "d90bf0e2433a3c65abe6ace09953cf98efd3a94036666812ccf7166aad4fe3d7" + }, + { + "path": "src/Ludarium.Application/NativeRemotePlayPolicy.cs", + "bytes": 3443, + "sha256": "dc306fc9291830c743150bf3d0e175e0b0a1be0551e62d0b2497103d17aa3fbf" + }, + { + "path": "src/Ludarium.Application/NativeRemotePlayerRegistry.cs", + "bytes": 4716, + "sha256": "8f3002231142e04cd58b5b06d0025a0476a1dd8123af6550e3324e0cdc8e2dde" + }, + { + "path": "src/Ludarium.Application/ReleaseIdentity.cs", + "bytes": 877, + "sha256": "aa45bb17e4adaa233cbb608f4975f77e5b97517f46c935c3af38d0d1d810a19a" + }, + { + "path": "src/Ludarium.Application/ReviewGroupQuery.cs", + "bytes": 672, + "sha256": "b9b6018fb8da960fc8aac3e4ed58e204e43ff109b2af92f87db2ec5a93de205d" + }, + { + "path": "src/Ludarium.Application/ScanCoordinator.cs", + "bytes": 29887, + "sha256": "05cd325cd207986b14af53c9eac8ea3ca2fd88b28fec92f7e03d8de55de1c20b" + }, + { + "path": "src/Ludarium.Application/SnapshotComparer.cs", + "bytes": 1847, + "sha256": "29760bab0613e405f4adb4e436ebbdfbc12bd586aff22f1effe8f5b9d0727d63" + }, + { + "path": "src/Ludarium.Application/SwitchLaunchPolicy.cs", + "bytes": 4598, + "sha256": "89087c7ca4c5ce066a73aae02492a5a3ef25dd4d3f90464e24a08c33b8d504f5" + }, + { + "path": "src/Ludarium.Application/packages.lock.json", + "bytes": 870, + "sha256": "98a83bf2f6d17716999cc3625a874c6b76422f640d2c9703b49d521ab3ac3cf8" + }, + { + "path": "src/Ludarium.Domain/Ludarium.Domain.csproj", + "bytes": 219, + "sha256": "61e3f2d043e37f0db4be1d8083dd31bebbfbb8fde2cf027327db2556c16bc385" + }, + { + "path": "src/Ludarium.Domain/Model.cs", + "bytes": 16528, + "sha256": "f01f02429bcb5cea2cc0e109524194dd5593b93e219f1368aafb72882b0cda7e" + }, + { + "path": "src/Ludarium.Domain/packages.lock.json", + "bytes": 66, + "sha256": "a4364d81a6de2707d66fbced18fbbc926c882ab96e1d1aebbb17e871285b9d75" + }, + { + "path": "src/Ludarium.HealthCheck/Ludarium.HealthCheck.csproj", + "bytes": 168, + "sha256": "e010305cc3f85a16872604061831dcb308d2cdea6097e6c21fb6c0206db546d9" + }, + { + "path": "src/Ludarium.HealthCheck/Program.cs", + "bytes": 327, + "sha256": "e101be7503d4f5890fb262db893c5f5eaf6280a3dee6b77293e35c793eada65c" + }, + { + "path": "src/Ludarium.HealthCheck/packages.lock.json", + "bytes": 66, + "sha256": "a4364d81a6de2707d66fbced18fbbc926c882ab96e1d1aebbb17e871285b9d75" + }, + { + "path": "src/Ludarium.Infrastructure/AssemblyInfo.cs", + "bytes": 103, + "sha256": "226e2be294cbd45533135fa417d2f1ffc08c20882a4003308001bf6223be1c74" + }, + { + "path": "src/Ludarium.Infrastructure/DependencyInjection.cs", + "bytes": 5590, + "sha256": "7ed6ea7483b84a2543f1fb79a5b8cac1ca99545d38acd8b3db75e66c9b4cb849" + }, + { + "path": "src/Ludarium.Infrastructure/DiscSetArchive.cs", + "bytes": 12072, + "sha256": "0fb8c8afe4dd812fea7c30b6a1480b1fd0724cf17cca72c3b70970c70015a07c" + }, + { + "path": "src/Ludarium.Infrastructure/FirmwareVaultFileStore.cs", + "bytes": 6692, + "sha256": "c25da808352204b80c37feff4cb4e8c90e62d6d63f245662f598d8071e25f6d3" + }, + { + "path": "src/Ludarium.Infrastructure/GameArtworkEnricher.cs", + "bytes": 31211, + "sha256": "f6d8dcdd673aa33493f0d4afc29d523e884f9d0c66179b4b32f58c9124e7da05" + }, + { + "path": "src/Ludarium.Infrastructure/GameArtworkStore.cs", + "bytes": 9545, + "sha256": "2068b7b4ec20915e35b96436844d6f79bd22683e4c8240fd7612dbfba15d6b43" + }, + { + "path": "src/Ludarium.Infrastructure/GameCatalogExportService.cs", + "bytes": 11111, + "sha256": "730b33b6d8bcf4188ce085d2985cf6c866d2f635472ba87bf1adb99b5add27c4" + }, + { + "path": "src/Ludarium.Infrastructure/GameCheatCatalog.cs", + "bytes": 11607, + "sha256": "c8f2842f5a7e3f5e61e95fabd0fec8e5dde2ff61232861caaf0e0d882a77ba05" + }, + { + "path": "src/Ludarium.Infrastructure/GameDataVaultFileStore.cs", + "bytes": 10548, + "sha256": "3cd288efbcf2cfc5d7a91ef0111598cb0d562701853951cfdc52e606f5c8973f" + }, + { + "path": "src/Ludarium.Infrastructure/GameMediaStore.cs", + "bytes": 6653, + "sha256": "d1d411af0bc35378b37178fd741e65982b8bd971a1463a1e221b45ed6bcd8fbe" + }, + { + "path": "src/Ludarium.Infrastructure/GameMetadataEnricher.cs", + "bytes": 11157, + "sha256": "8452b84e2d53fe1b5c668da41159c1da6ce22505c6bffe4defb02e4bb0c21453" + }, + { + "path": "src/Ludarium.Infrastructure/GameProviderHub.cs", + "bytes": 12841, + "sha256": "021facfc2ef7f919b231ff4afff914df9031fcc4cdbb944ac67a900e9a0227da" + }, + { + "path": "src/Ludarium.Infrastructure/HttpMetadataProviders.cs", + "bytes": 28915, + "sha256": "31903ebad0cb829abba64201caa04769ad0ad2d537027b7103723b0101b58ffc" + }, + { + "path": "src/Ludarium.Infrastructure/InventoryExportService.cs", + "bytes": 5360, + "sha256": "90c7cb0098c4a49c907b434c1b57797ee5c545064602438a6e12a773e763db83" + }, + { + "path": "src/Ludarium.Infrastructure/Ludarium.Infrastructure.csproj", + "bytes": 558, + "sha256": "871505795835a2a020603f09f89dae3c3cb35af7b7b61ae714ea2eff21a0f135" + }, + { + "path": "src/Ludarium.Infrastructure/N64ZipRom.cs", + "bytes": 4297, + "sha256": "4b423738123b2e6eadcf20cbd5976939f8d7593031d1fe7054c361a4b397f8ac" + }, + { + "path": "src/Ludarium.Infrastructure/NativeBrowserPlayService.cs", + "bytes": 14764, + "sha256": "5d2a0320e65a600d01e421e537b8eddf065b7a7a3194ee4d50adb438be660c7e" + }, + { + "path": "src/Ludarium.Infrastructure/PostgresStore.BrowserPlay.cs", + "bytes": 7562, + "sha256": "994124d22e055a0e394f902534580f16a68155bd8d8e05db82006f9586459027" + }, + { + "path": "src/Ludarium.Infrastructure/PostgresStore.Firmware.cs", + "bytes": 3000, + "sha256": "6650006a18a791f5c805d315ee6fac05c858c1ed4853b49e33850febacef2a76" + }, + { + "path": "src/Ludarium.Infrastructure/PostgresStore.GameDataVault.cs", + "bytes": 16973, + "sha256": "1100fc3723c11740a241db5965d89baa0fcc10dc0c69d2b63515fc103c5cbe01" + }, + { + "path": "src/Ludarium.Infrastructure/PostgresStore.LibraryExperience.cs", + "bytes": 36003, + "sha256": "3d4aa91395aa22faa17935fe226e1935710375f60c2d308a91b21a0ccb2d0c8a" + }, + { + "path": "src/Ludarium.Infrastructure/PostgresStore.cs", + "bytes": 154137, + "sha256": "565d837c5e470c0ec8f5dabb7818f302566363c69789c1a3d6ac6c6427ec5013" + }, + { + "path": "src/Ludarium.Infrastructure/RawgGameDiscoveryService.cs", + "bytes": 3444, + "sha256": "09aae593013a3fa6a2b680efd817e2884db5c1452d832ce0f7a34b6cace4a6c2" + }, + { + "path": "src/Ludarium.Infrastructure/ReadOnlyLibraryFileSystem.cs", + "bytes": 6901, + "sha256": "4f7b66e376bc60a681383a338330a6da56127ff1b49d131d6cec45097fb557c7" + }, + { + "path": "src/Ludarium.Infrastructure/SupportBundleService.cs", + "bytes": 3203, + "sha256": "650dca703d679349af547594a17504da2761e183926e6847a25e54f7d807777d" + }, + { + "path": "src/Ludarium.Infrastructure/SwitchRuntimeProvisioner.cs", + "bytes": 2866, + "sha256": "c645a8865531eee84cb938566fe2106382fe6dded31d8cfb28de3998dbc10321" + }, + { + "path": "src/Ludarium.Infrastructure/packages.lock.json", + "bytes": 2002, + "sha256": "f6fb5de3f1e3017f8bb2a7961cbeefb7418b0711b5a41fb988b70661cdd23b12" + }, + { + "path": "src/Ludarium.Web/.gitignore", + "bytes": 277, + "sha256": "d50ab07e11fa4bf3b1c7c3312e1d7baeab56cca756832ccf40476823ad2a38b7" + }, + { + "path": "src/Ludarium.Web/e2e/accessibility.mjs", + "bytes": 6475, + "sha256": "db03dac95ebf763a67ddfefb6d1bbdc77c03f4c646ff497a8af18aee16fea806" + }, + { + "path": "src/Ludarium.Web/e2e/archive-atlas.mjs", + "bytes": 5579, + "sha256": "f6383c35b1da480947601dd92b0ba317f2fa55b7477fdff4182b061fb4202928" + }, + { + "path": "src/Ludarium.Web/e2e/game-data-vault.mjs", + "bytes": 6066, + "sha256": "89374a4fe8b5c733b0c5ac7f378dcd2640a062baab02129b0d932170a8465af1" + }, + { + "path": "src/Ludarium.Web/e2e/live-browserplay-core.mjs", + "bytes": 7344, + "sha256": "e564f55384ba320f064db36f8f8bb3391d6e9644eb703ab3e6b629dac142907c" + }, + { + "path": "src/Ludarium.Web/e2e/live-browserplay-n64.mjs", + "bytes": 5971, + "sha256": "1b6471c502457e2c903793f60fe07227117555580deb42b562dbdc35b8e6e4e1" + }, + { + "path": "src/Ludarium.Web/e2e/live-browserplay-ps2.mjs", + "bytes": 4003, + "sha256": "19cae94e8609418d59fe797fa981afbf82ffa09d4a782cbf06ca0b7193d7b720" + }, + { + "path": "src/Ludarium.Web/e2e/live-browserplay.mjs", + "bytes": 4924, + "sha256": "35966deecb4fa76edce79c8b16d10dc6526dd88062a0ac81e997c95d46b2d5bc" + }, + { + "path": "src/Ludarium.Web/e2e/live-switch-player.mjs", + "bytes": 4212, + "sha256": "3718e7a7058739c2f2cda22ddd1a095c8a13a19583df32f9bde3437631a33309" + }, + { + "path": "src/Ludarium.Web/e2e/mock-visual-audit-api.mjs", + "bytes": 8480, + "sha256": "cf3fa4c1ef5b0e6b0733243a4d8cffad771ea3207dbbd646d353c6e69b73da87" + }, + { + "path": "src/Ludarium.Web/e2e/workflows.mjs", + "bytes": 28106, + "sha256": "3339adec2a2a12107342556268035212802510de73eb21fd6b6e4868a7a34db5" + }, + { + "path": "src/Ludarium.Web/index.html", + "bytes": 558, + "sha256": "9026fc418f9481b182f71aaaf8809c17fee0844100fa5d481ef1c1cdc87d5b01" + }, + { + "path": "src/Ludarium.Web/package-lock.json", + "bytes": 61596, + "sha256": "d387a33d6bdfa5045cd0a4dba082612191d8cdc993d39f8abc516ea9e14c8761" + }, + { + "path": "src/Ludarium.Web/package.json", + "bytes": 1156, + "sha256": "5732effaebe4b686210094607c972858fffb95c6125090b31765dab6ace3a027" + }, + { + "path": "src/Ludarium.Web/public/brand/PROVENANCE.md", + "bytes": 1689, + "sha256": "571824ebb5c70c72a9a8db4bc574347792cba6e498579d0c9be841d2f2c1762c" + }, + { + "path": "src/Ludarium.Web/public/brand/living-archive-core.webp", + "bytes": 130538, + "sha256": "8ae78a6bde81d58667560e68eacf687e703d218815a2fe48c44c82102fafe656" + }, + { + "path": "src/Ludarium.Web/public/brand/living-archive-shelves.webp", + "bytes": 127552, + "sha256": "1de417cb7474bdf982715593348c4ee2a89759d14f40c789b7335a9870df4422" + }, + { + "path": "src/Ludarium.Web/public/brand/living-archive-veo-loop.mp4", + "bytes": 2268948, + "sha256": "fe1c55a782ec0da418b35736601f2948ceeaa05b39af95448842fd0ea67a1365" + }, + { + "path": "src/Ludarium.Web/public/favicon.svg", + "bytes": 9522, + "sha256": "61bc9a161de58248288e6905425d7180f0624c2865007b97d763fdac12043a66" + }, + { + "path": "src/Ludarium.Web/public/icons.svg", + "bytes": 5055, + "sha256": "7ca2d67c9c3aebf50cdc8709ee8aacf8cdb8cc7ea6325ea94939caef5da1c53d" + }, + { + "path": "src/Ludarium.Web/public/n64-player.html", + "bytes": 2165, + "sha256": "49bbd519c123da8e8b8ff350a978bbd3e1a62fcb1c618340d8b6c5d0433dcb56" + }, + { + "path": "src/Ludarium.Web/public/n64-player.js", + "bytes": 8064, + "sha256": "22ad1596dfb6159e03e99eb8bd09e26009f59a7659cef628bf233c6b1828068a" + }, + { + "path": "src/Ludarium.Web/public/platforms/3ds.png", + "bytes": 737480, + "sha256": "157816852aabc404b40af91473b3f3be9392324c170053cb85ff0ef1f8980457" + }, + { + "path": "src/Ludarium.Web/public/platforms/README.md", + "bytes": 3622, + "sha256": "d3c484d2d016199262c1fe437de3a2bcd731ef265f811cfd46b1a3a5a09fe9ea" + }, + { + "path": "src/Ludarium.Web/public/platforms/gamecube-cutout.png", + "bytes": 1718298, + "sha256": "ad31ae4b7f4dda51cde2229fefe59a7d357aa2cdd0d6b0b30672fc4987b315cd" + }, + { + "path": "src/Ludarium.Web/public/platforms/gb-cutout-v2.png", + "bytes": 1990293, + "sha256": "6e0b4842e647de58740c2d7b1584904a1ef975fa1735129acadd3f368fa456d4" + }, + { + "path": "src/Ludarium.Web/public/platforms/gb.jpg", + "bytes": 134034, + "sha256": "fd31793630297b0789bc0a6378ecd843cbfbff66832f7dc8d2fedff0296d58e0" + }, + { + "path": "src/Ludarium.Web/public/platforms/gba.png", + "bytes": 411057, + "sha256": "f5386eb29633fc60a1da2439467eb59a10c3335a6d743aa09434b2803ed1cd3f" + }, + { + "path": "src/Ludarium.Web/public/platforms/gbc-cutout.png", + "bytes": 1841320, + "sha256": "52cccab334b74ce3d481b1d907ea1860f435d132047b2216fefecf6cea3be9b3" + }, + { + "path": "src/Ludarium.Web/public/platforms/gbc.jpg", + "bytes": 184031, + "sha256": "de134a6db0c89423314a3ee7e36c2e9e996d9e9f3c690720e3725c48db8336be" + }, + { + "path": "src/Ludarium.Web/public/platforms/generic-platform.svg", + "bytes": 1010, + "sha256": "508ecf673b636e119d820f9227eb226220f0ec0ef90009615df7b95a322b9026" + }, + { + "path": "src/Ludarium.Web/public/platforms/n64.png", + "bytes": 344867, + "sha256": "7144b7d0cf3ca23dc6ba7012c9d3b6abfe0f583cf98ddb95966cf533cba55bb2" + }, + { + "path": "src/Ludarium.Web/public/platforms/nds.png", + "bytes": 193261, + "sha256": "375fe9078f8aa7c5a8038ee1a23280035665aede3e1497aedf71d6c336f547e3" + }, + { + "path": "src/Ludarium.Web/public/platforms/nes.png", + "bytes": 259488, + "sha256": "f3086df730301daf8c1bf77a32c454b2557d98cfb5d7d48211563bc64460850f" + }, + { + "path": "src/Ludarium.Web/public/platforms/ps2.png", + "bytes": 423542, + "sha256": "97a88d42ff142ea69a228061faa883f6d3a1aeab589bd325ba89f1d58f5c3fe4" + }, + { + "path": "src/Ludarium.Web/public/platforms/ps3-cutout.png", + "bytes": 738393, + "sha256": "345fd83aa01bb486a6e6b766362cd5c6a85e59c38667a34dd15f3a33c84e5148" + }, + { + "path": "src/Ludarium.Web/public/platforms/ps4-cutout.png", + "bytes": 1074659, + "sha256": "142eec4a3b49736c93143cd353d2a46f71efe3e10f0e0b74ffaa34bd57a84b9c" + }, + { + "path": "src/Ludarium.Web/public/platforms/ps5.png", + "bytes": 409653, + "sha256": "ead9318b4a87094bef128d2524f431f7c07d714fdc241c7c71414bc44c166eef" + }, + { + "path": "src/Ludarium.Web/public/platforms/psp.png", + "bytes": 367805, + "sha256": "f277a288e1c5239bd0815c6454c7e74cef9e3ff71a2851484fc0dfa617f09afe" + }, + { + "path": "src/Ludarium.Web/public/platforms/psvita.png", + "bytes": 360551, + "sha256": "36d170460efb089cdc69c9d9a1bddaf44c77cf648adf2c6b56426f654b9e360b" + }, + { + "path": "src/Ludarium.Web/public/platforms/psx.png", + "bytes": 236429, + "sha256": "0b11db694e3819fb9607d421a2e96fcd42c1674482169e34ae4f6da895d76e74" + }, + { + "path": "src/Ludarium.Web/public/platforms/snes-cutout-v2.png", + "bytes": 1115047, + "sha256": "e1af3576fac72b18895b6830e44b8cc0ece66dd2ee7cd3d400491e5f7a5e7908" + }, + { + "path": "src/Ludarium.Web/public/platforms/snes.jpg", + "bytes": 45524, + "sha256": "ec8dc0599637cb1b76fcff992c6e1ea527b19432a8b5f6a30ffc4adba64fab80" + }, + { + "path": "src/Ludarium.Web/public/platforms/switch-cutout.png", + "bytes": 1359269, + "sha256": "6e2f0b8b8fa1df570d9241c7ddc7ed004fdb4f8d7955a42f2409608b2af1e838" + }, + { + "path": "src/Ludarium.Web/public/platforms/switch.jpg", + "bytes": 49013, + "sha256": "a4e5702207010df96dd3d15218adeadce7d45513d507ea9e99a90ec056e4fa44" + }, + { + "path": "src/Ludarium.Web/public/platforms/wii.png", + "bytes": 632982, + "sha256": "b2f7febf41e14d39e99c05b35fd6277075b91c0eab55612f93bafd747a162099" + }, + { + "path": "src/Ludarium.Web/public/platforms/wiiu-cutout.png", + "bytes": 1062169, + "sha256": "a07a927fcbab2133d5670553e92c0f9dfa446ed368ff8a4e127f379b0b76be2e" + }, + { + "path": "src/Ludarium.Web/public/platforms/windows.jpg", + "bytes": 109312, + "sha256": "7b4ae879362b86354c1e23f7d63d4e60a63745b847894fbda2f7f692a2c12f89" + }, + { + "path": "src/Ludarium.Web/public/platforms/xbox-cutout.png", + "bytes": 1461557, + "sha256": "bd01bbe252cbc8337e6d1ac63c6e391133d242aaeb71c85670888ee115e5eea8" + }, + { + "path": "src/Ludarium.Web/public/platforms/xbox360-cutout.png", + "bytes": 939916, + "sha256": "35bf8330303922080a5bdeca734645d1ae8af645306eec2180d4a356e9558daf" + }, + { + "path": "src/Ludarium.Web/public/platforms/xboxone-cutout.png", + "bytes": 1022664, + "sha256": "1408b685c8a1624d0db897e2f67fb2b8cc6676796ca40e15e494f5b3e9afd603" + }, + { + "path": "src/Ludarium.Web/public/platforms/xboxseries-cutout.png", + "bytes": 1052533, + "sha256": "27f49eddeb5004e6613f298d000fc12c898ae288de5b04e972730493f46b16b7" + }, + { + "path": "src/Ludarium.Web/public/player.html", + "bytes": 1200, + "sha256": "2404ea808a2f9f752bb7d9131c1fb628fb6c639aecff5333e7c70030d02995fa" + }, + { + "path": "src/Ludarium.Web/public/player.js", + "bytes": 6885, + "sha256": "c0d72cfb52e97be990c4b29221c15888b6360dc5efebcf377d10c11619613954" + }, + { + "path": "src/Ludarium.Web/public/ps2-player.html", + "bytes": 1457, + "sha256": "ab350ed8601c87dc5164e171249b7b5b620bb203f06e6162e1f5f8fd3942133f" + }, + { + "path": "src/Ludarium.Web/public/ps2-player.js", + "bytes": 4289, + "sha256": "0ce0cd0ee057091c2fe9a4925f141953eeb55ad90ff179fd3439214d02294421" + }, + { + "path": "src/Ludarium.Web/src/api/client.test.ts", + "bytes": 1566, + "sha256": "ebfe0745c07ef9124208b0e32c1be28c0d6646135d5e6c3e8e3cc0bbc6f00744" + }, + { + "path": "src/Ludarium.Web/src/api/client.ts", + "bytes": 4438, + "sha256": "d764a2e009881b10e6e0344a1d4c17be9b2113f50a532daa221c3df5db129a25" + }, + { + "path": "src/Ludarium.Web/src/api/nativePlayers.ts", + "bytes": 3754, + "sha256": "8b4ccb385fbe136975cc7c42dd37b3b6c5816a9c8e2c8e8c5206f74b7401efa7" + }, + { + "path": "src/Ludarium.Web/src/api/types.ts", + "bytes": 16448, + "sha256": "d74477cc92b7ced32f440992b1c308b4e67585ed6e2c71fb83395dd265b3c16e" + }, + { + "path": "src/Ludarium.Web/src/app/App.tsx", + "bytes": 4220, + "sha256": "21dbf53c9ad4dd8f11a9571c1589766f3470a460eec797f82d5186f294a12b2c" + }, + { + "path": "src/Ludarium.Web/src/app/AppShell.test.tsx", + "bytes": 3806, + "sha256": "d1089b9f0b1cdab7e903bc05a09ae55f2195314909145e2381ca56cb2b0ef187" + }, + { + "path": "src/Ludarium.Web/src/app/AppShell.tsx", + "bytes": 13277, + "sha256": "8a5931b6d5539077f9b0fed665f3c2351141485d7e0d053e69229f57f259999e" + }, + { + "path": "src/Ludarium.Web/src/app/Login.test.tsx", + "bytes": 2698, + "sha256": "f90262c817fc65d106e791d42c6b33542ae04365022f4c2bbb619cbd41728559" + }, + { + "path": "src/Ludarium.Web/src/app/Login.tsx", + "bytes": 5541, + "sha256": "7a33d2787b7bcf8e3a1a67e0d619aac3f7da36a10b4a97ceffc90e3787d457c9" + }, + { + "path": "src/Ludarium.Web/src/app/routes.test.ts", + "bytes": 1307, + "sha256": "57e866871f3718d8657988c6debc11a4b7e2c6bb652412345ce668ba8eacf699" + }, + { + "path": "src/Ludarium.Web/src/app/routes.ts", + "bytes": 2595, + "sha256": "c583c09196d46b5d53e2aef5d80b273491b23480f3156135ffef9d878285b682" + }, + { + "path": "src/Ludarium.Web/src/assets/hero.png", + "bytes": 13057, + "sha256": "881ffbcaafc212e49addad08846a5b82761355fa20624253af3477ba33262c5c" + }, + { + "path": "src/Ludarium.Web/src/assets/typescript.svg", + "bytes": 1304, + "sha256": "c1f92be30cbb7159085e05cedd9dd3da54d04f72ad54f6df314739e61845917e" + }, + { + "path": "src/Ludarium.Web/src/assets/vite.svg", + "bytes": 8710, + "sha256": "2f1f6c6f90a0ef7422cbb4cfafcd8ad329c507a18afdc34cc21fa72179b9c54a" + }, + { + "path": "src/Ludarium.Web/src/components/ArchivePipeline.tsx", + "bytes": 1612, + "sha256": "76282ed54c4b258f4e096a0d964fd415e9d0fd5d1f4b384a9117cbbbc53ef37e" + }, + { + "path": "src/Ludarium.Web/src/components/ArtworkPreview.test.tsx", + "bytes": 1731, + "sha256": "ff6fa6b3e8edf531f3c916499bbe73e7851bc519cb4db6e0b253e6aeca1c01a4" + }, + { + "path": "src/Ludarium.Web/src/components/ArtworkPreview.tsx", + "bytes": 2674, + "sha256": "3574a5189daeaf3ab519403130fb771b5424295c635b7081250cadfec456356d" + }, + { + "path": "src/Ludarium.Web/src/components/ConfirmDialog.tsx", + "bytes": 3933, + "sha256": "d1d3227ae038ea014eaba5bf4c8aecfee2d5070cc577a89f0eb1bb7bcfcc24e4" + }, + { + "path": "src/Ludarium.Web/src/components/GamepadNavigation.test.ts", + "bytes": 1153, + "sha256": "18fa2b483ce40f278cabe02aae85eb6814a16b982f1570a8dd6961e6bd68eac5" + }, + { + "path": "src/Ludarium.Web/src/components/GamepadNavigation.tsx", + "bytes": 9180, + "sha256": "8aac8c706253ea870ac50f33adcf67938ef52c388ba8b30e8b25f20b5efcffe7" + }, + { + "path": "src/Ludarium.Web/src/components/GlobalSearch.test.tsx", + "bytes": 2846, + "sha256": "89488742807a16dff00da7f1691060fee3acde04c0934b09c8ad4f4f8881311b" + }, + { + "path": "src/Ludarium.Web/src/components/GlobalSearch.tsx", + "bytes": 10288, + "sha256": "e9ddacb102e62d78b5a3586eefa44a4eb5e53785e8b8ac53302d05d787e11e3f" + }, + { + "path": "src/Ludarium.Web/src/components/Icon.tsx", + "bytes": 1036, + "sha256": "722dd8d2065e7e64447fb72d68435ab1878dab1a4965ad3e4fc9ac110030555d" + }, + { + "path": "src/Ludarium.Web/src/components/Overlay.test.tsx", + "bytes": 1614, + "sha256": "d043845c7432111995e41ad71a7e0482d0ab4a94041455c228028428aa12add3" + }, + { + "path": "src/Ludarium.Web/src/components/Overlay.tsx", + "bytes": 4725, + "sha256": "e1f4e0d0ced51fd0ddaceabd94ada4b109d3267a932e6f989d5dfd03c7cbc95b" + }, + { + "path": "src/Ludarium.Web/src/components/PlatformVisual.test.tsx", + "bytes": 2132, + "sha256": "2991520cf521d6c50fa6ace9ff7b08ee200820dfdf238388d36ff9f14b541ba0" + }, + { + "path": "src/Ludarium.Web/src/components/PlatformVisual.tsx", + "bytes": 5713, + "sha256": "307ef825ab85c05e35a6fd5ebe0d354f69b6f8f3cfe46ea8f4bd4dd821622f29" + }, + { + "path": "src/Ludarium.Web/src/components/retailCover.test.ts", + "bytes": 813, + "sha256": "b39066f2abbd4415955016c523684d4e70400947b118858ba3a84b86fa2f3f69" + }, + { + "path": "src/Ludarium.Web/src/components/retailCover.ts", + "bytes": 1116, + "sha256": "4df818cfb61508ba4b85dcea5a041db6ef9615ee01714e63f707a511bb534b29" + }, + { + "path": "src/Ludarium.Web/src/features/OperationalRecovery.test.tsx", + "bytes": 2791, + "sha256": "992c84cb351effa25788007b14987b2a48c6b7c591405328c3b74df676b4cd06" + }, + { + "path": "src/Ludarium.Web/src/features/activity/Activity.snapshots.test.tsx", + "bytes": 2581, + "sha256": "e96c5631fdfa21c16c175c8a66251a5b2dfb0ca3388ce4d1bc48e2e07c8629fd" + }, + { + "path": "src/Ludarium.Web/src/features/activity/Activity.tsx", + "bytes": 27823, + "sha256": "aa84096298f96d49997b2067f3d37da3fa8d716f36575a5d5fdaea006626623e" + }, + { + "path": "src/Ludarium.Web/src/features/attention/Attention.lifecycle.test.tsx", + "bytes": 12816, + "sha256": "dfdc7584ce3b63e193732499250807a01b4c832c4efb4e9817b423c5447ed4e2" + }, + { + "path": "src/Ludarium.Web/src/features/attention/Attention.tsx", + "bytes": 31503, + "sha256": "8a87ef3f6f770022dd6f206f38ff655752a0c104e33c525a1fa4816be51f6b36" + }, + { + "path": "src/Ludarium.Web/src/features/home/ArchiveAtlas.test.ts", + "bytes": 1472, + "sha256": "54a4450f8f2098586449874fea9cb23b2855719ab1216af0bbc16a9b19ada9c9" + }, + { + "path": "src/Ludarium.Web/src/features/home/ArchiveAtlas.tsx", + "bytes": 7704, + "sha256": "1fedc3df4b687263f808c44b5fc0f250ae25020b16a28f8583294909caec3caa" + }, + { + "path": "src/Ludarium.Web/src/features/home/Home.recent.test.tsx", + "bytes": 5363, + "sha256": "adbc68e07cbec461b9befebeff1c7c70f73097c8bf9ccd46c470daf58b086878" + }, + { + "path": "src/Ludarium.Web/src/features/home/Home.test.ts", + "bytes": 1051, + "sha256": "8e9f3d686b6c700f360d4a0732f13059602e8551edfc9d91e04dc62a793ba2aa" + }, + { + "path": "src/Ludarium.Web/src/features/home/Home.tsx", + "bytes": 27884, + "sha256": "81618e6f50accbefced8b6dc0b3b7b2c1bd92fe6ca72626472b9a6d7647f06fe" + }, + { + "path": "src/Ludarium.Web/src/features/library/BrowserPlayer.test.tsx", + "bytes": 2069, + "sha256": "d73f16a960165a6545f3e4c39cacb70fd8f304f58e3ed13b868ccdf76f082a81" + }, + { + "path": "src/Ludarium.Web/src/features/library/BrowserPlayer.tsx", + "bytes": 2711, + "sha256": "91eb99426487e6319aa639df8a6622813a959dce4548c0c3482e1c75472fb829" + }, + { + "path": "src/Ludarium.Web/src/features/library/GameDataPanel.test.tsx", + "bytes": 3845, + "sha256": "8a3dfc3a23ee66d46b09d8715287274b9f690932c38e43c39a415c3f91ae4669" + }, + { + "path": "src/Ludarium.Web/src/features/library/LibraryWorkspace.test.tsx", + "bytes": 39484, + "sha256": "a73e44b8d54d741536bd770189eaa0f812e8ba2af626948bc389477dabf4eace" + }, + { + "path": "src/Ludarium.Web/src/features/library/LibraryWorkspace.tsx", + "bytes": 124129, + "sha256": "4ce89d9c8c95da28e48ac84e53c5bdff4b4e42b6d4aabb0e5c53267456ec1d49" + }, + { + "path": "src/Ludarium.Web/src/features/library/NativeRemotePlayer.test.tsx", + "bytes": 9185, + "sha256": "f886486b6f7704f491b00595c1aac878b8a0424c3c23963c4107ab0984dc3d4a" + }, + { + "path": "src/Ludarium.Web/src/features/library/NativeRemotePlayer.tsx", + "bytes": 12378, + "sha256": "537262ce417b40e95ac5cc160277777bddf0d97d1b1c86bb072f429c08ff9a0f" + }, + { + "path": "src/Ludarium.Web/src/features/library/libraryPreferences.test.ts", + "bytes": 933, + "sha256": "daa33894c4fed2560e9d3eb0bfc6ac7b97fa30ea2e220127a39de19c48a8b992" + }, + { + "path": "src/Ludarium.Web/src/features/library/libraryPreferences.ts", + "bytes": 700, + "sha256": "278b47bea955421210ff7202d7f83b8f76bed0f16eebc0af0ce2eb679567e00f" + }, + { + "path": "src/Ludarium.Web/src/features/settings/LibraryRepair.test.ts", + "bytes": 1855, + "sha256": "21578d8e7832de132f9ecaad08e98338cc3080cb7f2c196d9976792d8be602ec" + }, + { + "path": "src/Ludarium.Web/src/features/settings/LibraryRepair.tsx", + "bytes": 17583, + "sha256": "40c15ddee6385ff2b7c389ab5f08b5f3100327a1dae91f0300a0a47ca8468605" + }, + { + "path": "src/Ludarium.Web/src/features/settings/Settings.tsx", + "bytes": 44594, + "sha256": "be6c82095ee1bccc7e558e5f129d1eaf85e2b7fcec4ed033b586c9f4610cd2bf" + }, + { + "path": "src/Ludarium.Web/src/features/wishlist/Wishlist.test.tsx", + "bytes": 4822, + "sha256": "2f62d9e38ae0c775249902fd8de30d9288ec6f965450d0fbb52ee38b1ecfef35" + }, + { + "path": "src/Ludarium.Web/src/features/wishlist/Wishlist.tsx", + "bytes": 19562, + "sha256": "d0b71d6f59c5717a5e43570c915ccc5481ce6e2adbb82168125b031dd39a496e" + }, + { + "path": "src/Ludarium.Web/src/i18n/locale.test.ts", + "bytes": 8513, + "sha256": "926d4e72f6e81d66ddf2de4e94867d0c9397e54e2b902de7e34bbe80739418fe" + }, + { + "path": "src/Ludarium.Web/src/i18n/locale.ts", + "bytes": 68975, + "sha256": "73871b26e9b415b5e899821da7f5911ad42511839e385a7aebe9ed2b4989c72d" + }, + { + "path": "src/Ludarium.Web/src/main.tsx", + "bytes": 331, + "sha256": "02bd32deb67e73bfbed52f3344aead9720118714ef2ad733d86b33f196901471" + }, + { + "path": "src/Ludarium.Web/src/styles/app.css", + "bytes": 235154, + "sha256": "aa5380a5453e182133e1857753dd21a4c576896af4bacbdd03511cbb9073cd8b" + }, + { + "path": "src/Ludarium.Web/src/styles/coverSurfaces.test.ts", + "bytes": 1367, + "sha256": "0dc475ea123a48d76417e76408922ecf638fecbcb0f6dea2d7924d9e7383b104" + }, + { + "path": "src/Ludarium.Web/src/styles/stylesheet.test.ts", + "bytes": 6612, + "sha256": "876d778421440b91f7eaeac85c422ff30afe879ac0300d017a52815aa88fd280" + }, + { + "path": "src/Ludarium.Web/src/test/setup.ts", + "bytes": 44, + "sha256": "60aa525f7ffa6bfd3045d22710d4eeef3a5ff2074ecc3dbcef99374badebad17" + }, + { + "path": "src/Ludarium.Web/src/vite-env.d.ts", + "bytes": 39, + "sha256": "424faf9241dd699dda995b367ed36665732da1e6ec1f33b2fd40394488ecac92" + }, + { + "path": "src/Ludarium.Web/tsconfig.json", + "bytes": 617, + "sha256": "6ca237136b2c74957b2cbcff9031f96d6a7e1143afc95c5f5ef90f84cd1692c0" + }, + { + "path": "src/Ludarium.Web/vite.config.ts", + "bytes": 464, + "sha256": "99cd55b3bc3357ec2616da2eddbe065a2ea502222884ad16f19fe220887f483f" + }, + { + "path": "src/Ludarium.Web/vitest.config.ts", + "bytes": 241, + "sha256": "0b169c90a542aeef4774a1abb6ec8f18361cd891838813eba04323b83488d6c9" + }, + { + "path": "src/Ludarium.Worker/Ludarium.Worker.csproj", + "bytes": 648, + "sha256": "acd6ff09d7373dcef9b8abb8ed32201763e47e81c68f0a745e0267309163c867" + }, + { + "path": "src/Ludarium.Worker/Program.cs", + "bytes": 168, + "sha256": "afcdd069c5fb771463ddfe1ea53987af20f9dcc76015c9c5b3ea5ceb5317280f" + }, + { + "path": "src/Ludarium.Worker/Properties/launchSettings.json", + "bytes": 284, + "sha256": "0fd8eef9b546f5d000576eff6a0ef02b9b0ebc45c851d310dbc578fef99ab2cb" + }, + { + "path": "src/Ludarium.Worker/Worker.cs", + "bytes": 502, + "sha256": "85a36c6b1eed5c8e3cc420f835094a48734e4f83aa1fbdaf54b7a96863959ba6" + }, + { + "path": "src/Ludarium.Worker/appsettings.Development.json", + "bytes": 137, + "sha256": "ab16b7b75d012475e615a41c21000c9215c6e02cd70b9c2618d25d885aa6ff33" + }, + { + "path": "src/Ludarium.Worker/appsettings.json", + "bytes": 137, + "sha256": "ab16b7b75d012475e615a41c21000c9215c6e02cd70b9c2618d25d885aa6ff33" + }, + { + "path": "src/Ludarium.Worker/packages.lock.json", + "bytes": 15681, + "sha256": "c6fedb1e1561316ec56b6e1961a5572194374d800a202772e73604b5ef1fabbe" + }, + { + "path": "tests/Ludarium.ArchitectureTests/DependencyTests.cs", + "bytes": 26717, + "sha256": "0d4d7b63296801c4002fbc8cb47d4b1727f26d3bb78e974934f77c351c4a5fc9" + }, + { + "path": "tests/Ludarium.ArchitectureTests/Ludarium.ArchitectureTests.csproj", + "bytes": 957, + "sha256": "f76cddd66e3b1fa879996df9e9e2542a97c35c87c3cd9e2495343945d5c0ede2" + }, + { + "path": "tests/Ludarium.ArchitectureTests/packages.lock.json", + "bytes": 6817, + "sha256": "f2e583f071cf8539d6828c15b4b8a23d8e4aa137c85a623ebcd140eb2d68b170" + }, + { + "path": "tests/Ludarium.E2E/AdminTokenPolicyTests.cs", + "bytes": 2236, + "sha256": "008843323f944add99a914350e5ed01d15290a8d521b2e5bf1be02247fa8e9ef" + }, + { + "path": "tests/Ludarium.E2E/Ludarium.E2E.csproj", + "bytes": 676, + "sha256": "0a6abd36ee685cb012658cf3f57f368f0d15f793aab098916ef5d2cd7036ce15" + }, + { + "path": "tests/Ludarium.E2E/NativeRemotePlayerTests.cs", + "bytes": 16149, + "sha256": "5df7e911c02d3fd04b92aad83bd1ce60367ca70cf1b72861bce1a37c98ffc065" + }, + { + "path": "tests/Ludarium.E2E/RateLimitPolicyTests.cs", + "bytes": 825, + "sha256": "e5ed26c8ac4131be90332297d0888d3c34d4d7e67de256834c86b81c1f86c589" + }, + { + "path": "tests/Ludarium.E2E/ReleaseSurfaceTests.cs", + "bytes": 1579, + "sha256": "7da90b354153fd03fc0e33f46ce9806d3d752ba1bfa960d76ca50940fd2a87af" + }, + { + "path": "tests/Ludarium.E2E/packages.lock.json", + "bytes": 6817, + "sha256": "f2e583f071cf8539d6828c15b4b8a23d8e4aa137c85a623ebcd140eb2d68b170" + }, + { + "path": "tests/Ludarium.IntegrationTests/ArtifactReconciliationTests.cs", + "bytes": 3680, + "sha256": "b09e13995585253d9b78e6a6e06ba8d05764328bb4febf5db5bcf9abedb3ef97" + }, + { + "path": "tests/Ludarium.IntegrationTests/ContractTests.cs", + "bytes": 190, + "sha256": "4c83dffee26fb6d61460b607c391796b831008bb5d4da8f602f9c55c4d36a37c" + }, + { + "path": "tests/Ludarium.IntegrationTests/DiscSetArchiveTests.cs", + "bytes": 13598, + "sha256": "38bc0ceb0a2c183b467283bf6c534a85dc2adea97428903ad798ed9911d4c210" + }, + { + "path": "tests/Ludarium.IntegrationTests/DiscoveredGameTitleTests.cs", + "bytes": 3721, + "sha256": "b5bd4c0bc2d6ac9d9b07bfdc8b1dd169307a063b705148d9abb76a17e6ef4216" + }, + { + "path": "tests/Ludarium.IntegrationTests/FirmwareVaultFileStoreTests.cs", + "bytes": 1869, + "sha256": "86552454afcc4607a79ad2d5d046d1f7755c399d796b8ac17794c42eaff9a3cb" + }, + { + "path": "tests/Ludarium.IntegrationTests/GameArtworkEnricherTests.cs", + "bytes": 2054, + "sha256": "de6d955eae554034fe0f8120b6d45c5cbb45e7010636eb15787a51e7f9c4260e" + }, + { + "path": "tests/Ludarium.IntegrationTests/GameArtworkStoreTests.cs", + "bytes": 2244, + "sha256": "37c22e5f8475990a38c0fd30e85127a2051c64ec0ccb632042a3a22a4d1daeb3" + }, + { + "path": "tests/Ludarium.IntegrationTests/GameCheatCatalogTests.cs", + "bytes": 3880, + "sha256": "70543a62a38adb8508e5a63140a14da6055ae748b29598b4ac9c3db8b7c6a45e" + }, + { + "path": "tests/Ludarium.IntegrationTests/GameDataVaultFileStoreTests.cs", + "bytes": 7657, + "sha256": "93e9676f021432b710cb263dee49e4543e338701724892c403d27d5d5b178c6a" + }, + { + "path": "tests/Ludarium.IntegrationTests/GameLibraryQueryTests.cs", + "bytes": 7948, + "sha256": "9a6781bf1a552f3e6214957bd4456bcf749c161883ff38629f6f7089dc2af8f1" + }, + { + "path": "tests/Ludarium.IntegrationTests/GameMediaStoreTests.cs", + "bytes": 2204, + "sha256": "c614d58c1e590c5be9b54839ec50efc4fa4a6c2831e5607f31ca43a15982c61f" + }, + { + "path": "tests/Ludarium.IntegrationTests/Ludarium.IntegrationTests.csproj", + "bytes": 1110, + "sha256": "f94268eb5c008fb1e782523503fb125654e7c3e332bfba8b2b50545ec52d416d" + }, + { + "path": "tests/Ludarium.IntegrationTests/N64ZipRomTests.cs", + "bytes": 2099, + "sha256": "54759077f281a2ac148eaffd8d9d428d613d0316efcc7d61976f043229a436a9" + }, + { + "path": "tests/Ludarium.IntegrationTests/NativeBrowserPlayServiceTests.cs", + "bytes": 12060, + "sha256": "34e9a8df9875460252bc961aad36e242a61a22df80b6b674fe30aa829316e5cf" + }, + { + "path": "tests/Ludarium.IntegrationTests/NativeProviderFixtureTests.cs", + "bytes": 2726, + "sha256": "bb9b9eb1c19a20df96dca0d1adbc07485c53bdd972b42a8f33a714c7677f8217" + }, + { + "path": "tests/Ludarium.IntegrationTests/PostgresStoreTests.cs", + "bytes": 50417, + "sha256": "760333eb47891178b737c3e393e3dccaea4e63dacec97caf174f24d9225156a6" + }, + { + "path": "tests/Ludarium.IntegrationTests/RawgGameDiscoveryServiceTests.cs", + "bytes": 1934, + "sha256": "4993d88707f43a1c642589859428148c940d728c411cb82c4b66fd17bc3d9fbf" + }, + { + "path": "tests/Ludarium.IntegrationTests/ReadOnlyLibraryFileSystemTests.cs", + "bytes": 2948, + "sha256": "52a584dc1dc51f1444d85c6bf30969159aca69d1b6524ec8c89f714040563f03" + }, + { + "path": "tests/Ludarium.IntegrationTests/RetailCoverProfileCoverageTests.cs", + "bytes": 759, + "sha256": "4c693736cbf3d6560ee096191adee6acf6c8755c9db5b21431e2d05f4d97c960" + }, + { + "path": "tests/Ludarium.IntegrationTests/ReviewResolutionTests.cs", + "bytes": 2901, + "sha256": "c6edca81602b041d614c950f3f41717b2f718f21062b272d227a24a2b927ea20" + }, + { + "path": "tests/Ludarium.IntegrationTests/SafeScanTests.cs", + "bytes": 30873, + "sha256": "b180e573780f4304f521ab470b74b46be764a1c0c780fd6741102145734ed63a" + }, + { + "path": "tests/Ludarium.IntegrationTests/SupportBundleServiceTests.cs", + "bytes": 2884, + "sha256": "de97fcc8ce5080c5677f7ae07076ebbe5a3ef038c514f374a3938e05179363fa" + }, + { + "path": "tests/Ludarium.IntegrationTests/SwitchRuntimeProvisionerTests.cs", + "bytes": 2325, + "sha256": "19a87fe3cb54d76807eb3e547290e9da9a0c714fc8ed90b540ba636db1d27ab2" + }, + { + "path": "tests/Ludarium.IntegrationTests/packages.lock.json", + "bytes": 11084, + "sha256": "772ca7adfec9549fd4f8fcfa6919d0463663cf71989a445105484bd85d8fc44f" + }, + { + "path": "tests/Ludarium.UnitTests/AnalysisTests.cs", + "bytes": 21685, + "sha256": "1ffa0a8faea93a082d4ce79f17d18c33bb5cc1fa72b4494fb4e612495464559a" + }, + { + "path": "tests/Ludarium.UnitTests/ArchiveAnalysisTests.cs", + "bytes": 3695, + "sha256": "c5a501c214db86a054b9accb6cbf2b764acf0bab6b1722bdae69622cc90ef5de" + }, + { + "path": "tests/Ludarium.UnitTests/ArtworkQualityTests.cs", + "bytes": 5804, + "sha256": "c05c084ad6da91c404b916613e30b8f1bd072e03bd4410b73a0e1ea17cc581bd" + }, + { + "path": "tests/Ludarium.UnitTests/BrowserGameDataPolicyTests.cs", + "bytes": 3574, + "sha256": "68177db929da76b65d4fadf644b0991f5fb0bb932a881535219f914f5b6eb1d7" + }, + { + "path": "tests/Ludarium.UnitTests/BrowserPlayPolicyTests.cs", + "bytes": 13994, + "sha256": "b848838afa4f3fbe8f40db8bcc7eaa53b93b6aae55a4aee6d3531108e3cfab18" + }, + { + "path": "tests/Ludarium.UnitTests/CatalogImportTests.cs", + "bytes": 2862, + "sha256": "c7f4d8015cc9ed807013d2673fed4c115c112f60549f80fde5622f1010f0b57c" + }, + { + "path": "tests/Ludarium.UnitTests/CatalogVisibilityTests.cs", + "bytes": 610, + "sha256": "2de590cfd4e65e4863ec97aa326f3145050861852761f439db69694c6a2b62db" + }, + { + "path": "tests/Ludarium.UnitTests/ClaimPolicyTests.cs", + "bytes": 1254, + "sha256": "a1fdc38683794e4a6ad1f8d2c381bfd7f9eac53590a7626f683a6e9d4a66ceee" + }, + { + "path": "tests/Ludarium.UnitTests/DashboardSummaryTests.cs", + "bytes": 783, + "sha256": "fba5027b898f49bcb35b6d7337579efdaf1b4b60ee672a0b072f26a3271d646c" + }, + { + "path": "tests/Ludarium.UnitTests/DolphinLaunchPolicyTests.cs", + "bytes": 2389, + "sha256": "000badc2002707a83e41b098247116d6f1cee5cc773dc4892a240c33b8bda1cd" + }, + { + "path": "tests/Ludarium.UnitTests/FirmwarePolicyTests.cs", + "bytes": 1626, + "sha256": "17d23b0d257a875c6972bc08640494b0bd033c4799d5f88e5819330e5a88e455" + }, + { + "path": "tests/Ludarium.UnitTests/GameDataVaultPolicyTests.cs", + "bytes": 1249, + "sha256": "40eea37b8a02fc0f5b623631e35ab1f548ba0fe5e70d7a73f46ef01dc0bd4345" + }, + { + "path": "tests/Ludarium.UnitTests/GameQueryTests.cs", + "bytes": 3560, + "sha256": "c4f253581074f268e6e33f8269667c24ad5188a48de3f98f6197c63170b37bc1" + }, + { + "path": "tests/Ludarium.UnitTests/HealthRuleTests.cs", + "bytes": 973, + "sha256": "abbdf0a7eb1223f07e29e978df0e7a2bbb87f3178a33f2b9f244113f64e9221f" + }, + { + "path": "tests/Ludarium.UnitTests/LibraryExperiencePolicyTests.cs", + "bytes": 1157, + "sha256": "d51a6f1c0aa11436845a06fdade181a4eef121807503ab24efd4ed563a4505f9" + }, + { + "path": "tests/Ludarium.UnitTests/Ludarium.UnitTests.csproj", + "bytes": 1148, + "sha256": "35043b8ed8006096709379f2fdd57f1a90d477bfdad4e4d7a8fd29e9e19cd89e" + }, + { + "path": "tests/Ludarium.UnitTests/MsiDatabaseAnalysisTests.cs", + "bytes": 8866, + "sha256": "c40c02b21d52fc9b90c2a9abf5ee59fd36c0853d90d7616a401d795f0ddbc7b2" + }, + { + "path": "tests/Ludarium.UnitTests/PlatformIdentificationTests.cs", + "bytes": 9504, + "sha256": "848b3df1b56621a6a46ccb1c3a00bcea971ee97966d5d15259eb79b2a098859b" + }, + { + "path": "tests/Ludarium.UnitTests/ScaleTests.cs", + "bytes": 1507, + "sha256": "621148487338c6ff1b0091492f8c3f9e26fc171358eed461ea05688de678dc0e" + }, + { + "path": "tests/Ludarium.UnitTests/SnapshotComparerTests.cs", + "bytes": 1806, + "sha256": "a6ef640aa47da7d85885ff075dcd83686e3690777077f53b370f336c3833ed1c" + }, + { + "path": "tests/Ludarium.UnitTests/SnapshotSerializationTests.cs", + "bytes": 719, + "sha256": "cb04bd0ced8a9152f144b891dd39d49f96f3618ba4932bd90e6d4c1700ecff88" + }, + { + "path": "tests/Ludarium.UnitTests/SwitchLaunchPolicyTests.cs", + "bytes": 3556, + "sha256": "5d056d2c0c7c326fe1ab8766e91d6d07663399ce7cb0898584c9858b0846c2bc" + }, + { + "path": "tests/Ludarium.UnitTests/packages.lock.json", + "bytes": 4483, + "sha256": "e0e64a1ad98de0a80fec234a8d19fc84ef52dd6c4fa877752ae3fac96e0f55d3" + }, + { + "path": "tests/controllers/test_ludarium_sidecar.py", + "bytes": 29763, + "sha256": "45085de1ac3772f0d1f8ed38a9325303de8c75ff36653a5e78df304d9e9eafbe" + } + ] +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..71ee4fc --- /dev/null +++ b/README.md @@ -0,0 +1,89 @@ +# Ludarium + +**A local-first, self-hosted catalog for game archives.** Ludarium turns read-only folders of ROMs, +disc images and archived Windows installers into a searchable library with provenance, integrity +evidence and an explicit review queue. It is built for Unraid, but the supplied Compose deployment +also works on a regular Docker host. + +![Ludarium home screen with an empty, privacy-safe example archive](docs/screenshots/living-archive-audit/audit-home-desktop.png) + +## What you can do + +- discover and fingerprint files without renaming, moving or executing them; +- group multi-file games, discs, installers, patches and DLC into useful catalog entries; +- review uncertain matches instead of silently accepting guesses; +- find duplicates and integrity problems while retaining the evidence behind each result; +- organise games with collections, tags, favourites, play status, ratings and notes; +- enrich metadata from optional providers while keeping manual values authoritative; +- export JSON, EmulationStation and Pegasus-compatible catalog data; +- keep emulator saves and savestates as bounded, immutable revisions in the Game Data Vault; +- optionally launch supported browser or isolated native players. + +The source libraries are mounted read-only. Ludarium writes only to its own PostgreSQL database, +cache, exports and explicitly configured player data. Scanned native files are never executed by the +cataloging pipeline. + +## Quick start + +You need Docker with Compose, three directories to expose as read-only libraries, and two independent +random secrets of at least 32 characters. + +```sh +cp deploy/.env.example deploy/.env +# Edit deploy/.env: set POSTGRES_PASSWORD, LUDARIUM_ADMIN_TOKEN and your library paths. +docker compose --env-file deploy/.env -f deploy/compose.yml up -d --build +``` + +Open `http://localhost:1230`. Check liveness at `/health/live` and database readiness at +`/health/ready`. API routes require `Authorization: Bearer `; the web interface +stores that token in the browser session after you enter it. + +Before scanning, confirm in the container details that every `/library/*` mount is read-only. The +application refuses unsafe paths and does not need write access to your originals. See +[`deploy/README.md`](deploy/README.md) for Unraid templates, backups, upgrades, optional providers and +isolated player sidecars. + +## How the workflow feels + +1. Add one or more library roots and run a quick inventory. +2. Open the review queue for unknown files, ambiguous matches and incomplete bundles. +3. Accept or correct proposed identities; Ludarium preserves the evidence and confidence behind them. +4. Browse the resulting catalog, build collections and inspect integrity or duplicate reports. +5. Run deeper hashes or exports only when useful; the original files remain untouched throughout. + +Ludarium is an archive manager, not a downloader, storefront or rights-management bypass. It does not +ship games, firmware, console keys, provider datasets or third-party credentials. You are responsible +for the content you index and for complying with the rules that apply to it. + +## Security and privacy + +- Keep Ludarium on a trusted network or place it behind an authenticated HTTPS reverse proxy. +- Use a unique admin token; production startup rejects missing, short and obvious placeholder tokens. +- Treat the browser-player origin as privileged. A distinct player origin is recommended when exposing + browser emulation beyond a trusted LAN. +- Optional metadata providers receive only the requests required for enrichment and are disabled when + no credentials are configured. +- Report vulnerabilities privately using [`SECURITY.md`](SECURITY.md). + +## Development + +The backend targets .NET 10, the UI uses React 19 and Vite, and controller helpers use Python's standard +library. Run the complete source-only validation without Docker privileges: + +```sh +sh deploy/run-managed-validation.sh source +``` + +The canonical development repository also contains hardened image and release gates for trusted +revisions. The public validation workflow deliberately needs no Docker daemon or deployment secrets. + +## Project status and licensing + +Ludarium is release-candidate software. Back up app data before upgrades and qualify optional native +players against your own hardware. First-party source code in this repository is licensed under +**AGPL-3.0-or-later**; see [`LICENSE`](LICENSE). Third-party components and assets remain subject to +their own documented terms. + +Third-party and generated visual assets are documented in +[`THIRD_PARTY_NOTICES.md`](THIRD_PARTY_NOTICES.md) and their adjacent provenance records. Game metadata, +DAT files, ROMs, firmware and proprietary keys are not part of the project. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..b8d7372 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,35 @@ +# Security Policy + +## Supported code + +Security fixes target the current `master` release-candidate line. + +## Reporting vulnerabilities + +Report suspected vulnerabilities privately to `security@itworx.tech`. This +monitored mailbox is the permanent private reporting channel for the project. +Do not publish credentials, private library paths or inventories, emulator +firmware or keys, ROM/game contents, account data, provider tokens, production +databases, private infrastructure, copyrighted archives, or exploit-sensitive +evidence in a public issue. + +Include the affected version or commit, component, minimal reproduction using +synthetic metadata and legally redistributable fixtures, expected and observed +behaviour, and impact. State whether the issue affects archive parsing, path +containment, read-only source mounts, browser emulation, metadata acquisition, +authentication, imports, storage, player isolation, deployment, or third-party +artifact provenance. + +## Security and content boundary + +Ludarium coordinates untrusted filenames, metadata, archives, emulator +components, and isolated players. Preserve path confinement, process isolation, +explicit player origins, least-privilege filesystem access, and read-only source +libraries. Contributions must not silently rename, move, delete, patch, extract +into, or otherwise mutate source game archives. Unsupported or uncertain +content must fail closed or become an explicit review item; runtime emulation +must remain allowlisted and user-initiated. + +Never commit live `.env` files, firmware or keys, ROMs, copyrighted game assets, +account data, private library databases, deployment credentials, private +production exports, or unredacted production evidence. diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..11bee76 --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,20 @@ +# Third-party and generated media notices + +Ludarium bundles visual assets so its interface remains local and does not contact image hosts at +runtime. These assets do not imply endorsement by a platform vendor. + +The platform photographs and their licenses are listed in +`src/Ludarium.Web/public/platforms/README.md`. Most are public-domain photographs by Evan-Amos. The +PlayStation 5 image is licensed under CC BY-SA 4.0, with the original credited to Osh33m and the +retouched transparent version to Soberian. Ludarium resizes images for web delivery without changing +their subject matter; share-alike terms continue to apply to derivatives of that image. + +Background-extracted and generated hardware portraits are documented in +`src/Ludarium.Web/public/platforms/PROVENANCE.md`. Decorative Living Archive media and its generation +records are documented in `src/Ludarium.Web/public/brand/PROVENANCE.md`. The prompts excluded people, +logos, copyrighted characters and game screenshots. Those provenance records must remain next to any +redistribution of the assets. + +Browser emulator components copied into release images retain their upstream license and provenance +files in the image. Ludarium does not bundle games, firmware, console keys, provider datasets or +third-party credentials. diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..760df5b --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.4.19-rc.8 diff --git a/deploy/.env.example b/deploy/.env.example new file mode 100644 index 0000000..0cc2749 --- /dev/null +++ b/deploy/.env.example @@ -0,0 +1,55 @@ +POSTGRES_PASSWORD=replace-with-a-long-random-password +LUDARIUM_ADMIN_TOKEN=replace-with-a-separate-long-random-token +TZ=Europe/Brussels +LUDARIUM_HTTP_PORT=1230 +IGDB_CLIENT_ID= +IGDB_CLIENT_SECRET="" # optional; leave empty to disable +MOBYGAMES_API_KEY="" # optional; leave empty to disable +SCREENSCRAPER_DEVID= +SCREENSCRAPER_DEVPASSWORD="" # optional; leave empty to disable +SCREENSCRAPER_SOFTNAME=Ludarium +SCREENSCRAPER_USER= +SCREENSCRAPER_PASSWORD="" # optional; leave empty to disable +RETROACHIEVEMENTS_API_KEY="" # optional; leave empty to disable +RETROACHIEVEMENTS_USERNAME= +BROWSERPLAY_MAX_SESSIONS=2 +LUDARIUM_DATA_ROOT=./data +PUID=1000 +PGID=1000 +GAMES_LIBRARY=/path/to/games +PS4_LIBRARY=/path/to/ps4-games +PS5_LIBRARY=/path/to/ps5-games + +# Optional embedded Switch player (use with compose.switch.yml). +SWITCH_CONTAINER_NAME=Ludarium-Switch +SWITCH_DATA_ROOT=./data/switch +SWITCH_LIBRARY=/path/to/switch-games +SWITCH_PLAYER_URL=https://switch.example.test/switch-player/ +SWITCH_PUBLIC_URL=http://[IP]:[PORT:1262]/ +SWITCH_REMOTE_PORT=1262 +SWITCH_EMBED_PORT=1231 +SWITCH_EMBED_ORIGIN= +SWITCH_CONTROL_TOKEN=replace-with-a-separate-random-secret +SWITCH_CATALOG_PREFIX=roms/switch + +# Optional isolated GameCube/Wii player (compose.dolphin.yml). +DOLPHIN_DATA_ROOT=./data/dolphin +DOLPHIN_GAMECUBE_LIBRARY=/path/to/gamecube-games +DOLPHIN_WII_LIBRARY=/path/to/wii-games +DOLPHIN_PLAYER_URL=https://dolphin.example.test/dolphin-player/ +DOLPHIN_PUBLIC_URL=http://[IP]:[PORT:1263]/ +DOLPHIN_REMOTE_PORT=1263 +DOLPHIN_EMBED_PORT=1232 +DOLPHIN_REMOTE_USER= +DOLPHIN_REMOTE_PASSWORD= +DOLPHIN_CONTROL_TOKEN= +DOLPHIN_PROXY_CERT_SHA256= +# Exact SHA-256 of the read-only GameCube/Wii artifact used by the live launch + savestate qualification gate. +DOLPHIN_FIXTURE_SHA256= +DOLPHIN_GAMECUBE_CATALOG_PREFIX=roms/gamecube +DOLPHIN_WII_CATALOG_PREFIX=roms/wii +SWITCH_DRI_NODE=/dev/dri/renderD128 +SWITCH_REMOTE_USER=replace-with-a-local-player-user +SWITCH_REMOTE_PASSWORD=replace-with-a-long-random-player-password +# SHA-256 of Eden's self-signed TLS certificate, without colons. See deploy/README.md. +SWITCH_PROXY_CERT_SHA256=replace-with-64-lowercase-hex-characters diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000..87bb39c --- /dev/null +++ b/deploy/README.md @@ -0,0 +1,197 @@ +# Deployment and operations + +## Unraid DockerMan + +The Unraid Compose deployment runs the web application and its private PostgreSQL 16 process inside one supervised `Ludarium` container. DockerMan exposes one WebUI on port 1230 and one persisted PNG icon; PostgreSQL has no host port. The entrypoint forwards shutdown signals to both processes and the database remains isolated in its durable bind mount. + +Install `src/Ludarium.Web/public/favicon.svg` as `/boot/config/plugins/dockerMan/images/Ludarium-icon.svg` on the Unraid host before recreating the application container. + +## Clean start + +1. Copy `.env.example` to `.env` and replace both secrets with independent random values. +2. Set the three host library paths. Do not remove the `:ro` suffixes in `compose.yml`. +3. From `deploy/`, run `docker compose build --pull` and `docker compose up -d`. +4. Wait for `docker compose ps` to report the single service healthy, then open `http://HOST:1230` (or the configured `LUDARIUM_HTTP_PORT`) and supply the admin bearer token through a trusted reverse proxy or API client. +5. Add `/library/games`, `/library/ps4`, and `/library/ps5` in Settings and verify each root. A writable or unverifiable mapping remains visibly unsafe. + +Set `PUID` and `PGID` to the numeric owner of library directories that are not world-readable. Ludarium remains non-root; only its writable appdata directories should be owned by this identity. Never relax source-library permissions solely for a scan. + +The application container is read-only, drops all Linux capabilities, enables `no-new-privileges`, runs as the configured non-root UID/GID, and has no Docker socket, privileged mode, host networking, or source-write mount. + +## Optional native metadata providers + +Ludarium's catalog, collections, tags, personal state, relationships, media and exports work offline and do not require RomM or another service. Optional metadata enrichment is performed directly by Ludarium when the corresponding operator-owned credentials are present: + +- IGDB: `IGDB_CLIENT_ID` and `IGDB_CLIENT_SECRET`; +- MobyGames: `MOBYGAMES_API_KEY`; +- ScreenScraper: `SCREENSCRAPER_DEVID`, `SCREENSCRAPER_DEVPASSWORD` and `SCREENSCRAPER_SOFTNAME`; account user/password are optional; +- RetroAchievements: `RETROACHIEVEMENTS_API_KEY`; `RETROACHIEVEMENTS_USERNAME` additionally enables personal unlock progress. + +Leave variables empty to keep a provider disabled. Provider status and missing configuration are shown in Settings. Credentials stay in the local deployment environment; they are never written to support bundles. Metadata claims, media sources and ambiguous conflicts retain provider provenance, and uncertain conflicts enter review instead of being silently merged. + +## Native browser play + +EmulatorJS 4.2.3 and the official Play!.js PS2 runtime are checksum-pinned and bundled into the Ludarium image. No RomM installation, account, API token, shared database or runtime internet access is required. Play!.js uses its built-in HLE BIOS; Ludarium does not expose an uploaded PS2 BIOS to the browser. `BROWSERPLAY_MAX_SESSIONS` limits concurrent two-hour sessions from 1 through 8 and defaults to 2. + +### Optional isolated Switch player + +`compose.switch.yml` adds LinuxServer's browser-accessible Eden image pinned to its exact amd64 +digest. It is a native remote Wayland/WebRTC application. Ludarium embeds that remote desktop on +the separate `SWITCH_EMBED_PORT` origin through a certificate-pinned streaming/WebSocket proxy; +Eden credentials remain server-side and are never written into browser state. Set +`SWITCH_DATA_ROOT`, `SWITCH_LIBRARY`, `SWITCH_PLAYER_URL`, `SWITCH_REMOTE_USER`, +`SWITCH_REMOTE_PASSWORD`, `SWITCH_PROXY_CERT_SHA256` and optionally `SWITCH_DRI_NODE`, then start +with both Compose files. Obtain the lowercase certificate pin from the already-initialized Eden +configuration with `openssl x509 -in config/ssl/cert.pem -noout -fingerprint -sha256`, removing +colons from the fingerprint. Recheck the pin after replacing Eden's persistent configuration. + +For access through a public reverse proxy, set `SWITCH_EMBED_ORIGIN` to a dedicated HTTPS child +origin such as `https://player.ludarium.example/` and forward that hostname to +`SWITCH_EMBED_PORT`. The child origin is required: Ludarium rejects the player proxy on the main +application hostname so the remote desktop cannot access the administrator origin. Keep WebSocket +upgrade enabled on this proxy host. + +Before the first start, after restoring appdata, or after changing `PUID`/`PGID`, run the bounded +appdata preparation step. It refuses root identities, broad data roots and any overlap between the +app-owned data root and source library. It normalizes only Eden's writable config ownership; it +never changes the read-only game library. + +```sh +./prepare-switch-appdata.sh +``` + +`/games` is mounted read-only, Docker's socket is never exposed, Docker-in-Docker is disabled, +HTTPS Basic authentication is mandatory, and app-owned Eden configuration is separate from every +source library. Eden runs at `/switch-player/`; sharing, collaboration, file transfer, command +execution and its file/app sidebars are locked off. Uploading personal `prod.keys` or `title.keys` +through Ludarium atomically provisions the selected verified key into Eden's app-owned key +directory. Ludarium links Switch games to the embedded player but does not claim exact per-title +directory. For a game with one exact present base XCI/NSP, Ludarium starts that title directly in +Eden. Update/DLC-only, missing, ambiguous or writable-source records remain unavailable instead of +opening a generic game picker. + +```sh +docker compose -f compose.yml -f compose.switch.yml up -d +``` + +The independently versioned sidecar has its own fail-closed security gate. Run it against an +isolated healthy candidate, never production appdata: + +```sh +./run-eden-security-gate.sh \ + ludarium/eden-controller:0.4.9-rc.1 \ + Ludarium-Switch-Candidate \ + /mnt/user/appdata/ludarium-candidate/eden-0.4.9/evidence +``` + +The gate retains both raw and OpenVEX-resolved Grype JSON. It accepts a Critical decision only when +the exact running image is healthy with zero restarts, `/games` is read-only, the executable +runtime-boundary audit passes, and the raw Critical occurrence/ID sets exactly equal the VEX sets. +High and lower findings remain visible in the raw report. + +The current LinuxServer Wayland path requires HTTPS for its best WebCodecs path and, for +proprietary NVIDIA rendering, driver 580 or newer. Intel/AMD DRM render nodes can be selected +directly. Keep both Ludarium and Eden on a trusted LAN. The direct HTTPS URL is retained only as a +fallback; the embedded surface is capability-gated and the hardened Eden desktop does not expose +its normal command/file controls. + +### Optional isolated GameCube and Wii player + +`compose.dolphin.yml` adds the digest-pinned LinuxServer Dolphin/Selkies runtime and a minimal +exact-title controller. Configure `DOLPHIN_DATA_ROOT`, both source-library paths, separate remote +credentials and control token, the initialized TLS certificate pin, and the public ports. Both +source libraries retain `:ro`; saves and savestates are written only below the app-owned Dolphin +data root. No Docker socket is mounted and the remote desktop's command, file, app, clipboard and +sharing controls are locked off. + +Do not populate `DOLPHIN_FIXTURE_SHA256` just to enable the UI. First run an exact owned or freely +licensed read-only GameCube/Wii image through the isolated candidate and verify launch, streamed +video/input, save-state, load-state and persistence after restart. Confirm the source manifest is +unchanged, then record that exact artifact's lowercase SHA-256. Ludarium remains fail-closed until +this evidence field is a valid digest; it does not imply compatibility for every game. + +```sh +docker compose -f compose.yml -f compose.dolphin.yml up -d --build +``` + +The public player origin, when used, must be a dedicated HTTPS child hostname with WebSocket +upgrade enabled. Ludarium rejects a configured origin that is not a child of the main host and +keeps the remote credentials server-side. + +The Play control appears only for one exact present ROM linked by the scanner, on a verified read-only library and with an allowlisted platform, extension and size. An explicit click creates an audited, expiring capability; the browser receives no source path. The scoped content route resolves the stored artifact server-side, revalidates the mount and streams with Range support. PS2 has a dedicated 8 GiB ceiling and 32 MiB per-read bound; other platforms retain the 512 MiB ceiling. Executables, scripts, archives and unknown content fail closed. + +## Isolated candidate gate + +Never point a candidate at production appdata. On Unraid, export the normal secret and library variables plus an isolated root and run the fail-closed gate: + +```sh +export LUDARIUM_CANDIDATE_DATA_ROOT=/mnt/user/appdata/ludarium-candidate/0.4.19-rc.8 +export LUDARIUM_CANDIDATE_PORT=1232 +export LUDARIUM_IMAGE=ludarium/ludarium:0.4.19-rc.8 +export PUID=1000 PGID=1000 # numeric owner/read-capable identity for the current archive +./run-candidate-gate.sh +``` + +The script accepts candidate data only below `/mnt/user/appdata/ludarium-candidate` (or `/tmp/ludarium-candidate`), uses a distinct Compose project, network and container name, and refuses to continue unless all three library mounts report `RW=false`. It tests an exact `git archive` of `HEAD`, so ignored build/cache files and deployment secrets never enter the ephemeral test workspace. It first runs locked restore, formatting, Release build and all .NET tests with PostgreSQL Testcontainers enabled. It records source manifests before/after, builds the pinned image, checks the runtime schema against the version declared in source and checks readiness, runs frontend unit/build/audit plus the full responsive Playwright/axe matrix in an ephemeral tmpfs, restarts the app, restores a custom-format dump into a temporary database, and runs Gitleaks/Syft/Grype. Scanner binaries are checksum-pinned, cached and downloaded with bounded retries/timeouts; the remaining Playwright image pull is digest-pinned and bounded. Its final line contains the image digest, port, schema and backup path required for release evidence. + +On Unraid, the .NET container defaults to 4 CPUs, 8 GiB memory/no extra swap and 1,024 PIDs; +the browser container defaults to 4 CPUs, 4 GiB memory/no extra swap and 1,024 PIDs. MSBuild uses +two nodes, Vitest four workers and Compose one concurrent build. Lower-capacity hosts can override +`LUDARIUM_GATE_CPUS`, `LUDARIUM_GATE_MEMORY`, `LUDARIUM_BROWSER_GATE_CPUS`, +`LUDARIUM_BROWSER_GATE_MEMORY`, `LUDARIUM_GATE_BUILD_NODES` and +`LUDARIUM_COMPOSE_PARALLEL_LIMIT`. A reboot or interrupted shell invalidates the attempt; restart +with a new versioned candidate root instead of reusing partial evidence. + +After that gate passes, run `./run-live-browserplay-gate.sh` with the same candidate environment. It materializes the original cartridge fixtures and reproducibly builds the PS2 ISO below candidate appdata with a digest-pinned PS2SDK image, verifies every SHA-256, mounts the set read-only and performs an Integrity scan. The matrix proves scoped Range delivery and real local canvases; state-capable EmulatorJS cores also prove cancel/relaunch/restore, while Play!.js proves frames and cancellation without claiming a state API. It fails on browser console errors, request failures or a missing local core and automatically restores the canonical candidate mounts afterward. + +When the Docker registry is unavailable, set `LUDARIUM_BROWSER_GATE_MODE=external`. The script then reports `browser-external-required` instead of a full pass; run the checked-in `e2e/workflows.mjs` and `e2e/accessibility.mjs` from a Playwright 1.62.1 workstation against the candidate before promotion. This mode never converts a missing browser result into a green gate. + +The durable archive-share paths on the current host are `/mnt/user/Media/Games`, `/mnt/user/PS4-Games` and `/mnt/user/PS5-Games`. Map them to `/library/games`, `/library/ps4` and `/library/ps5` respectively and keep every mount read-only. The PS4 and PS5 shares are currently empty; populate or remount them before starting a production scan so retained inventory is not marked missing. + +### Candidate retention and cleanup + +After production validation, stop the candidate application and database and disable their restart policies. Retain candidate appdata, database dumps and the prior production backup for 30 days unless an operator-approved retention policy requires longer. During this window, verify production health and backup readability before removing anything. + +Stopping is non-destructive: + +```sh +docker update --restart=no ludarium-candidate-Ludarium +docker stop ludarium-candidate-Ludarium +``` + +After 30 days, first resolve and record the exact candidate project/appdata paths. Remove only explicitly approved candidate containers and versioned candidate appdata; never target `/mnt/user/appdata`, the production `/mnt/user/appdata/ludarium` tree or any library root. Candidate cleanup is intentionally not automated because deletion requires a fresh operator decision. + +## Backup and restore + +Stop application writes with `docker compose stop ludarium`, create a PostgreSQL custom-format dump with `docker compose exec -T ludarium pg_dump -U ludarium -Fc ludarium > ludarium.dump`, and archive `data/app` plus the optional exports. Restart Ludarium afterward. + +To restore, start a clean Ludarium container, pipe the dump through `docker compose exec -T ludarium pg_restore -U ludarium -d ludarium --clean --if-exists`, restore `data/app`, then start Ludarium. Verify libraries, claims, reviews, snapshots and `/health/ready`. Game libraries are not part of this backup and remain untouched. + +## Upgrade and rollback + +Back up first, pull/build the new pinned image, then run `docker compose up -d`. Migrations are forward-only. Rollback means restoring the matching prior image and its pre-upgrade database dump; never run an older binary against a newer schema without a documented compatibility statement. + +## HTTPS, reverse proxy and token rotation + +Expose Ludarium outside a trusted LAN only through an HTTPS reverse proxy. Preserve `X-Forwarded-For`, restrict request-body size and do not log the `Authorization` header. Ludarium itself has no cloud authentication dependency. + +Validate both a normal GET and a completed authenticated inventory export through the public host. +Some Nginx Proxy Manager/OpenResty combinations can accept ordinary HTTP/2 navigation while +stalling longer browser POST streams. If and only if the public browser export stalls while the +same request completes through the direct HTTP/1.1 origin, disable HTTP/2 on the main Ludarium +proxy host and keep bounded `proxy_read_timeout`/`proxy_send_timeout` plus +`proxy_buffering off`/`proxy_request_buffering off`. The dedicated Switch player host may retain +HTTP/2 and must retain WebSocket upgrades. Run `nginx -t` and keep a proxy-manager database backup +before reload. + +To rotate the administrator token, generate a new high-entropy value, update only `LUDARIUM_ADMIN_TOKEN` in the protected deployment environment and recreate the application container. Existing browser sessions receive `401`, erase their session-scoped token and return to the login screen. Never place the token in Compose YAML, shell history, support bundles or source control. + +Before promotion, create a custom-format database backup and retain the previous image digest. If post-migration validation fails, stop application writes and restore both the pre-upgrade dump and matching prior image; do not run an older application against schema 7 or later. + +## Live Unraid validation + +Follow `docs/25_RELEASE_AND_HANDOFF.md`. Capture independent path/name/hash manifests before and after quick/deep scans, restart during discovery, test one unavailable root, create exports, and restore a backup into a clean stack. + +For production browser smoke tests set `PLAYWRIGHT_MUTATE_CATALOG=0` so the general workflow remains read-only. Run the Game Data Vault creation story only against an isolated candidate database; it deliberately creates a temporary game identity and is not a production smoke test. + +For the non-mutating structural manifest gate, run `verify-source-manifest.sh` with every host library path before and after the scan. The file count and SHA-256 digest must be identical. Record the result together with the Docker `RW=false` mount inspection in `RELEASE_GATE_EVIDENCE.md`. diff --git a/deploy/audit-eden-runtime.sh b/deploy/audit-eden-runtime.sh new file mode 100644 index 0000000..092eb0c --- /dev/null +++ b/deploy/audit-eden-runtime.sh @@ -0,0 +1,55 @@ +#!/bin/sh +set -eu + +fail() { + printf 'Eden runtime audit failed: %s\n' "$1" >&2 + exit 1 +} + +for command in eden Xwayland nginx python3 xdotool findmnt ss pgrep; do + command -v "$command" >/dev/null 2>&1 || fail "required command is absent: $command" +done + +for command in docker dockerd containerd gcc g++ make cmake git ssh curl cron crond; do + command -v "$command" >/dev/null 2>&1 && fail "unused privileged/development command is present: $command" +done + +for process in eden Xwayland nginx python3 selkies; do + pgrep -f "$process" >/dev/null 2>&1 || fail "required process is not running: $process" +done + +for process in dockerd containerd cron crond sshd; do + pgrep -x "$process" >/dev/null 2>&1 && fail "unused service is running: $process" +done + +mount_options="$(findmnt -n -o OPTIONS --target /games)" +case ",$mount_options," in + *,ro,*) ;; + *) fail "/games is not mounted read-only" ;; +esac + +[ -z "${DEV_MODE+x}" ] || fail "PixelFlux development mode must remain disabled" +if env | grep -Eiq '^(http|https|all|no)_proxy=|^netrc='; then + fail "proxy or netrc environment can expose unused libcurl credential paths" +fi +if find /config /root -xdev -name .netrc -print -quit 2>/dev/null | grep -q .; then + fail "a runtime .netrc file is present" +fi +if grep -RIEq '^[[:space:]]*map[[:space:]]' /etc/nginx /defaults 2>/dev/null; then + fail "nginx map directives are forbidden in the isolated player" +fi + +xwayland="$(pgrep -a -x Xwayland | head -1)" +printf '%s' "$xwayland" | grep -Fq -- '-rootless' || fail "Xwayland is not rootless" +printf '%s' "$xwayland" | grep -Eq -- '-listen[[:space:]]+tcp' && fail "Xwayland TCP listening is enabled" +ss -lnt | awk 'NR > 1 { print $4 }' | grep -Eq '(^|:):?60[0-9][0-9]$' && fail "an X11 TCP port is listening" + +if find /config -xdev -type f \( -iname '*.svg' -o -iname '*.dtd' \) -print -quit 2>/dev/null | grep -q .; then + fail "untrusted SVG or DTD input is present in app-owned runtime configuration" +fi +if find /config -xdev -type f -size -1M -exec grep -Il 'rist://' {} + 2>/dev/null | grep -q .; then + fail "a RIST media URL is present in runtime configuration" +fi +env | grep -Eiq 'rist://' && fail "a RIST media URL is present in the environment" + +printf 'Eden runtime audit passed: minimal tools, read-only games, bounded local display and no vulnerable optional protocol configuration.\n' diff --git a/deploy/compose.dolphin.yml b/deploy/compose.dolphin.yml new file mode 100644 index 0000000..be5c3e7 --- /dev/null +++ b/deploy/compose.dolphin.yml @@ -0,0 +1,83 @@ +# Optional browser-accessible GameCube/Wii runtime. Combine with compose.yml after setting DOLPHIN_*. +# Both source libraries are mounted read-only; only /config is writable for saves and savestates. +services: + ludarium: + environment: + LUDARIUM_DOLPHIN_PLAYER_URL: ${DOLPHIN_PLAYER_URL:?set the trusted-LAN Dolphin URL} + LUDARIUM_DOLPHIN_PROXY_URL: https://dolphin:3001/ + LUDARIUM_DOLPHIN_PROXY_USERNAME: ${DOLPHIN_REMOTE_USER:?set the Dolphin trusted-LAN username} + LUDARIUM_DOLPHIN_PROXY_PASSWORD: ${DOLPHIN_REMOTE_PASSWORD:?set the Dolphin trusted-LAN password} + LUDARIUM_DOLPHIN_PROXY_CERT_SHA256: ${DOLPHIN_PROXY_CERT_SHA256:?pin the Dolphin TLS certificate SHA-256} + LUDARIUM_DOLPHIN_EMBED_PORT: ${DOLPHIN_EMBED_PORT:-1232} + LUDARIUM_DOLPHIN_EMBED_ORIGIN: ${DOLPHIN_EMBED_ORIGIN:-} + LUDARIUM_DOLPHIN_CONTROL_URL: http://dolphin:8765/ + LUDARIUM_DOLPHIN_CONTROL_TOKEN: ${DOLPHIN_CONTROL_TOKEN:?set the internal Dolphin control token} + LUDARIUM_DOLPHIN_FIXTURE_SHA256: ${DOLPHIN_FIXTURE_SHA256:?set after deterministic live launch/save-state validation} + LUDARIUM_DOLPHIN_GAMECUBE_CATALOG_PREFIX: ${DOLPHIN_GAMECUBE_CATALOG_PREFIX:-roms/gamecube} + LUDARIUM_DOLPHIN_WII_CATALOG_PREFIX: ${DOLPHIN_WII_CATALOG_PREFIX:-roms/wii} + ports: + - ${DOLPHIN_EMBED_PORT:-1232}:8734 + depends_on: + dolphin: + condition: service_healthy + + dolphin: + container_name: ${DOLPHIN_CONTAINER_NAME:-Ludarium-Dolphin} + image: ${DOLPHIN_IMAGE:-ludarium/dolphin-controller:0.4.19-rc.8} + build: + context: .. + dockerfile: Dockerfile.dolphin-controller + labels: + net.unraid.docker.webui: "${DOLPHIN_PUBLIC_URL:-http://[IP]:[PORT:1263]/}" + net.unraid.docker.icon: "/boot/config/plugins/dockerMan/images/Ludarium-icon.svg" + net.unraid.docker.managed: "dockerman" + net.unraid.docker.shell: "" + environment: + PUID: ${PUID:-1654} + PGID: ${PGID:-1654} + TZ: ${TZ:-Europe/Brussels} + PIXELFLUX_WAYLAND: "true" + DRINODE: ${DOLPHIN_DRI_NODE:-/dev/dri/renderD128} + DRI_NODE: ${DOLPHIN_DRI_NODE:-/dev/dri/renderD128} + START_DOCKER: "false" + DISABLE_IPV6: "true" + CUSTOM_USER: ${DOLPHIN_REMOTE_USER:?set the Dolphin trusted-LAN username} + PASSWORD: ${DOLPHIN_REMOTE_PASSWORD:?set the Dolphin trusted-LAN password} + LUDARIUM_DOLPHIN_CONTROL_TOKEN: ${DOLPHIN_CONTROL_TOKEN:?set the internal Dolphin control token} + # App-owned save directory the Game Data Vault captures and restores. Override only when a + # different Dolphin build stores its saves and states elsewhere. + LUDARIUM_DOLPHIN_SAVE_ROOT: ${DOLPHIN_SAVE_ROOT:-/config/.local/share/dolphin-emu} + TITLE: Ludarium Dolphin + FILE_MANAGER_PATH: /games + SUBFOLDER: /dolphin-player/ + HARDEN_DESKTOP: "true" + HARDEN_OPENBOX: "true" + SELKIES_ENABLE_SHARING: "false|locked" + SELKIES_ENABLE_COLLAB: "false|locked" + SELKIES_ENABLE_SHARED: "false|locked" + SELKIES_FILE_TRANSFERS: "none|locked" + SELKIES_COMMAND_ENABLED: "false|locked" + SELKIES_CLIPBOARD_ENABLED: "false|locked" + SELKIES_MICROPHONE_ENABLED: "false|locked" + SELKIES_UI_SIDEBAR_SHOW_CLIPBOARD: "false|locked" + SELKIES_UI_SIDEBAR_SHOW_FILES: "false|locked" + SELKIES_UI_SIDEBAR_SHOW_APPS: "false|locked" + MAX_RES: 3840x2160 + ports: + - ${DOLPHIN_REMOTE_PORT:-1263}:3001 + devices: + - /dev/dri:/dev/dri + shm_size: 1gb + volumes: + - ${DOLPHIN_DATA_ROOT:?set isolated app-owned Dolphin data}/config:/config:rw + - ${DOLPHIN_GAMECUBE_LIBRARY:?set the GameCube source library}:/games/gamecube:ro + - ${DOLPHIN_WII_LIBRARY:?set the Wii source library}:/games/wii:ro + security_opt: + - no-new-privileges:true + healthcheck: + test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8765/health', timeout=3).read()"] + interval: 10s + timeout: 5s + retries: 12 + start_period: 30s + restart: unless-stopped diff --git a/deploy/compose.switch.yml b/deploy/compose.switch.yml new file mode 100644 index 0000000..2cb6d5d --- /dev/null +++ b/deploy/compose.switch.yml @@ -0,0 +1,85 @@ +# Optional browser-accessible Switch runtime. Combine with compose.yml only after setting all +# SWITCH_* variables. The game library is always mounted read-only and no Docker socket is exposed. +services: + ludarium: + environment: + LUDARIUM_SWITCH_PLAYER_URL: ${SWITCH_PLAYER_URL:?set the trusted-LAN Eden URL} + LUDARIUM_SWITCH_KEYS_ROOT: /app/switch-keys + LUDARIUM_SWITCH_PROXY_URL: https://eden:3001/ + LUDARIUM_SWITCH_PROXY_USERNAME: ${SWITCH_REMOTE_USER:?set the Eden trusted-LAN username} + LUDARIUM_SWITCH_PROXY_PASSWORD: ${SWITCH_REMOTE_PASSWORD:?set the Eden trusted-LAN password} + LUDARIUM_SWITCH_PROXY_CERT_SHA256: ${SWITCH_PROXY_CERT_SHA256:?pin the Eden TLS certificate SHA-256} + LUDARIUM_SWITCH_EMBED_PORT: ${SWITCH_EMBED_PORT:-1231} + LUDARIUM_SWITCH_EMBED_ORIGIN: ${SWITCH_EMBED_ORIGIN:-} + LUDARIUM_SWITCH_CONTROL_URL: http://eden:8765/ + LUDARIUM_SWITCH_CONTROL_TOKEN: ${SWITCH_CONTROL_TOKEN:?set the internal Eden control token} + LUDARIUM_SWITCH_CATALOG_PREFIX: ${SWITCH_CATALOG_PREFIX:-roms/switch} + ports: + - ${SWITCH_EMBED_PORT:-1231}:8734 + volumes: + - ${SWITCH_DATA_ROOT:?set isolated app-owned Switch data}/config/.local/share/eden/keys:/app/switch-keys:rw + depends_on: + eden: + condition: service_healthy + + eden: + container_name: ${SWITCH_CONTAINER_NAME:-Ludarium-Switch} + image: ${SWITCH_IMAGE:-ludarium/eden-controller:0.4.9-rc.1} + labels: + net.unraid.docker.webui: "${SWITCH_PUBLIC_URL:-http://[IP]:[PORT:1262]/}" + net.unraid.docker.icon: "/boot/config/plugins/dockerMan/images/Ludarium-icon.svg" + net.unraid.docker.managed: "dockerman" + net.unraid.docker.shell: "" + environment: + PUID: ${PUID:-1654} + PGID: ${PGID:-1654} + TZ: ${TZ:-Europe/Brussels} + PIXELFLUX_WAYLAND: "true" + DRINODE: ${SWITCH_DRI_NODE:-/dev/dri/renderD128} + DRI_NODE: ${SWITCH_DRI_NODE:-/dev/dri/renderD128} + START_DOCKER: "false" + DISABLE_IPV6: "true" + CUSTOM_USER: ${SWITCH_REMOTE_USER:?set the Eden trusted-LAN username} + PASSWORD: ${SWITCH_REMOTE_PASSWORD:?set the Eden trusted-LAN password} + LUDARIUM_EDEN_CONTROL_TOKEN: ${SWITCH_CONTROL_TOKEN:?set the internal Eden control token} + # App-owned save directory the Game Data Vault captures and restores. Override only when a + # different Eden build stores its saves elsewhere. + LUDARIUM_EDEN_SAVE_ROOT: ${SWITCH_SAVE_ROOT:-/config/.local/share/eden} + TITLE: Ludarium Switch + FILE_MANAGER_PATH: /games + SUBFOLDER: /switch-player/ + HARDEN_DESKTOP: "true" + HARDEN_OPENBOX: "true" + SELKIES_ENABLE_SHARING: "false|locked" + SELKIES_ENABLE_COLLAB: "false|locked" + SELKIES_ENABLE_SHARED: "false|locked" + SELKIES_FILE_TRANSFERS: "false|locked" + SELKIES_COMMAND_ENABLED: "false|locked" + SELKIES_CLIPBOARD_ENABLED: "false|locked" + SELKIES_CLIPBOARD_IN_ENABLED: "false|locked" + SELKIES_CLIPBOARD_OUT_ENABLED: "false|locked" + SELKIES_MICROPHONE_ENABLED: "false|locked" + SELKIES_UI_SIDEBAR_SHOW_CLIPBOARD: "false|locked" + SELKIES_UI_SIDEBAR_SHOW_FILES: "false|locked" + SELKIES_UI_SIDEBAR_SHOW_APPS: "false|locked" + ports: + - ${SWITCH_REMOTE_PORT:-1262}:3001 + devices: + - /dev/dri:/dev/dri + shm_size: 1gb + volumes: + - ${SWITCH_DATA_ROOT}/config:/config:rw + - ${SWITCH_LIBRARY:?set the Switch source library}:/games:ro + security_opt: + - no-new-privileges:true + healthcheck: + test: + - CMD + - python3 + - -c + - "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8765/health', timeout=3).read()" + interval: 10s + timeout: 5s + retries: 12 + start_period: 30s + restart: unless-stopped diff --git a/deploy/compose.yml b/deploy/compose.yml new file mode 100644 index 0000000..79c9c18 --- /dev/null +++ b/deploy/compose.yml @@ -0,0 +1,52 @@ +name: ludarium +services: + ludarium: + container_name: ${LUDARIUM_CONTAINER_NAME:-Ludarium} + build: + context: .. + dockerfile: Dockerfile.unraid + args: + LUDARIUM_SOURCE_REVISION: ${GITEA_COMMIT_SHA:-local} + LUDARIUM_RELEASE_VERSION: ${LUDARIUM_RELEASE_VERSION:-0.4.19-rc.8} + image: ${LUDARIUM_IMAGE:-ludarium/ludarium:0.4.19-rc.8} + environment: + ConnectionStrings__Ludarium: Host=127.0.0.1;Database=ludarium;Username=ludarium;Password=${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD};GSS Encryption Mode=Disable + LUDARIUM_ADMIN_TOKEN: ${LUDARIUM_ADMIN_TOKEN:?set LUDARIUM_ADMIN_TOKEN} + RAWG_API_KEY: ${RAWG_API_KEY:-} + IGDB_CLIENT_ID: ${IGDB_CLIENT_ID:-} + IGDB_CLIENT_SECRET: ${IGDB_CLIENT_SECRET:-} + MOBYGAMES_API_KEY: ${MOBYGAMES_API_KEY:-} + SCREENSCRAPER_DEVID: ${SCREENSCRAPER_DEVID:-} + SCREENSCRAPER_DEVPASSWORD: ${SCREENSCRAPER_DEVPASSWORD:-} + SCREENSCRAPER_SOFTNAME: ${SCREENSCRAPER_SOFTNAME:-Ludarium} + SCREENSCRAPER_USER: ${SCREENSCRAPER_USER:-} + SCREENSCRAPER_PASSWORD: ${SCREENSCRAPER_PASSWORD:-} + RETROACHIEVEMENTS_API_KEY: ${RETROACHIEVEMENTS_API_KEY:-} + RETROACHIEVEMENTS_USERNAME: ${RETROACHIEVEMENTS_USERNAME:-} + BROWSERPLAY_MAX_SESSIONS: ${BROWSERPLAY_MAX_SESSIONS:-2} + POSTGRES_DB: ludarium + POSTGRES_USER: ludarium + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD} + PUID: ${PUID:-1654} + PGID: ${PGID:-1654} + TZ: ${TZ:-Europe/Brussels} + ports: ["${LUDARIUM_HTTP_PORT:-1230}:8734"] + labels: + net.unraid.docker.webui: "${LUDARIUM_PUBLIC_URL:-http://[IP]:[PORT:1230]/}" + net.unraid.docker.icon: "/boot/config/plugins/dockerMan/images/Ludarium-icon.svg" + net.unraid.docker.managed: "dockerman" + net.unraid.docker.shell: "" + volumes: + - ${LUDARIUM_DATA_ROOT:-./data}/app:/app/data:rw + - ${LUDARIUM_DATA_ROOT:-./data}/cache:/app/cache:rw + - ${LUDARIUM_DATA_ROOT:-./data}/exports:/app/exports:rw + - ${LUDARIUM_DATA_ROOT:-./data}/postgres:/var/lib/postgresql/data:rw + - ${GAMES_LIBRARY:-./fixtures/games}:/library/games:ro + - ${PS4_LIBRARY:-./fixtures/ps4}:/library/ps4:ro + - ${PS5_LIBRARY:-./fixtures/ps5}:/library/ps5:ro + read_only: true + tmpfs: [/tmp, /var/run/postgresql] + cap_drop: [ALL] + cap_add: [CHOWN, DAC_OVERRIDE, FOWNER, KILL, SETGID, SETUID] + security_opt: [no-new-privileges:true] + restart: unless-stopped diff --git a/deploy/dolphin-controller-run b/deploy/dolphin-controller-run new file mode 100644 index 0000000..3fc957e --- /dev/null +++ b/deploy/dolphin-controller-run @@ -0,0 +1,2 @@ +#!/usr/bin/with-contenv bash +exec /usr/bin/python3 /opt/ludarium/dolphin-controller.py diff --git a/deploy/dolphin-controller.py b/deploy/dolphin-controller.py new file mode 100644 index 0000000..03c95da --- /dev/null +++ b/deploy/dolphin-controller.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +"""Ludarium Dolphin (GameCube and Wii) sidecar controller. + +Only the Dolphin-specific runtime identity lives here; the bounded HTTP control surface, +path validation, display resolution and process supervision are shared with every other +Ludarium emulator sidecar. See deploy/ludarium_sidecar.py. +""" +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +import ludarium_sidecar as sidecar + +TOKEN = os.environ.get("LUDARIUM_DOLPHIN_CONTROL_TOKEN", "") +GAMES = os.environ.get("LUDARIUM_GAMES_ROOT", "/games") +# App-owned save directory. Ludarium exports and restores it through the vault; the exact +# location differs per emulator build, so the operator can override it. +SAVE_ROOT = os.environ.get("LUDARIUM_DOLPHIN_SAVE_ROOT", "/config/.local/share/dolphin-emu") + +# Dolphin's profile also holds shader caches and dumps, which dwarf one bounded revision. +# Only the GameCube memory cards, the Wii NAND and the savestates are captured. +SAVE_PATHS = tuple(part.strip() + for part in os.environ.get("LUDARIUM_DOLPHIN_SAVE_PATHS", "").split(",") + if part.strip()) or ("GC", "Wii", "StateSaves") +RUNTIME = sidecar.EmulatorRuntime( + name="dolphin", + process_name="dolphin-emu", + launch_argv=lambda target: ["/usr/bin/dolphin-emu", "--batch", "--exec", str(target)], + games=GAMES, + extensions={".iso", ".gcm", ".rvz", ".gcz", ".wbfs", ".wia"}, + hotkeys={"pause-resume": "F10", "fullscreen": "alt+Return", + "save-state": "shift+F1", "load-state": "F1"}, + save_root=SAVE_ROOT, + save_paths=SAVE_PATHS, + save_mode="native-and-savestate-persistent", + # Dolphin's own main window also carries "Dolphin" in its title, so the launched + # process id is the authoritative match and this pattern is only a bounded fallback. + window_name="Dolphin", + # Both source libraries are mounted under one /games root, so the runtime path must + # name its platform directory. The API only ever emits "gamecube/..." or "wii/...". + platforms={"gamecube", "wii"}, +) + + +def main(): + sidecar.serve(RUNTIME, TOKEN) + + +if __name__ == "__main__": + main() diff --git a/deploy/eden-controller-run b/deploy/eden-controller-run new file mode 100644 index 0000000..11a7e74 --- /dev/null +++ b/deploy/eden-controller-run @@ -0,0 +1,2 @@ +#!/usr/bin/with-contenv bash +exec /usr/bin/python3 /opt/ludarium/eden-controller.py diff --git a/deploy/eden-controller.py b/deploy/eden-controller.py new file mode 100644 index 0000000..dbdb49e --- /dev/null +++ b/deploy/eden-controller.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python3 +"""Ludarium Eden (Nintendo Switch) sidecar controller. + +Only the Switch-specific runtime identity lives here; the bounded HTTP control surface, +path validation, display resolution and process supervision are shared with every other +Ludarium emulator sidecar. See deploy/ludarium_sidecar.py. +""" +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +import ludarium_sidecar as sidecar + +TOKEN = os.environ.get("LUDARIUM_EDEN_CONTROL_TOKEN", "") +GAMES = os.environ.get("LUDARIUM_GAMES_ROOT", "/games") +# App-owned save directory. Ludarium exports and restores it through the vault; the exact +# location differs per emulator build, so the operator can override it. +SAVE_ROOT = os.environ.get("LUDARIUM_EDEN_SAVE_ROOT", "/config/.local/share/eden") + +# Eden keeps Switch save data in its emulated NAND user profile. +SAVE_PATHS = tuple(part.strip() + for part in os.environ.get("LUDARIUM_EDEN_SAVE_PATHS", "").split(",") + if part.strip()) or ("nand/user/save",) +RUNTIME = sidecar.EmulatorRuntime( + name="eden", + process_name="eden", + launch_argv=lambda target: ["/usr/bin/eden", "-f", "-g", str(target)], + games=GAMES, + extensions={".xci", ".nsp"}, + hotkeys={"pause-resume": "F4", "fullscreen": "F11"}, + save_root=SAVE_ROOT, + save_paths=SAVE_PATHS, + save_mode="native-persistent", + # Eden titles the running game window "eden |"; the emulator's own front-end + # window does not match, so a hotkey cannot reach it if _NET_WM_PID is unavailable. + window_name=r"eden .* \|", +) + + +def main(): + sidecar.serve(RUNTIME, TOKEN) + + +if __name__ == "__main__": + main() diff --git a/deploy/ludarium-entrypoint.sh b/deploy/ludarium-entrypoint.sh new file mode 100644 index 0000000..8d6e132 --- /dev/null +++ b/deploy/ludarium-entrypoint.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +postgres_pid="" +app_pid="" +shutdown_requested=0 +terminate_children() { + [[ -z "$app_pid" ]] || kill -TERM "$app_pid" 2>/dev/null || true + [[ -z "$postgres_pid" ]] || kill -TERM "$postgres_pid" 2>/dev/null || true + for _ in $(seq 1 10); do + if { [[ -z "$app_pid" ]] || ! kill -0 "$app_pid" 2>/dev/null; } && + { [[ -z "$postgres_pid" ]] || ! kill -0 "$postgres_pid" 2>/dev/null; }; then + break + fi + sleep 1 + done + [[ -z "$app_pid" ]] || ! kill -0 "$app_pid" 2>/dev/null || kill -KILL "$app_pid" 2>/dev/null || true + [[ -z "$postgres_pid" ]] || ! kill -0 "$postgres_pid" 2>/dev/null || kill -KILL "$postgres_pid" 2>/dev/null || true + [[ -z "$app_pid" ]] || wait "$app_pid" 2>/dev/null || true + [[ -z "$postgres_pid" ]] || wait "$postgres_pid" 2>/dev/null || true +} +shutdown() { + shutdown_requested=1 + trap - TERM INT + terminate_children +} +trap shutdown TERM INT + +runtime_owner="${PUID:-1654}:${PGID:-1654}" +for app_directory in /app/data /app/cache /app/exports; do + current_owner="$(stat -c '%u:%g' "$app_directory")" + if [[ "$current_owner" != "$runtime_owner" ]]; then + chown -R "$runtime_owner" "$app_directory" + fi +done +/usr/local/bin/docker-entrypoint.sh postgres & +postgres_pid=$! +for attempt in $(seq 1 60); do + if pg_isready --host=127.0.0.1 --username="${POSTGRES_USER:-ludarium}" --dbname="${POSTGRES_DB:-ludarium}" >/dev/null 2>&1; then break; fi + if ! kill -0 "$postgres_pid" 2>/dev/null; then wait "$postgres_pid"; exit $?; fi + if [[ "$attempt" == "60" ]]; then echo "PostgreSQL did not become ready within 60 seconds" >&2; exit 1; fi + sleep 1 +done + +/usr/local/bin/repair-postgres-collation.sh + +gosu "${PUID:-1654}:${PGID:-1654}" /usr/share/dotnet/dotnet /app/Ludarium.Api.dll & +app_pid=$! +set +e +wait -n "$postgres_pid" "$app_pid" +first_status=$? +set -e + +if [[ "$shutdown_requested" == "1" ]]; then + exit 0 +fi + +if ! kill -0 "$postgres_pid" 2>/dev/null; then + echo "PostgreSQL exited unexpectedly; stopping the Ludarium API." >&2 + terminate_children + [[ "$first_status" == "0" ]] && exit 1 + exit "$first_status" +fi + +echo "The Ludarium API exited; stopping embedded PostgreSQL." >&2 +terminate_children +exit "$first_status" diff --git a/deploy/ludarium-unraid.xml b/deploy/ludarium-unraid.xml new file mode 100644 index 0000000..162a3fa --- /dev/null +++ b/deploy/ludarium-unraid.xml @@ -0,0 +1,29 @@ +<?xml version="1.0"?> +<Container version="2"> + <Name>Ludarium</Name><Repository>ludarium/ludarium:0.4.19-rc.8</Repository><Registry>https://hub.docker.com/r/ludarium/ludarium</Registry> + <Network>bridge</Network><Privileged>false</Privileged><Support>https://github.com/ludarium/ludarium</Support><Project>https://github.com/ludarium/ludarium</Project> + <ExtraParams>--read-only --cap-drop=ALL --cap-add=CHOWN --cap-add=DAC_OVERRIDE --cap-add=FOWNER --cap-add=KILL --cap-add=SETGID --cap-add=SETUID --security-opt=no-new-privileges --tmpfs=/tmp --tmpfs=/var/run/postgresql</ExtraParams> + <Overview>Local-first game archive intelligence. Source libraries must be mapped read-only.</Overview><WebUI>http://[IP]:[PORT:1230]/</WebUI><Icon>/boot/config/plugins/dockerMan/images/Ludarium-icon.svg</Icon> + <Config Name="Web UI" Target="8734" Default="1230" Mode="tcp" Description="Ludarium web port" Type="Port" Display="always" Required="true" Mask="false"/> + <Config Name="Appdata" Target="/app/data" Default="/mnt/user/appdata/ludarium" Mode="rw" Type="Path" Display="always" Required="true" Mask="false"/> + <Config Name="Exports" Target="/app/exports" Default="/mnt/user/appdata/ludarium/exports" Mode="rw" Type="Path" Display="advanced" Required="true" Mask="false"/> + <Config Name="Games library" Target="/library/games" Default="/mnt/user/Media/Games" Mode="ro" Description="Must remain read-only" Type="Path" Display="always" Required="true" Mask="false"/> + <Config Name="PS4 library" Target="/library/ps4" Default="/mnt/user/PS4-Games" Mode="ro" Description="Must remain read-only" Type="Path" Display="always" Required="true" Mask="false"/> + <Config Name="PS5 library" Target="/library/ps5" Default="/mnt/user/PS5-Games" Mode="ro" Description="Must remain read-only" Type="Path" Display="always" Required="true" Mask="false"/> + <Config Name="Database connection" Target="ConnectionStrings__Ludarium" Default="" Mode="" Type="Variable" Display="always" Required="true" Mask="true"/> + <Config Name="Admin token" Target="LUDARIUM_ADMIN_TOKEN" Default="" Mode="" Type="Variable" Display="always" Required="true" Mask="true"/> + <Config Name="RAWG API key" Target="RAWG_API_KEY" Default="" Mode="" Description="Optional: enables online wishlist discovery; the offline core remains fully usable without it" Type="Variable" Display="advanced" Required="false" Mask="true"/> + <Config Name="IGDB client ID" Target="IGDB_CLIENT_ID" Default="" Mode="" Description="Optional native metadata provider" Type="Variable" Display="advanced" Required="false" Mask="false"/> + <Config Name="IGDB client secret" Target="IGDB_CLIENT_SECRET" Default="" Mode="" Description="Optional native metadata provider secret" Type="Variable" Display="advanced" Required="false" Mask="true"/> + <Config Name="MobyGames API key" Target="MOBYGAMES_API_KEY" Default="" Mode="" Description="Optional native metadata provider" Type="Variable" Display="advanced" Required="false" Mask="true"/> + <Config Name="ScreenScraper developer ID" Target="SCREENSCRAPER_DEVID" Default="" Mode="" Description="Optional native metadata provider" Type="Variable" Display="advanced" Required="false" Mask="true"/> + <Config Name="ScreenScraper developer password" Target="SCREENSCRAPER_DEVPASSWORD" Default="" Mode="" Description="Optional native metadata provider secret" Type="Variable" Display="advanced" Required="false" Mask="true"/> + <Config Name="ScreenScraper software name" Target="SCREENSCRAPER_SOFTNAME" Default="Ludarium" Mode="" Description="Optional native metadata provider client name" Type="Variable" Display="advanced" Required="false" Mask="false"/> + <Config Name="ScreenScraper user" Target="SCREENSCRAPER_USER" Default="" Mode="" Description="Optional personal ScreenScraper account" Type="Variable" Display="advanced" Required="false" Mask="false"/> + <Config Name="ScreenScraper password" Target="SCREENSCRAPER_PASSWORD" Default="" Mode="" Description="Optional personal ScreenScraper account secret" Type="Variable" Display="advanced" Required="false" Mask="true"/> + <Config Name="RetroAchievements API key" Target="RETROACHIEVEMENTS_API_KEY" Default="" Mode="" Description="Optional native achievements provider" Type="Variable" Display="advanced" Required="false" Mask="true"/> + <Config Name="RetroAchievements user" Target="RETROACHIEVEMENTS_USERNAME" Default="" Mode="" Description="Optional personal achievement progress" Type="Variable" Display="advanced" Required="false" Mask="false"/> + <Config Name="Timezone" Target="TZ" Default="Europe/Brussels" Mode="" Type="Variable" Display="advanced" Required="true" Mask="false"/> + <Config Name="Runtime UID" Target="PUID" Default="1000" Mode="" Type="Variable" Display="advanced" Required="true" Mask="false"/> + <Config Name="Runtime GID" Target="PGID" Default="1000" Mode="" Type="Variable" Display="advanced" Required="true" Mask="false"/> +</Container> diff --git a/deploy/ludarium_sidecar.py b/deploy/ludarium_sidecar.py new file mode 100644 index 0000000..231e499 --- /dev/null +++ b/deploy/ludarium_sidecar.py @@ -0,0 +1,474 @@ +#!/usr/bin/env python3 +"""Shared control-surface primitives for Ludarium emulator sidecars. + +Every isolated player sidecar exposes the same bounded surface: a token-authenticated +HTTP server on :8765 with /health, /v1/status, /v1/launch and /v1/action. Only the +emulator binary, its game-path allowlist, its window identity and its hotkey map +differ, so a concrete controller supplies only those. + +Keeping the surface in one module is a safety property, not only tidiness. The +Xwayland display resolution below was found and fixed against the live Eden runtime; +a per-sidecar copy silently missed that fix. Anything a future Azahar, xemu, Cemu or +Vita3K controller needs belongs here so it inherits the same behaviour. + +The module has no third-party dependencies: the sidecar images are minimized and must +not regain a package manager surface. +""" +import hmac +import io +import json +import os +import signal +import subprocess +import tarfile +import tempfile +import threading +import time +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path, PurePosixPath + +CONTROL_PORT = 8765 +MIN_REQUEST_BYTES = 2 +MAX_REQUEST_BYTES = 8192 +# The Ludarium game-data vault stores one revision of at most 64 MiB, so an archive larger than that +# could never be kept. Refusing it here keeps the failure at the sidecar instead of half-way upstream. +MAX_SAVE_DATA_BYTES = 64 * 1024 * 1024 +MAX_SAVE_DATA_ENTRIES = 20000 +MAX_SAVE_DATA_ENTRY_BYTES = 256 * 1024 * 1024 +MAX_SAVE_DATA_EXPANDED_BYTES = 512 * 1024 * 1024 +SETUIDGID = "/usr/bin/s6-setuidgid" +RUNTIME_USER = "abc" +XDOTOOL = "/usr/bin/xdotool" + + +def respond(handler, status, body): + payload = json.dumps(body).encode("utf-8") + handler.send_response(status) + handler.send_header("Content-Type", "application/json") + handler.send_header("Content-Length", str(len(payload))) + handler.end_headers() + handler.wfile.write(payload) + + +def authorized(handler, token): + supplied = handler.headers.get("X-Ludarium-Control-Token", "") + return bool(token) and hmac.compare_digest(token, supplied) + + +def resolve_game_path(value, games, extensions, platforms=None): + """Resolve a runtime-relative game path to an exact file inside the read-only mount. + + This is the sidecar's last defence. The API already validates and prefixes the path, + but the controller must never execute anything it cannot prove lives inside the + games mount, carries an allowlisted extension and is a regular file. Symlinks are + resolved before the containment check, so a link out of the mount fails closed. + """ + relative = PurePosixPath(value) + if relative.is_absolute() or not relative.parts: + raise ValueError("Unsafe game path") + if any(part in ("", ".", "..") for part in relative.parts): + raise ValueError("Unsafe game path") + if platforms is not None and (len(relative.parts) < 2 or relative.parts[0] not in platforms): + raise ValueError("Unsafe game path") + root = Path(games).resolve() + target = (root / Path(*relative.parts)).resolve() + if root not in target.parents: + raise ValueError("Game is outside the read-only games mount") + if target.suffix.lower() not in extensions: + raise ValueError("Game is not an allowlisted read-only image") + if not target.is_file(): + raise ValueError("Game is not a present read-only file") + return target + + +def desktop_display(process_name): + """Resolve the Xwayland display owned by the active emulator desktop. + + PixelFlux allocates a new display number after container restarts while retaining + older socket files. Reading the running desktop process is authoritative; the newest + socket is only a bounded fallback for the short interval before the emulator has + published its environment. + """ + try: + process = subprocess.run( + ["/usr/bin/pgrep", "-o", "-x", process_name], capture_output=True, text=True, + timeout=2, check=False) + pid = process.stdout.strip() + if process.returncode == 0 and pid.isdigit(): + environment = subprocess.run( + [SETUIDGID, RUNTIME_USER, "/bin/cat", f"/proc/{pid}/environ"], + capture_output=True, timeout=2, check=False) + for item in environment.stdout.split(b"\0"): + if item.startswith(b"DISPLAY=:"): + display = item.removeprefix(b"DISPLAY=").decode("ascii", errors="ignore") + if display[1:].isdigit(): + return display + except (OSError, subprocess.SubprocessError): + pass + + sockets = [] + try: + for socket in Path("/tmp/.X11-unix").glob("X*"): + if socket.name[1:].isdigit(): + sockets.append((socket.stat().st_mtime_ns, int(socket.name[1:]))) + except OSError: + pass + if sockets: + return f":{max(sockets)[1]}" + return os.environ.get("DISPLAY", ":0") + + +def resolve_save_paths(save_root, save_paths): + """Resolve the subdirectories of a profile that actually hold save data. + + An emulator profile is not a save directory. Dolphin's holds shader caches and dumps that dwarf + the bounded revision size, so capturing the whole profile would fail on any real installation. + Naming the save-bearing subdirectories keeps a capture to what an operator wants back, and + resolving them here means a wrong path is visible in /v1/status instead of silently capturing + the wrong thing. + """ + if save_root is None: + return [] + root = Path(save_root) + if not root.is_dir(): + return [] + root = root.resolve() + if not save_paths: + return [root] + resolved = [] + for relative in save_paths: + candidate = PurePosixPath(relative) + if candidate.is_absolute() or any(part in ("", ".", "..") for part in candidate.parts): + continue + target = (root / Path(*candidate.parts)).resolve() + if root in target.parents and target.is_dir(): + resolved.append(target) + return resolved + + +def export_save_data(save_root, save_paths=()): + """Pack an emulator's app-owned save directories into one bounded gzip archive. + + Only regular files are packed and every ownership, device and symlink attribute is dropped, so + the archive describes save content and nothing about the container it came from. Paths stay + relative to the profile root, so a restore lands exactly where the capture came from. + """ + root = Path(save_root).resolve() if save_root is not None else None + directories = resolve_save_paths(save_root, save_paths) + if not directories: + raise ValueError("No save-data directory is present for this runtime") + buffer = io.BytesIO() + packed = 0 + with tarfile.open(fileobj=buffer, mode="w:gz") as archive: + for directory in directories: + for path in sorted(directory.rglob("*")): + if path.is_symlink() or not path.is_file(): + continue + packed += 1 + if packed > MAX_SAVE_DATA_ENTRIES: + raise ValueError("The save directories hold more files than one revision may carry") + info = archive.gettarinfo(str(path), arcname=str(path.relative_to(root).as_posix())) + info.uid = info.gid = 0 + info.uname = info.gname = "" + info.mode = 0o644 + info.mtime = int(info.mtime) + with path.open("rb") as content: + archive.addfile(info, content) + if buffer.tell() > MAX_SAVE_DATA_BYTES: + raise ValueError("The save-data archive exceeds the bounded revision size") + if packed == 0: + raise ValueError("The configured save directories hold no save data yet") + return buffer.getvalue() + + +def import_save_data(save_root, payload, save_paths=()): + """Restore a previously exported archive over an emulator's save directories. + + The archive is operator data that has travelled through the vault, so every member is checked + before extraction: only plain relative files are written, nothing may escape the root, and a + member must land inside one of the configured save directories. A restore therefore cannot + reach an emulator's configuration even if the stored archive was crafted to try. + """ + root = Path(save_root).resolve() if save_root is not None else None + directories = resolve_save_paths(save_root, save_paths) + if root is None or not root.is_dir() or not directories: + raise ValueError("No save-data directory is present for this runtime") + if len(payload) > MAX_SAVE_DATA_BYTES: + raise ValueError("The save-data archive exceeds the bounded revision size") + with tarfile.open(fileobj=io.BytesIO(payload), mode="r:gz") as archive: + members = archive.getmembers() + if len(members) > MAX_SAVE_DATA_ENTRIES: + raise ValueError("The save-data archive holds more files than one revision may carry") + expanded = 0 + validated = [] + for member in members: + if not member.isfile(): + raise ValueError(f"The save-data archive contains an unsupported entry '{member.name}'") + if member.size < 0 or member.size > MAX_SAVE_DATA_ENTRY_BYTES: + raise ValueError(f"The save-data archive entry '{member.name}' is too large") + expanded += member.size + if expanded > MAX_SAVE_DATA_EXPANDED_BYTES: + raise ValueError("The expanded save-data archive exceeds the restore limit") + relative = PurePosixPath(member.name) + if relative.is_absolute() or any(part in ("", ".", "..") for part in relative.parts): + raise ValueError(f"The save-data archive contains an unsafe path '{member.name}'") + destination = (root / Path(*relative.parts)).resolve() + if root not in destination.parents: + raise ValueError(f"The save-data archive escapes the save directory at '{member.name}'") + if not any(directory == destination or directory in destination.parents + for directory in directories): + raise ValueError(f"The save-data archive targets '{member.name}' outside the save directories") + validated.append((member, destination)) + + restored = 0 + for member, destination in validated: + destination.parent.mkdir(parents=True, exist_ok=True) + source = archive.extractfile(member) + if source is None: + raise ValueError(f"The save-data archive entry '{member.name}' is unreadable") + temporary = None + try: + with source, tempfile.NamedTemporaryFile(dir=destination.parent, delete=False) as output: + temporary = Path(output.name) + copied = 0 + while chunk := source.read(1024 * 1024): + copied += len(chunk) + if copied > member.size or copied > MAX_SAVE_DATA_ENTRY_BYTES: + raise ValueError(f"The save-data archive entry '{member.name}' exceeds its declared size") + output.write(chunk) + if copied != member.size: + raise ValueError(f"The save-data archive entry '{member.name}' is truncated") + os.chmod(temporary, 0o644) + os.replace(temporary, destination) + temporary = None + restored += 1 + finally: + if temporary is not None: + temporary.unlink(missing_ok=True) + return restored + + +def runtime_environment(process_name): + return { + **os.environ, + "HOME": "/config", + "DISPLAY": desktop_display(process_name), + "XDG_RUNTIME_DIR": "/config/.XDG", + "WAYLAND_DISPLAY": "wayland-0", + } + + +def visible_windows(environment, pid=None, name=None): + """List visible window ids for the launched title. + + The launched process id is authoritative: it is the exact emulator Ludarium started, + so a hotkey can never reach an unrelated window such as an emulator's own menu or + file browser. Matching on the window name stays as a bounded fallback for runtimes + that do not publish _NET_WM_PID. + """ + for query in ([["--pid", str(pid)]] if pid else []) + ([["--name", name]] if name else []): + found = subprocess.run( + [SETUIDGID, RUNTIME_USER, XDOTOOL, "search", "--onlyvisible", *query], + env=environment, capture_output=True, text=True, timeout=5, check=False) + windows = [line for line in found.stdout.splitlines() if line.isdigit()] + if windows: + return windows + return [] + + +class EmulatorRuntime: + """Owns the single title process a sidecar may run at a time.""" + + def __init__(self, name, process_name, launch_argv, games, extensions, hotkeys, + save_mode, platforms=None, window_name=None, ready_seconds=1.0, save_root=None, + save_paths=()): + self.name = name + self.process_name = process_name + self.launch_argv = launch_argv + self.games = games + self.extensions = extensions + self.hotkeys = hotkeys + self.save_mode = save_mode + self.platforms = platforms + self.window_name = window_name + self.ready_seconds = ready_seconds + self.save_root = save_root + self.save_paths = tuple(save_paths) + self.lock = threading.Lock() + self.active = None + + def log(self, message): + print(f"{self.name}-controller: {message}", flush=True) + + def resolve(self, value): + return resolve_game_path(value, self.games, self.extensions, self.platforms) + + def environment(self): + return runtime_environment(self.process_name) + + def resolved_save_paths(self): + return resolve_save_paths(self.save_root, self.save_paths) + + @property + def save_data_available(self): + return len(self.resolved_save_paths()) > 0 + + def status(self): + directories = self.resolved_save_paths() + with self.lock: + running = self.active is not None and self.active.poll() is None + return {"running": running, "pid": self.active.pid if running else None, + "saveMode": self.save_mode, "saveData": len(directories) > 0, + # Naming what will be captured makes a misconfigured path visible here rather + # than at the moment an operator tries to keep their progress. + "saveRoot": self.save_root, + "savePaths": [str(directory) for directory in directories]} + + def terminate_locked(self): + if self.active is None or self.active.poll() is not None: + self.active = None + return False + self.active.send_signal(signal.SIGTERM) + try: + self.active.wait(timeout=8) + except subprocess.TimeoutExpired: + self.active.kill() + self.active.wait(timeout=3) + self.active = None + return True + + def stop(self): + with self.lock: + return self.terminate_locked() + + def launch(self, target): + environment = self.environment() + with self.lock: + self.terminate_locked() + self.active = subprocess.Popen( + [SETUIDGID, RUNTIME_USER, *self.launch_argv(target)], env=environment, + stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + start_new_session=True) + process = self.active + pid = process.pid + time.sleep(self.ready_seconds) + if process.poll() is not None: + self.log(f"{self.name} exited early with code {process.returncode}; " + f"display={environment['DISPLAY']}; game={target}") + return None + return pid + + def hotkey(self, key): + environment = self.environment() + with self.lock: + pid = self.active.pid if self.active is not None and self.active.poll() is None else None + windows = visible_windows(environment, pid, self.window_name) + if not windows: + self.log(f"no visible {self.name} title window for hotkey {key}; " + f"display={environment['DISPLAY']}; pid={pid}") + return False + subprocess.run( + [SETUIDGID, RUNTIME_USER, XDOTOOL, "windowactivate", "--sync", windows[-1], "key", key], + env=environment, timeout=5, check=True) + return True + + +def build_handler(runtime, token): + class Handler(BaseHTTPRequestHandler): + server_version = f"Ludarium{runtime.name.capitalize()}Controller/1" + + def log_message(self, fmt, *args): + runtime.log(fmt % args) + + def do_GET(self): + if self.path == "/health": + return respond(self, 200, {"status": "healthy"}) + if not authorized(self, token): + return respond(self, 404, {"message": "Not found"}) + if self.path == "/v1/status": + return respond(self, 200, runtime.status()) + if self.path == "/v1/save-data": + return self.export_save_data() + return respond(self, 404, {"message": "Not found"}) + + def export_save_data(self): + if not runtime.save_data_available: + return respond(self, 409, {"message": f"No {runtime.name} save directory is configured"}) + try: + payload = export_save_data(runtime.save_root, runtime.save_paths) + except (ValueError, OSError, tarfile.TarError) as error: + runtime.log(f"save-data export failed: {error}") + return respond(self, 409, {"message": str(error)}) + self.send_response(200) + self.send_header("Content-Type", "application/gzip") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + + def do_POST(self): + if not authorized(self, token): + return respond(self, 404, {"message": "Not found"}) + if self.path == "/v1/save-data": + return self.import_save_data() + body = {} + try: + length = int(self.headers.get("Content-Length", "0")) + if length < MIN_REQUEST_BYTES or length > MAX_REQUEST_BYTES: + raise ValueError("Invalid request size") + body = json.loads(self.rfile.read(length)) + if not isinstance(body, dict): + raise ValueError("Invalid request body") + if self.path == "/v1/launch": + return self.launch(body) + if self.path == "/v1/action": + return self.action(body) + return respond(self, 404, {"message": "Not found"}) + except (ValueError, json.JSONDecodeError) as error: + runtime.log(f"rejected {self.path}: {error}; " + f"path={body.get('path') if isinstance(body, dict) else None!r}") + return respond(self, 400, {"message": str(error)}) + except (OSError, subprocess.SubprocessError) as error: + return respond(self, 503, {"message": f"{runtime.name} control operation failed", + "detail": str(error)}) + + def import_save_data(self): + if not runtime.save_data_available: + return respond(self, 409, {"message": f"No {runtime.name} save directory is configured"}) + length = int(self.headers.get("Content-Length", "0")) + if length <= 0 or length > MAX_SAVE_DATA_BYTES: + return respond(self, 400, {"message": "The save-data archive is empty or exceeds its bound"}) + payload = self.rfile.read(length) + try: + written = import_save_data(runtime.save_root, payload, runtime.save_paths) + except (ValueError, OSError, tarfile.TarError, EOFError) as error: + runtime.log(f"save-data restore rejected: {error}") + return respond(self, 400, {"message": str(error)}) + runtime.log(f"restored {written} save-data files") + return respond(self, 202, {"state": "restored", "files": written}) + + def launch(self, body): + target = runtime.resolve(body.get("path", "")) + pid = runtime.launch(target) + if pid is None: + return respond(self, 503, {"message": f"{runtime.name} exited before the title started"}) + return respond(self, 202, {"state": "starting", "pid": pid, "saveMode": runtime.save_mode}) + + def action(self, body): + action = body.get("action", "") + if action == "stop": + if not runtime.stop(): + return respond(self, 409, {"message": f"No Ludarium-started {runtime.name} title is running"}) + return respond(self, 202, {"state": "accepted", "action": action}) + if action not in runtime.hotkeys: + raise ValueError("Unsupported action") + if not runtime.hotkey(runtime.hotkeys[action]): + return respond(self, 409, {"message": f"No running {runtime.name} title window was found"}) + return respond(self, 202, {"state": "accepted", "action": action}) + + return Handler + + +def serve(runtime, token, port=CONTROL_PORT): + if not token: + raise SystemExit(f"A {runtime.name} control token is required") + ThreadingHTTPServer(("0.0.0.0", port), build_handler(runtime, token)).serve_forever() diff --git a/deploy/prepare-switch-appdata.sh b/deploy/prepare-switch-appdata.sh new file mode 100644 index 0000000..f79359f --- /dev/null +++ b/deploy/prepare-switch-appdata.sh @@ -0,0 +1,70 @@ +#!/bin/sh +set -eu + +: "${SWITCH_DATA_ROOT:?set the isolated app-owned Switch data root}" +: "${SWITCH_LIBRARY:?set the read-only Switch source library}" +: "${PUID:?set the non-root Eden UID}" +: "${PGID:?set the non-root Eden GID}" + +case "$PUID:$PGID" in + *[!0-9:]*|:*|*:) echo "PUID and PGID must be numeric." >&2; exit 1 ;; +esac + +if [ "$PUID" -eq 0 ] || [ "$PGID" -eq 0 ]; then + echo "Eden must use a non-root PUID and PGID." >&2 + exit 1 +fi + +case "$SWITCH_DATA_ROOT" in + /*) ;; + *) echo "SWITCH_DATA_ROOT must be absolute." >&2; exit 1 ;; +esac + +case "$SWITCH_LIBRARY" in + /*) ;; + *) echo "SWITCH_LIBRARY must be absolute." >&2; exit 1 ;; +esac + +data_parent=$(dirname -- "$SWITCH_DATA_ROOT") +data_name=$(basename -- "$SWITCH_DATA_ROOT") +if [ ! -d "$data_parent" ]; then + echo "The SWITCH_DATA_ROOT parent must already exist: $data_parent" >&2 + exit 1 +fi + +data_parent=$(readlink -f -- "$data_parent") +data_root=$(readlink -m -- "$data_parent/$data_name") +library_root=$(readlink -f -- "$SWITCH_LIBRARY") + +case "$data_root" in + /|/mnt|/mnt/user|/mnt/user/appdata) + echo "Refusing unsafe SWITCH_DATA_ROOT: $data_root" >&2 + exit 1 + ;; +esac + +case "$data_root/" in + "$library_root"/*) + echo "SWITCH_DATA_ROOT must not be inside the source library." >&2 + exit 1 + ;; +esac + +case "$library_root/" in + "$data_root"/*) + echo "SWITCH_LIBRARY must not be inside app-owned data." >&2 + exit 1 + ;; +esac + +mkdir -p -- "$data_root/config" +data_root=$(readlink -f -- "$data_root") + +# Eden must be able to read and update its own persistent config after a PUID/PGID change. +# This path is app-owned; the source library is intentionally never touched. +chown -R "$PUID:$PGID" -- "$data_root/config" +chmod 750 -- "$data_root/config" +find "$data_root/config" -type f -name qt-config.ini -exec chmod 600 {} + + +printf 'Prepared %s for Eden identity %s:%s; source library untouched: %s\n' \ + "$data_root/config" "$PUID" "$PGID" "$library_root" diff --git a/deploy/repair-postgres-collation.sh b/deploy/repair-postgres-collation.sh new file mode 100644 index 0000000..c7e6516 --- /dev/null +++ b/deploy/repair-postgres-collation.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +database="${POSTGRES_DB:-ludarium}" +database_user="${POSTGRES_USER:-ludarium}" +if [[ ! "$database" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]]; then + echo "PostgreSQL collation repair requires a simple validated database identifier." >&2 + exit 1 +fi +psql_base=(psql -v ON_ERROR_STOP=1 -U "$database_user" -d "$database") + +versions="$("${psql_base[@]}" -At -F '|' -c \ + "select coalesce(datcollversion,''),coalesce(pg_database_collation_actual_version(oid),'') from pg_database where datname=current_database()")" +IFS='|' read -r stored_version actual_version <<< "$versions" + +if [[ -z "$actual_version" || "$stored_version" == "$actual_version" ]]; then + exit 0 +fi + +printf 'PostgreSQL collation drift detected for %s: stored=%s actual=%s\n' \ + "$database" "${stored_version:-<unversioned>}" "$actual_version" + +has_platform_definitions="$("${psql_base[@]}" -At -c \ + "select to_regclass('public.platform_definitions') is not null")" +if [[ "$has_platform_definitions" == "t" ]]; then + unsafe_duplicates="$("${psql_base[@]}" -At -c " + with duplicate_ids as ( + select id from platform_definitions group by id having count(*) > 1 + ), variants as ( + select p.id, + count(distinct jsonb_build_object( + 'custom',p.custom,'enabled',p.enabled,'version',p.version, + 'data',p.data - 'updatedAt')) as semantic_variants, + bool_or(p.custom) as contains_custom + from platform_definitions p join duplicate_ids d using(id) + group by p.id + ) + select count(*) from variants where contains_custom or semantic_variants <> 1")" + if [[ "$unsafe_duplicates" != "0" ]]; then + echo "Collation repair found ambiguous or custom platform duplicates; refusing automatic data changes." >&2 + exit 1 + fi + + removed_duplicates="$("${psql_base[@]}" -At -c " + with ranked as ( + select ctid,row_number() over(partition by id order by updated_at desc,ctid desc) as ordinal + from platform_definitions + ), removed as ( + delete from platform_definitions p using ranked r + where p.ctid=r.ctid and r.ordinal > 1 returning 1 + ) + select count(*) from removed")" + if [[ "$removed_duplicates" != "0" ]]; then + printf 'Removed %s semantically identical built-in platform duplicate(s) before reindex.\n' "$removed_duplicates" + fi +fi + +"${psql_base[@]}" -c "REINDEX DATABASE \"$database\"" >/dev/null +if [[ -z "$stored_version" ]]; then + "${psql_base[@]}" -c " + update pg_database + set datcollversion=pg_database_collation_actual_version(oid) + where datname=current_database() and datcollversion is null" >/dev/null +else + "${psql_base[@]}" -c "ALTER DATABASE \"$database\" REFRESH COLLATION VERSION" >/dev/null +fi + +refreshed_version="$("${psql_base[@]}" -At -c \ + "select coalesce(datcollversion,'') from pg_database where datname=current_database()")" +if [[ "$refreshed_version" != "$actual_version" ]]; then + echo "PostgreSQL collation version did not refresh to the runtime version." >&2 + exit 1 +fi +printf 'PostgreSQL collation indexes rebuilt and version refreshed to %s.\n' "$refreshed_version" diff --git a/deploy/run-candidate-gate.sh b/deploy/run-candidate-gate.sh new file mode 100644 index 0000000..417c7a2 --- /dev/null +++ b/deploy/run-candidate-gate.sh @@ -0,0 +1,255 @@ +#!/bin/sh +set -eu + +repo="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)" +compose="$repo/deploy/compose.yml" +project="${LUDARIUM_CANDIDATE_PROJECT:-ludarium-candidate}" +candidate_port="${LUDARIUM_CANDIDATE_PORT:-1232}" +data_root="${LUDARIUM_CANDIDATE_DATA_ROOT:?set an isolated candidate data root}" +release_identity="$(tr -d '\r\n' < "$repo/VERSION")" +image="${LUDARIUM_IMAGE:-ludarium/ludarium:$release_identity}" +release_version="${LUDARIUM_RELEASE_VERSION:-${image##*:}}" +playwright_image="mcr.microsoft.com/playwright:v1.62.1-noble@sha256:dcc5531e97840b9b5e794f2814476b21571c5124a3fca2267d73041f56e7580e" +browser_gate_mode="${LUDARIUM_BROWSER_GATE_MODE:-docker}" +dotnet_image="mcr.microsoft.com/dotnet/sdk:10.0.302@sha256:72dd743782f2ae7e5476fd64f6a460045e3998dc862218b80e6944cba79a01b0" +gate_cpus="${LUDARIUM_GATE_CPUS:-4}" +gate_memory="${LUDARIUM_GATE_MEMORY:-8g}" +browser_gate_cpus="${LUDARIUM_BROWSER_GATE_CPUS:-4}" +browser_gate_memory="${LUDARIUM_BROWSER_GATE_MEMORY:-4g}" +build_nodes="${LUDARIUM_GATE_BUILD_NODES:-2}" +export COMPOSE_PARALLEL_LIMIT="${LUDARIUM_COMPOSE_PARALLEL_LIMIT:-1}" + +case "$browser_gate_mode" in docker|external) ;; *) echo "browser gate mode must be docker or external" >&2; exit 2;; esac +case "$gate_cpus:$browser_gate_cpus:$build_nodes:$COMPOSE_PARALLEL_LIMIT" in + *[!0-9.:]*) echo "candidate resource limits must be positive numeric values" >&2; exit 2 ;; +esac +for value in "$gate_cpus" "$browser_gate_cpus" "$build_nodes" "$COMPOSE_PARALLEL_LIMIT"; do + [ "$value" != "0" ] || { echo "candidate resource limits must be greater than zero" >&2; exit 2; } +done +case "$gate_memory:$browser_gate_memory" in + *[!0-9kKmMgGtT:]*|:) echo "candidate memory limits must use Docker byte values such as 8g" >&2; exit 2 ;; +esac + +case "$data_root" in + /mnt/user/appdata/ludarium-candidate|/mnt/user/appdata/ludarium-candidate/*|/tmp/ludarium-candidate|/tmp/ludarium-candidate/*) ;; + *) echo "candidate data root must remain below /mnt/user/appdata/ludarium-candidate or /tmp/ludarium-candidate" >&2; exit 2 ;; +esac + +: "${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}" +: "${LUDARIUM_ADMIN_TOKEN:?set LUDARIUM_ADMIN_TOKEN}" +: "${GAMES_LIBRARY:?set GAMES_LIBRARY}" +: "${PS4_LIBRARY:?set PS4_LIBRARY}" +: "${PS5_LIBRARY:?set PS5_LIBRARY}" +: "${PUID:?set the numeric read-capable application UID}" +: "${PGID:?set the numeric read-capable application GID}" + +for root in "$GAMES_LIBRARY" "$PS4_LIBRARY" "$PS5_LIBRARY"; do + [ -d "$root" ] || { echo "library root unavailable: $root" >&2; exit 3; } +done +command -v docker >/dev/null || { echo "docker is required" >&2; exit 3; } + +ensure_image() { + target_image="$1" + docker image inspect "$target_image" >/dev/null 2>&1 && return + attempt=1 + while [ "$attempt" -le 3 ]; do + if command -v timeout >/dev/null; then timeout 420 docker pull "$target_image" && return + else docker pull "$target_image" && return + fi + attempt=$((attempt + 1)) + sleep $((attempt * 5)) + done + echo "bounded image download failed after 3 attempts: $target_image" >&2 + exit 3 +} +[ "$browser_gate_mode" = "external" ] || ensure_image "$playwright_image" + +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT INT TERM +before="$work/source-before.txt" +after="$work/source-after.txt" +source_archive="$work/source.tar" +git -C "$repo" archive --format=tar --output="$source_archive" HEAD +sh "$repo/deploy/verify-source-manifest.sh" "$GAMES_LIBRARY" "$PS4_LIBRARY" "$PS5_LIBRARY" > "$before" +cat "$before" + +if command -v dotnet >/dev/null; then + dotnet restore "$repo/Ludarium.slnx" --locked-mode + dotnet format "$repo/Ludarium.slnx" --no-restore --verify-no-changes + dotnet build "$repo/Ludarium.slnx" -c Release --no-restore -m:"$build_nodes" -p:UseSharedCompilation=false + LUDARIUM_RUN_CONTAINER_TESTS=1 dotnet test "$repo/Ludarium.slnx" -c Release --no-build --no-restore -m:"$build_nodes" +else + docker run --rm --network host --cpus "$gate_cpus" --memory "$gate_memory" \ + --memory-swap "$gate_memory" --pids-limit 1024 --tmpfs /work:rw,nosuid,nodev,size=2g \ + -v /var/run/docker.sock:/var/run/docker.sock -v "$source_archive:/source.tar:ro" -w /work \ + -e LUDARIUM_RUN_CONTAINER_TESTS=1 -e LUDARIUM_GATE_BUILD_NODES="$build_nodes" \ + "$dotnet_image" sh -lc \ + 'tar -xf /source.tar -C /work && dotnet restore Ludarium.slnx --locked-mode && dotnet format Ludarium.slnx --no-restore --verify-no-changes && dotnet build Ludarium.slnx -c Release --no-restore -m:"$LUDARIUM_GATE_BUILD_NODES" -p:UseSharedCompilation=false && dotnet test Ludarium.slnx -c Release --no-build --no-restore -m:"$LUDARIUM_GATE_BUILD_NODES"' +fi + +# The emulator sidecar controllers are the last defence before a process launch inside an +# isolated player. They are stdlib-only, so this gate needs no dependency installation. +if command -v python3 >/dev/null; then + ( cd "$repo" && python3 -m unittest discover -s tests/controllers -t tests/controllers ) +else + docker run --rm --network none --cpus "$gate_cpus" --memory 512m \ + -v "$repo:/repo:ro" -w /repo python:3.13-alpine@sha256:540c7d91f98ff6880174c40e99067bf5941eb54d818a7a5e094d188b196a934d \ + python -m unittest discover -s tests/controllers -t tests/controllers +fi + +mkdir -p "$data_root/app" "$data_root/cache" "$data_root/exports" "$data_root/postgres" "$data_root/backups" +chown -R "$PUID:$PGID" "$data_root/app" "$data_root/cache" "$data_root/exports" +export LUDARIUM_DATA_ROOT="$data_root" LUDARIUM_HTTP_PORT="$candidate_port" LUDARIUM_IMAGE="$image" +export LUDARIUM_CONTAINER_NAME="${LUDARIUM_CANDIDATE_CONTAINER_NAME:-${project}-Ludarium}" + +docker compose -p "$project" -f "$compose" build --pull +image_digest="$(docker image inspect "$image" --format '{{.Id}}')" +docker compose -p "$project" -f "$compose" up -d --wait + +app_id="$(docker compose -p "$project" -f "$compose" ps -q ludarium)" +[ -n "$app_id" ] || { echo "candidate app container was not created" >&2; exit 4; } +initial_restart_count="$(docker inspect "$app_id" --format '{{.RestartCount}}')" +[ "$initial_restart_count" = "0" ] || { echo "candidate started with an unexpected restart count: $initial_restart_count" >&2; exit 4; } +docker inspect "$app_id" --format '{{range .Config.Env}}{{println .}}{{end}}' | + grep -Fq 'GSS Encryption Mode=Disable' || { + echo "candidate database connection must disable unused local GSS negotiation" >&2 + exit 4 + } +for destination in /library/games /library/ps4 /library/ps5; do + docker inspect "$app_id" --format '{{range .Mounts}}{{println .Destination .RW}}{{end}}' | grep -F "$destination false" >/dev/null || { + echo "read-only mount proof failed for $destination" >&2; exit 5; + } +done + +wget -qO- "http://127.0.0.1:$candidate_port/health/ready" >/dev/null +schema="$(docker compose -p "$project" -f "$compose" exec -T ludarium psql -U ludarium -d ludarium -Atc 'select max(version) from schema_versions')" +expected_schema="$(sed -n 's/.*CurrentSchemaVersion = \([0-9][0-9]*\).*/\1/p' "$repo/src/Ludarium.Infrastructure/PostgresStore.cs")" +case "$expected_schema" in ''|*[!0-9]*) echo "could not read the declared schema version" >&2; exit 6;; esac +[ "$schema" = "$expected_schema" ] || { echo "expected schema $expected_schema, found $schema" >&2; exit 6; } +server_encoding="$(docker compose -p "$project" -f "$compose" exec -T ludarium psql -U ludarium -d ludarium -Atc 'show server_encoding')" +[ "$server_encoding" = "UTF8" ] || { echo "expected UTF8 database encoding, found $server_encoding" >&2; exit 6; } +docker compose -p "$project" -f "$compose" exec -T ludarium locale -a | grep -Fx 'en_US.utf8' >/dev/null || { + echo "candidate runtime must provide the legacy production locale en_US.utf8" >&2 + exit 6 +} +for locale_setting in lc_messages lc_monetary lc_numeric lc_time; do + docker compose -p "$project" -f "$compose" exec -T ludarium \ + psql -v ON_ERROR_STOP=1 -U ludarium -d ludarium \ + -c "ALTER SYSTEM SET $locale_setting TO 'en_US.utf8'" >/dev/null +done +reload_result="$(docker compose -p "$project" -f "$compose" exec -T ludarium psql -U ludarium -d ludarium -Atc 'select pg_reload_conf()')" +[ "$reload_result" = "t" ] || { echo "failed to load the production-shaped locale profile" >&2; exit 6; } + +collation_fixture="ludarium_collation_fixture" +docker compose -p "$project" -f "$compose" exec -T ludarium dropdb -U ludarium --if-exists "$collation_fixture" +docker compose -p "$project" -f "$compose" exec -T ludarium \ + createdb -U ludarium --template=template0 --locale=en_US.utf8 "$collation_fixture" +docker compose -p "$project" -f "$compose" exec -T ludarium psql -v ON_ERROR_STOP=1 -U ludarium -d "$collation_fixture" -c " + create table platform_definitions(id text,custom boolean not null,enabled boolean not null,version bigint not null,data jsonb not null,updated_at timestamptz not null); + insert into platform_definitions values + ('game-gear',false,true,1,'{\"id\":\"game-gear\",\"custom\":false,\"enabled\":true,\"updatedAt\":\"2026-08-10T00:00:00Z\"}',timestamptz '2026-08-10T00:00:00Z'), + ('game-gear',false,true,1,'{\"id\":\"game-gear\",\"custom\":false,\"enabled\":true,\"updatedAt\":\"2026-08-21T00:00:00Z\"}',timestamptz '2026-08-21T00:00:00Z'); + create table indexed_fixture(id text primary key); + insert into indexed_fixture values('alpha'),('beta');" >/dev/null +fixture_index_before="$(docker compose -p "$project" -f "$compose" exec -T ludarium psql -U ludarium -d "$collation_fixture" -Atc \ + "select relfilenode from pg_class where oid='indexed_fixture_pkey'::regclass")" +docker compose -p "$project" -f "$compose" exec -T ludarium psql -v ON_ERROR_STOP=1 -U ludarium -d postgres -c \ + "update pg_database set datcollversion=null where datname='$collation_fixture'" >/dev/null +docker compose -p "$project" -f "$compose" exec -T -e POSTGRES_DB="$collation_fixture" ludarium \ + /usr/local/bin/repair-postgres-collation.sh +fixture_rows="$(docker compose -p "$project" -f "$compose" exec -T ludarium psql -U ludarium -d "$collation_fixture" -Atc \ + "select count(*) from platform_definitions where id='game-gear'")" +fixture_index_after="$(docker compose -p "$project" -f "$compose" exec -T ludarium psql -U ludarium -d "$collation_fixture" -Atc \ + "select relfilenode from pg_class where oid='indexed_fixture_pkey'::regclass")" +fixture_version_current="$(docker compose -p "$project" -f "$compose" exec -T ludarium psql -U ludarium -d "$collation_fixture" -Atc \ + "select datcollversion=pg_database_collation_actual_version(oid) from pg_database where datname=current_database()")" +docker compose -p "$project" -f "$compose" exec -T ludarium dropdb -U ludarium "$collation_fixture" +[ "$fixture_rows" = "1" ] || { echo "legacy collation fixture did not safely deduplicate built-in rows" >&2; exit 6; } +[ "$fixture_index_before" != "$fixture_index_after" ] || { echo "legacy collation fixture index was not rebuilt" >&2; exit 6; } +[ "$fixture_version_current" = "t" ] || { echo "legacy collation fixture version was not refreshed" >&2; exit 6; } + +api="http://127.0.0.1:$candidate_port/api/v1" +auth="Authorization: Bearer $LUDARIUM_ADMIN_TOKEN" +libraries="$(curl -fsS -H "$auth" "$api/libraries")" +if [ "$(printf '%s' "$libraries" | jq 'length')" = "0" ]; then + create_library() { + curl -fsS -H "$auth" -H 'Content-Type: application/json' -d "$2" "$api/libraries" | jq -r '.id' + } + games_id="$(create_library games '{"name":"Games","path":"/library/games","kind":"Mixed","recursive":true,"hashPolicy":"OnDemand","inspectArchives":true,"maxConcurrency":1}')" + ps4_id="$(create_library ps4 '{"name":"PS4 Games","path":"/library/ps4","kind":"DiscImage","recursive":true,"hashPolicy":"OnDemand","inspectArchives":false,"maxConcurrency":1}')" + ps5_id="$(create_library ps5 '{"name":"PS5 Games","path":"/library/ps5","kind":"DiscImage","recursive":true,"hashPolicy":"OnDemand","inspectArchives":false,"maxConcurrency":1}')" + libraries="$(curl -fsS -H "$auth" "$api/libraries")" +fi +scan_ids="" +for library_id in $(printf '%s' "$libraries" | jq -r '.[].id'); do + curl -fsS -X POST -H "$auth" "$api/libraries/$library_id/verify" >/dev/null + scan_id="$(curl -fsS -X POST -H "$auth" -H 'Content-Type: application/json' -d '{"mode":"Quick"}' "$api/libraries/$library_id/scans" | jq -r '.id')" + scan_ids="$scan_ids $scan_id" +done +deadline=$(( $(date +%s) + 1800 )) +while :; do + all_done=true + for scan_id in $scan_ids; do + state="$(curl -fsS -H "$auth" "$api/scans/$scan_id" | jq -r '.state')" + [ "$state" != "Failed" ] || { echo "candidate bootstrap scan failed: $scan_id" >&2; exit 6; } + [ "$state" = "Completed" ] || [ "$state" = "Cancelled" ] || all_done=false + done + [ "$all_done" = true ] && break + [ "$(date +%s)" -lt "$deadline" ] || { echo "candidate bootstrap scans timed out" >&2; exit 6; } + sleep 5 +done + +if [ "$browser_gate_mode" = "docker" ]; then + docker run --rm --network host --cpus "$browser_gate_cpus" --memory "$browser_gate_memory" \ + --memory-swap "$browser_gate_memory" --pids-limit 1024 \ + --tmpfs /work:rw,exec,nosuid,nodev,size=768m \ + -e PLAYWRIGHT_BASE_URL="http://127.0.0.1:$candidate_port" \ + -e LUDARIUM_ADMIN_TOKEN \ + -v "$source_archive:/source.tar:ro" -w /work "$playwright_image" \ + sh -lc 'tar -xf /source.tar -C /work --strip-components=2 src/Ludarium.Web && (npm ci --ignore-scripts || (sleep 5 && npm ci --ignore-scripts) || (sleep 15 && npm ci --ignore-scripts)) && node node_modules/vitest/vitest.mjs run --maxWorkers=4 && node node_modules/typescript/bin/tsc --noEmit && node node_modules/vite/bin/vite.js build && npm audit --audit-level=high && node e2e/workflows.mjs && node e2e/game-data-vault.mjs && node e2e/accessibility.mjs' +else + printf 'browser gate delegated: run the checked-in Playwright workflows against http://HOST:%s before promotion\n' "$candidate_port" +fi + +post_browser_restart_count="$(docker inspect "$app_id" --format '{{.RestartCount}}')" +[ "$post_browser_restart_count" = "$initial_restart_count" ] || { + echo "candidate restarted unexpectedly during browser gates: $initial_restart_count -> $post_browser_restart_count" >&2 + exit 6 +} + +docker compose -p "$project" -f "$compose" restart ludarium +docker compose -p "$project" -f "$compose" up -d --wait +wget -qO- "http://127.0.0.1:$candidate_port/health/ready" >/dev/null +server_encoding="$(docker compose -p "$project" -f "$compose" exec -T ludarium psql -U ludarium -d ludarium -Atc 'show server_encoding')" +[ "$server_encoding" = "UTF8" ] || { echo "expected UTF8 database encoding after restart, found $server_encoding" >&2; exit 6; } +for locale_setting in lc_messages lc_monetary lc_numeric lc_time; do + configured_locale="$(docker compose -p "$project" -f "$compose" exec -T ludarium psql -U ludarium -d ludarium -Atc "show $locale_setting")" + [ "$configured_locale" = "en_US.utf8" ] || { + echo "production-shaped locale $locale_setting did not survive restart: $configured_locale" >&2 + exit 6 + } +done +post_controlled_restart_count="$(docker inspect "$app_id" --format '{{.RestartCount}}')" +[ "$post_controlled_restart_count" = "$initial_restart_count" ] || { + echo "candidate restart count changed after controlled restart: $initial_restart_count -> $post_controlled_restart_count" >&2 + exit 6 +} + +backup="$data_root/backups/candidate-schema${expected_schema}.dump" +docker compose -p "$project" -f "$compose" exec -T ludarium pg_dump -U ludarium -Fc ludarium > "$backup" +docker compose -p "$project" -f "$compose" exec -T ludarium createdb -U ludarium ludarium_restore +docker compose -p "$project" -f "$compose" exec -T ludarium pg_restore -U ludarium -d ludarium_restore --clean --if-exists < "$backup" +restored_schema="$(docker compose -p "$project" -f "$compose" exec -T ludarium psql -U ludarium -d ludarium_restore -Atc 'select max(version) from schema_versions')" +docker compose -p "$project" -f "$compose" exec -T ludarium dropdb -U ludarium ludarium_restore +[ "$restored_schema" = "$expected_schema" ] || { + echo "restore schema verification failed: expected $expected_schema, found $restored_schema" >&2 + exit 7 +} + +sh "$repo/deploy/verify-source-manifest.sh" "$GAMES_LIBRARY" "$PS4_LIBRARY" "$PS5_LIBRARY" > "$after" +cat "$after" +cmp -s "$before" "$after" || { echo "source manifest changed" >&2; exit 8; } + +LUDARIUM_IMAGE="$image" LUDARIUM_RELEASE_VERSION="$release_version" LUDARIUM_RELEASE_TOOL_CACHE="$data_root/release-tools" sh "$repo/deploy/run-security-gates.sh" +if [ "$browser_gate_mode" = "docker" ]; then gate_state=passed; else gate_state=infrastructure-passed-browser-external-required; fi +printf 'candidate gate %s image=%s digest=%s port=%s schema=%s encoding=%s backup=%s\n' "$gate_state" "$image" "$image_digest" "$candidate_port" "$schema" "$server_encoding" "$backup" diff --git a/deploy/run-eden-security-gate.sh b/deploy/run-eden-security-gate.sh new file mode 100644 index 0000000..c11fac6 --- /dev/null +++ b/deploy/run-eden-security-gate.sh @@ -0,0 +1,93 @@ +#!/bin/sh +set -eu + +repo="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)" +image="${1:-${SWITCH_IMAGE:-ludarium/eden-controller:0.4.9-rc.1}}" +container="${2:-${SWITCH_CONTAINER_NAME:-Ludarium-Switch}}" +evidence="${3:?set an evidence directory below /mnt/user/appdata/ludarium-candidate or /tmp}" +vex="$repo/deploy/security/eden-controller-0.4.9.openvex.json" +cache="${LUDARIUM_RELEASE_TOOL_CACHE:-/mnt/user/appdata/ludarium-candidate/release-tools}" + +case "$evidence" in + /mnt/user/appdata/ludarium-candidate/*|/tmp/*) ;; + *) echo "Eden evidence must remain below /mnt/user/appdata/ludarium-candidate or /tmp" >&2; exit 2 ;; +esac + +command -v docker >/dev/null || { echo "docker is required" >&2; exit 3; } +command -v jq >/dev/null || { echo "jq is required" >&2; exit 3; } +[ -f "$vex" ] || { echo "version-bound Eden OpenVEX document is missing" >&2; exit 3; } +mkdir -p "$evidence" "$cache/bin" "$cache/downloads" "$cache/grype-db" + +grype="$cache/bin/grype-0.116.1" +if [ ! -x "$grype" ]; then + archive="$cache/downloads/grype_0.116.1_linux_amd64.tar.gz" + checksum="0122df7b655981abe547ad3d2190d65551dac6a2bfc80b4dc2a989b5d0587458" + if [ ! -f "$archive" ] || ! printf '%s %s\n' "$checksum" "$archive" | sha256sum -c - >/dev/null 2>&1; then + command -v curl >/dev/null || { echo "curl is required to acquire the pinned scanner" >&2; exit 3; } + rm -f "$archive.part" + curl --fail --location --silent --show-error --retry 5 --retry-all-errors \ + --connect-timeout 15 --max-time 300 \ + https://github.com/anchore/grype/releases/download/v0.116.1/grype_0.116.1_linux_amd64.tar.gz \ + -o "$archive.part" + printf '%s %s\n' "$checksum" "$archive.part" | sha256sum -c - >/dev/null + mv "$archive.part" "$archive" + fi + work="$(mktemp -d)" + trap 'rm -rf "$work"' EXIT INT TERM + tar -xzf "$archive" -C "$work" grype + install -m 0755 "$work/grype" "$grype" +fi + +container_image="$(docker inspect "$container" --format '{{.Image}}')" +expected_image="$(docker image inspect "$image" --format '{{.Id}}')" +[ "$container_image" = "$expected_image" ] || { + echo "running Eden container does not use the exact requested image" >&2 + exit 4 +} +[ "$(docker inspect "$container" --format '{{.State.Health.Status}}')" = "healthy" ] || { + echo "Eden container is not healthy" >&2 + exit 4 +} +[ "$(docker inspect "$container" --format '{{.RestartCount}}')" = "0" ] || { + echo "Eden candidate has restarted" >&2 + exit 4 +} +docker inspect "$container" --format '{{range .Mounts}}{{println .Destination .RW}}{{end}}' | + grep -F '/games false' >/dev/null || { echo "Eden /games mount is not read-only" >&2; exit 4; } + +docker exec "$container" /opt/ludarium/audit-eden-runtime.sh | tee "$evidence/runtime-audit.txt" +cp "$vex" "$evidence/openvex.json" + +run_grype() { + if command -v timeout >/dev/null; then + timeout 600 env GRYPE_DB_CACHE_DIR="$cache/grype-db" "$@" + else + env GRYPE_DB_CACHE_DIR="$cache/grype-db" "$@" + fi +} +run_grype "$grype" db update +run_grype "$grype" "docker:$image" -o json --file "$evidence/grype-raw.json" +run_grype "$grype" "docker:$image" --vex "$vex" --fail-on critical \ + -o json --file "$evidence/grype-vex.json" + +raw_critical="$(jq '[.matches[] | select(.vulnerability.severity == "Critical")] | length' "$evidence/grype-raw.json")" +active_critical="$(jq '[.matches[] | select(.vulnerability.severity == "Critical")] | length' "$evidence/grype-vex.json")" +ignored_critical="$(jq '[.ignoredMatches[] | select(.vulnerability.severity == "Critical")] | length' "$evidence/grype-vex.json")" +[ "$raw_critical" -gt 0 ] || { echo "expected raw Critical evidence is absent; review the VEX baseline" >&2; exit 5; } +[ "$active_critical" = "0" ] || { echo "unresolved Critical Eden findings remain" >&2; exit 5; } +[ "$ignored_critical" = "$raw_critical" ] || { echo "VEX does not account for every raw Critical match" >&2; exit 5; } + +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT INT TERM +jq -r '.matches[] | select(.vulnerability.severity == "Critical") | .vulnerability.id' \ + "$evidence/grype-raw.json" | sort -u > "$work/raw-ids" +jq -r '.ignoredMatches[] | select(.vulnerability.severity == "Critical") | .vulnerability.id' \ + "$evidence/grype-vex.json" | sort -u > "$work/vex-ids" +cmp -s "$work/raw-ids" "$work/vex-ids" || { echo "VEX Critical ID set differs from raw evidence" >&2; exit 5; } + +raw_high="$(jq '[.matches[] | select(.vulnerability.severity == "High")] | length' "$evidence/grype-raw.json")" +unique_critical="$(wc -l < "$work/raw-ids" | tr -d ' ')" +sha256sum "$evidence/runtime-audit.txt" "$evidence/openvex.json" \ + "$evidence/grype-raw.json" "$evidence/grype-vex.json" > "$evidence/SHA256SUMS" +printf 'Eden security gate passed image=%s digest=%s raw_critical=%s unique_critical=%s active_critical=0 raw_high=%s\n' \ + "$image" "$expected_image" "$raw_critical" "$unique_critical" "$raw_high" diff --git a/deploy/run-managed-validation.sh b/deploy/run-managed-validation.sh new file mode 100644 index 0000000..5c54fd9 --- /dev/null +++ b/deploy/run-managed-validation.sh @@ -0,0 +1,150 @@ +#!/bin/sh +set -eu + +repo="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)" +profile="${1:-full}" +case "$profile" in + source|test|lint|typecheck|build|security|full) ;; + *) echo "validation profile is not allowlisted: $profile" >&2; exit 2 ;; +esac + +cd "$repo" +git_worktree=false +if [ -n "${WSL_INTEROP:-}" ] && command -v git.exe >/dev/null 2>&1; then + dotnet_command=dotnet.exe + python_command=python.exe + docker_command=docker.exe + default_release_cache="/var/tmp/ludarium-release-tools" + npm_run() { cmd.exe /d /c npm "$@"; } + repo_windows="$(wslpath -w "$repo")" + if git.exe -C "$repo_windows" rev-parse --is-inside-work-tree >/dev/null 2>&1; then + git.exe -C "$repo_windows" diff --check + if git.exe -C "$repo_windows" grep -nE '^(<<<<<<< |=======$|>>>>>>> )' -- . ':!*.lock' ':!*.patch'; then + echo "unresolved merge markers detected" >&2 + exit 1 + fi + git_worktree=true + fi +else + dotnet_command=dotnet + if command -v python3 >/dev/null 2>&1; then python_command=python3; else python_command=python; fi + docker_command=docker + default_release_cache="${RUNNER_TEMP:-/tmp}/ludarium-release-tools" + npm_run() { npm "$@"; } + if git -C "$repo" rev-parse --is-inside-work-tree >/dev/null 2>&1; then + git -C "$repo" diff --check + if git -C "$repo" grep -nE '^(<<<<<<< |=======$|>>>>>>> )' -- . ':!*.lock' ':!*.patch'; then + echo "unresolved merge markers detected" >&2 + exit 1 + fi + git_worktree=true + fi +fi +if [ "$git_worktree" = false ]; then + if grep -RInI -E '^(<<<<<<< |=======$|>>>>>>> )' \ + --exclude='*.lock' --exclude='*.patch' .; then + echo "unresolved merge markers detected" >&2 + exit 1 + fi +fi +sh deploy/verify-release-identity.sh + +case "$profile" in + source|test|lint|build|full) + "$dotnet_command" restore Ludarium.slnx --locked-mode + ;; +esac +case "$profile" in + source|lint|full) + "$dotnet_command" format Ludarium.slnx --verify-no-changes --no-restore + ;; +esac +case "$profile" in + source|build|full) + "$dotnet_command" build Ludarium.slnx --configuration Release --no-restore + ;; +esac +case "$profile" in + test) + "$dotnet_command" test Ludarium.slnx --configuration Release --no-restore + ;; + source|full) + "$dotnet_command" test Ludarium.slnx --configuration Release --no-build --no-restore + ;; +esac + +case "$profile" in + source|test|typecheck|build|security|full) + npm_run ci --ignore-scripts --prefix src/Ludarium.Web + ;; +esac +case "$profile" in + source|test|full) + npm_run run test --prefix src/Ludarium.Web + ;; +esac +case "$profile" in + typecheck) + npm_run run typecheck --prefix src/Ludarium.Web + ;; + source|build|full) + npm_run run build --prefix src/Ludarium.Web + ;; +esac +case "$profile" in + source|security|full) + npm_run audit --audit-level=high --prefix src/Ludarium.Web + ;; +esac + +case "$profile" in + source|test|full) + "$python_command" -m unittest discover -s tests/controllers -t tests/controllers + ;; +esac + +case "$profile" in + security|full) + release="$(tr -d '\r\n' < VERSION)" + image="${LUDARIUM_IMAGE:-ludarium/ludarium:validation-${GITHUB_SHA:-$release}}" + build_network="${LUDARIUM_DOCKER_BUILD_NETWORK:-default}" + emulatorjs_asset="" + cleanup_build_inputs() { + [ -z "$emulatorjs_asset" ] || rm -f "$emulatorjs_asset" "${emulatorjs_asset}.part" + } + trap cleanup_build_inputs EXIT HUP INT TERM + case "$build_network" in + default) "$docker_command" build --pull --file Dockerfile.unraid --tag "$image" . ;; + host) + emulatorjs_version=4.2.3 + emulatorjs_sha256=07d451bc06fa3ad04ab30d9b94eb63ac34ad0babee52d60357b002bde8f3850b + emulatorjs_asset="$repo/.build-inputs/emulatorjs/$emulatorjs_version.7z" + mkdir -p "$(dirname "$emulatorjs_asset")" + if [ ! -f "$emulatorjs_asset" ] || ! printf '%s %s\n' "$emulatorjs_sha256" "$emulatorjs_asset" | sha256sum -c - >/dev/null 2>&1; then + rm -f "$emulatorjs_asset" "${emulatorjs_asset}.part" + download_attempt=1 + while ! "$docker_command" run --rm --dns 1.1.1.1 --dns 8.8.8.8 alpine:3.23@sha256:fd791d74b68913cbb027c6546007b3f0d3bc45125f797758156952bc2d6daf40 \ + wget -q -T 60 "https://github.com/EmulatorJS/EmulatorJS/releases/download/v$emulatorjs_version/$emulatorjs_version.7z" -O - \ + > "${emulatorjs_asset}.part"; do + rm -f "${emulatorjs_asset}.part" + [ "$download_attempt" -lt 3 ] || exit 1 + sleep "$download_attempt" + download_attempt=$((download_attempt + 1)) + done + printf '%s %s\n' "$emulatorjs_sha256" "${emulatorjs_asset}.part" | sha256sum -c - >/dev/null + mv "${emulatorjs_asset}.part" "$emulatorjs_asset" + fi + "$docker_command" build --network host --pull --file Dockerfile.unraid --tag "$image" . + ;; + *) echo "docker build network is not allowlisted: $build_network" >&2; exit 2 ;; + esac + cleanup_build_inputs + trap - EXIT HUP INT TERM + LUDARIUM_IMAGE="$image" \ + LUDARIUM_RELEASE_VERSION="${GITHUB_SHA:-$release}" \ + LUDARIUM_RELEASE_TOOL_CACHE="${LUDARIUM_RELEASE_TOOL_CACHE:-$default_release_cache}" \ + sh deploy/run-security-gates.sh + ;; +esac + +printf 'managed validation passed: %s\n' "$profile" diff --git a/deploy/run-security-gates.sh b/deploy/run-security-gates.sh new file mode 100644 index 0000000..f8a58d1 --- /dev/null +++ b/deploy/run-security-gates.sh @@ -0,0 +1,68 @@ +#!/bin/sh +set -eu + +repo="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)" +output="$repo/deploy/security" +mkdir -p "$output" + +application_image="${1:-${LUDARIUM_IMAGE:-ludarium/ludarium:0.4.1}}" +release_version="${LUDARIUM_RELEASE_VERSION:-${application_image##*:}}" +cache="${LUDARIUM_RELEASE_TOOL_CACHE:-/mnt/user/appdata/ludarium/release-tools}" +mkdir -p "$cache/bin" "$cache/downloads" "$cache/grype-db" "$cache/tmp" +TMPDIR="$cache/tmp" +export TMPDIR + +scan_source="docker:$application_image" +image_archive="" +if [ -n "${WSL_INTEROP:-}" ] \ + && command -v docker.exe >/dev/null 2>&1 \ + && docker.exe image inspect "$application_image" >/dev/null 2>&1 \ + && ! docker image inspect "$application_image" >/dev/null 2>&1; then + image_archive="$(mktemp "$cache/tmp/ludarium-image.XXXXXX.tar")" + trap 'rm -f "$image_archive"' EXIT HUP INT TERM + docker.exe save --output "$(wslpath -w "$image_archive")" "$application_image" + scan_source="docker-archive:$image_archive" +fi + +download_tool() { + name="$1" version="$2" archive="$3" checksum="$4" url="$5" + binary="$cache/bin/$name-$version" + [ -x "$binary" ] && { printf '%s\n' "$binary"; return; } + package="$cache/downloads/$archive" + if [ ! -f "$package" ] || ! printf '%s %s\n' "$checksum" "$package" | sha256sum -c - >/dev/null 2>&1; then + rm -f "$package.part" + curl --fail --location --silent --show-error --retry 5 --retry-all-errors \ + --connect-timeout 15 --max-time 300 "$url" -o "$package.part" + printf '%s %s\n' "$checksum" "$package.part" | sha256sum -c - >/dev/null + mv "$package.part" "$package" + fi + work="$(mktemp -d)" + tar -xzf "$package" -C "$work" "$name" + install -m 0755 "$work/$name" "$binary" + rm -rf "$work" + printf '%s\n' "$binary" +} + +gitleaks="$(download_tool gitleaks 8.30.1 gitleaks_8.30.1_linux_x64.tar.gz \ + 551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb \ + https://github.com/gitleaks/gitleaks/releases/download/v8.30.1/gitleaks_8.30.1_linux_x64.tar.gz)" +syft="$(download_tool syft 1.50.0 syft_1.50.0_linux_amd64.tar.gz \ + bf7b29ff57f06da30918266a0e1c2885a8f99784798d1bdb1628886aa015d788 \ + https://github.com/anchore/syft/releases/download/v1.50.0/syft_1.50.0_linux_amd64.tar.gz)" +grype="$(download_tool grype 0.116.1 grype_0.116.1_linux_amd64.tar.gz \ + 0122df7b655981abe547ad3d2190d65551dac6a2bfc80b4dc2a989b5d0587458 \ + https://github.com/anchore/grype/releases/download/v0.116.1/grype_0.116.1_linux_amd64.tar.gz)" + +# Scan the materialized candidate tree. A linked Git worktree can reference metadata +# outside /repo, which would otherwise make a containerized history scan inspect zero commits. +"$gitleaks" dir "$repo" --config "$repo/.gitleaks.toml" --redact --report-format json --report-path "$output/gitleaks.json" +"$syft" "$scan_source" -o "cyclonedx-json=$output/ludarium-$release_version.cdx.json" +if command -v timeout >/dev/null; then + timeout 600 env GRYPE_DB_CACHE_DIR="$cache/grype-db" "$grype" db update + timeout 600 env GRYPE_DB_CACHE_DIR="$cache/grype-db" "$grype" "$scan_source" -o json --file "$output/grype.json" --fail-on high +else + GRYPE_DB_CACHE_DIR="$cache/grype-db" "$grype" db update + GRYPE_DB_CACHE_DIR="$cache/grype-db" "$grype" "$scan_source" -o json --file "$output/grype.json" --fail-on high +fi + +printf 'security gates passed for %s\n' "$application_image" diff --git a/deploy/unraid/Ludarium-icon.png b/deploy/unraid/Ludarium-icon.png new file mode 100644 index 0000000..10f98d8 Binary files /dev/null and b/deploy/unraid/Ludarium-icon.png differ diff --git a/deploy/unraid/my-Ludarium.xml b/deploy/unraid/my-Ludarium.xml new file mode 100644 index 0000000..49e99cb --- /dev/null +++ b/deploy/unraid/my-Ludarium.xml @@ -0,0 +1,30 @@ +<?xml version="1.0"?> +<Container version="2"> + <Name>Ludarium</Name> + <Repository>ludarium/ludarium:0.4.19-rc.8</Repository> + <Network>ludarium_default</Network> + <Shell/> + <Privileged>false</Privileged> + <Overview>Premium read-only game-library inventory, classification, metadata and integrity management for Unraid.</Overview> + <Category>MediaApp:Other</Category> + <WebUI>http://[IP]:[PORT:1230]/</WebUI> + <Icon>/boot/config/plugins/dockerMan/images/Ludarium-icon.svg</Icon> + <ExtraParams>--read-only --cap-drop=ALL --cap-add=CHOWN --cap-add=DAC_OVERRIDE --cap-add=FOWNER --cap-add=KILL --cap-add=SETGID --cap-add=SETUID --security-opt=no-new-privileges --tmpfs=/tmp --tmpfs=/var/run/postgresql</ExtraParams> + <PostArgs/> + <CPUset/> + <DonateText/> + <DonateLink/> + <Requires/> + <Config Name="WebUI" Target="8734" Default="1230" Mode="tcp" Description="Ludarium web interface" Type="Port" Display="always" Required="true" Mask="false">1230</Config> + <Config Name="RAWG API key" Target="RAWG_API_KEY" Default="" Mode="" Description="Optional online wishlist discovery" Type="Variable" Display="advanced" Required="false" Mask="true"/> + <Config Name="IGDB client ID" Target="IGDB_CLIENT_ID" Default="" Mode="" Description="Optional native metadata provider" Type="Variable" Display="advanced" Required="false" Mask="false"/> + <Config Name="IGDB client secret" Target="IGDB_CLIENT_SECRET" Default="" Mode="" Description="Optional native metadata provider" Type="Variable" Display="advanced" Required="false" Mask="true"/> + <Config Name="MobyGames API key" Target="MOBYGAMES_API_KEY" Default="" Mode="" Description="Optional native metadata provider" Type="Variable" Display="advanced" Required="false" Mask="true"/> + <Config Name="ScreenScraper developer ID" Target="SCREENSCRAPER_DEVID" Default="" Mode="" Description="Optional native metadata provider" Type="Variable" Display="advanced" Required="false" Mask="true"/> + <Config Name="ScreenScraper developer password" Target="SCREENSCRAPER_DEVPASSWORD" Default="" Mode="" Description="Optional native metadata provider" Type="Variable" Display="advanced" Required="false" Mask="true"/> + <Config Name="ScreenScraper software name" Target="SCREENSCRAPER_SOFTNAME" Default="Ludarium" Mode="" Description="Optional native metadata provider client name" Type="Variable" Display="advanced" Required="false" Mask="false"/> + <Config Name="ScreenScraper user" Target="SCREENSCRAPER_USER" Default="" Mode="" Description="Optional personal ScreenScraper account" Type="Variable" Display="advanced" Required="false" Mask="false"/> + <Config Name="ScreenScraper password" Target="SCREENSCRAPER_PASSWORD" Default="" Mode="" Description="Optional personal ScreenScraper account" Type="Variable" Display="advanced" Required="false" Mask="true"/> + <Config Name="RetroAchievements API key" Target="RETROACHIEVEMENTS_API_KEY" Default="" Mode="" Description="Optional native achievements provider" Type="Variable" Display="advanced" Required="false" Mask="true"/> + <Config Name="RetroAchievements user" Target="RETROACHIEVEMENTS_USERNAME" Default="" Mode="" Description="Optional personal achievement progress" Type="Variable" Display="advanced" Required="false" Mask="false"/> +</Container> diff --git a/deploy/verify-release-identity.sh b/deploy/verify-release-identity.sh new file mode 100644 index 0000000..0fe9318 --- /dev/null +++ b/deploy/verify-release-identity.sh @@ -0,0 +1,52 @@ +#!/bin/sh +set -eu + +repo="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)" +version="$(tr -d '\r\n' < "$repo/VERSION")" + +printf '%s' "$version" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$' || { + echo "VERSION is not a semantic version" >&2 + exit 1 +} + +require_literal() { + file="$1" + grep -F "$version" "$repo/$file" >/dev/null || { + echo "$file does not reference release $version" >&2 + exit 1 + } +} + +for file in \ + CHANGELOG.md \ + deploy/README.md \ + deploy/compose.dolphin.yml \ + deploy/compose.yml \ + deploy/ludarium-unraid.xml \ + deploy/unraid/my-Ludarium.xml +do + require_literal "$file" +done + +grep -F 'ReadAllText('"'"'$(MSBuildThisFileDirectory)VERSION'"'"')' "$repo/Directory.Build.props" >/dev/null || { + echo "Directory.Build.props does not derive the assembly version from VERSION" >&2 + exit 1 +} +for dockerfile in Dockerfile Dockerfile.unraid +do + grep -E '^COPY .*VERSION.*Directory.Build.props|^COPY .*Directory.Build.props.*VERSION' "$repo/$dockerfile" >/dev/null || { + echo "$dockerfile does not copy VERSION into the build context" >&2 + exit 1 + } +done +grep -F 'ReleaseIdentity.Version' "$repo/src/Ludarium.Api/Program.cs" >/dev/null +grep -F 'release_identity="$(tr' "$repo/deploy/run-candidate-gate.sh" >/dev/null + +if grep -R -n -E --include='*.cs' '0\.4\.[0-9]+-rc\.[0-9]+' \ + "$repo/src/Ludarium.Api" "$repo/src/Ludarium.Application" "$repo/src/Ludarium.Infrastructure" | + grep -v -F "$version"; then + echo "A runtime release identifier bypasses ReleaseIdentity" >&2 + exit 1 +fi + +printf 'release identity verified: %s\n' "$version" diff --git a/deploy/verify-source-manifest.sh b/deploy/verify-source-manifest.sh new file mode 100644 index 0000000..5fa653e --- /dev/null +++ b/deploy/verify-source-manifest.sh @@ -0,0 +1,26 @@ +#!/bin/sh +set -eu + +if [ "$#" -lt 1 ]; then + echo "usage: $0 ROOT [ROOT ...]" >&2 + exit 2 +fi + +manifest_file="$(mktemp)" +trap 'rm -f "$manifest_file"' EXIT INT TERM + +root_index=0 +file_count=0 +for root in "$@"; do + if [ ! -d "$root" ]; then + echo "library root is unavailable: $root" >&2 + exit 3 + fi + root_index=$((root_index + 1)) + count="$(find "$root" -type f -printf . | wc -c)" + file_count=$((file_count + count)) + (cd "$root" && find . -type f -printf "${root_index}\t%P\t%s\t%T@\0") >> "$manifest_file" +done + +digest="$(LC_ALL=C sort -z "$manifest_file" | sha256sum | cut -d ' ' -f 1)" +printf 'sha256=%s files=%s roots=%s\n' "$digest" "$file_count" "$#" diff --git a/docs/PUBLIC_SOURCE_BOUNDARY.md b/docs/PUBLIC_SOURCE_BOUNDARY.md new file mode 100644 index 0000000..cf5c443 --- /dev/null +++ b/docs/PUBLIC_SOURCE_BOUNDARY.md @@ -0,0 +1,31 @@ +# Public source boundary + +The canonical Ludarium repository is an operational development record. It contains private release +evidence, infrastructure topology, historical scan reports and screenshots made against an owned game +library. Its Git history must therefore remain private even when the application source is published. + +Public releases are created as a new, parentless repository from `public-source.allowlist`. The export +contains the application, tests, safe deployment examples, required asset provenance and one synthetic +empty-library screenshot. It excludes production workflows, live validation evidence, security scan +artifacts, SBOM archives, internal plans, agent instructions, operational status records and media made +from commercial games or firmware. + +Run the exporter only from a clean, reviewed revision after a root `LICENSE` has been committed: + +```sh +node scripts/export-public-source.mjs ../ludarium-public +``` + +The exporter refuses a dirty tree, a missing license, private infrastructure markers, proprietary game +or key file extensions outside the small documented synthetic fixture set, oversized files, and any +tracked file not explicitly allowlisted. It writes a SHA-256 manifest. In the exported directory, run: + +```sh +node scripts/validate-public-source.mjs +sh deploy/run-managed-validation.sh source +gitleaks git . +``` + +Initialize and publish that directory as a new repository; never push the canonical branch, tags or +history into the public remote. Changes flow from the private canonical repository through another +reviewed export, not by merging the two histories. diff --git a/docs/screenshots/living-archive-audit/audit-home-desktop.png b/docs/screenshots/living-archive-audit/audit-home-desktop.png new file mode 100644 index 0000000..5d0bb4d Binary files /dev/null and b/docs/screenshots/living-archive-audit/audit-home-desktop.png differ diff --git a/fixtures/catalogs/synthetic-logiqx.dat b/fixtures/catalogs/synthetic-logiqx.dat new file mode 100644 index 0000000..8c310a4 --- /dev/null +++ b/fixtures/catalogs/synthetic-logiqx.dat @@ -0,0 +1,8 @@ +<?xml version="1.0"?> +<datafile> + <header><name>Ludarium Synthetic Catalog</name><version>1</version></header> + <game name="Synthetic Example"> + <description>Synthetic Example</description> + <rom name="synthetic.nes" size="16" crc="cbf43926" sha1="a9993e364706816aba3e25717850c26c9cd0d89d" sha256="ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" /> + </game> +</datafile> diff --git a/fixtures/providers/igdb-game.json b/fixtures/providers/igdb-game.json new file mode 100644 index 0000000..17823ca --- /dev/null +++ b/fixtures/providers/igdb-game.json @@ -0,0 +1,16 @@ +[ + { + "id": 1074, + "name": "Sonic the Hedgehog", + "summary": "A blue hedgehog races through six zones.", + "first_release_date": 708393600, + "genres": [{ "name": "Platform" }], + "involved_companies": [ + { "developer": true, "publisher": false, "company": { "name": "Sonic Team" } }, + { "developer": false, "publisher": true, "company": { "name": "Sega" } } + ], + "franchises": [{ "name": "Sonic the Hedgehog" }], + "cover": { "image_id": "co1v7r" }, + "platforms": [{ "slug": "genesis-slash-megadrive", "name": "Sega Mega Drive/Genesis" }] + } +] diff --git a/fixtures/providers/mobygames-game.json b/fixtures/providers/mobygames-game.json new file mode 100644 index 0000000..7822016 --- /dev/null +++ b/fixtures/providers/mobygames-game.json @@ -0,0 +1,12 @@ +{ + "games": [ + { + "game_id": 6225, + "title": "Sonic the Hedgehog", + "description": "Sonic must stop Dr. Robotnik.", + "genres": [{ "genre_name": "Action" }], + "platforms": [{ "platform_name": "Genesis" }], + "sample_cover": "https://cdn.mobygames.com/covers/sonic.jpg" + } + ] +} diff --git a/fixtures/providers/retroachievements-game.json b/fixtures/providers/retroachievements-game.json new file mode 100644 index 0000000..058b0dd --- /dev/null +++ b/fixtures/providers/retroachievements-game.json @@ -0,0 +1,29 @@ +{ + "ID": 1, + "Title": "Sonic the Hedgehog", + "ConsoleName": "Mega Drive", + "ImageBoxArt": "/Images/051872.png", + "Publisher": "Sega", + "Developer": "Sonic Team", + "Genre": "Platform", + "Released": "1991-06-23", + "Achievements": { + "9": { + "ID": 9, + "Title": "That Was Easy", + "Description": "Complete Green Hill Zone Act 1.", + "Points": 3, + "BadgeName": "250336", + "DateEarnedHardcore": "2026-01-02 12:30:00" + }, + "10": { + "ID": 10, + "Title": "Marble Master", + "Description": "Complete Marble Zone.", + "Points": 5, + "BadgeName": "250337" + } + }, + "NumAwardedToUser": 1, + "NumAchievements": 2 +} diff --git a/fixtures/providers/screenscraper-game.xml b/fixtures/providers/screenscraper-game.xml new file mode 100644 index 0000000..c94897b --- /dev/null +++ b/fixtures/providers/screenscraper-game.xml @@ -0,0 +1,2 @@ +<?xml version="1.0" encoding="UTF-8"?> +<Data><jeu id="1"><noms><nom region="us">Sonic the Hedgehog</nom><nom region="jp">Sonic the Hedgehog</nom></noms><synopsis><synopsis langue="en">Sonic crosses South Island.</synopsis></synopsis><genres><genre><noms><nom langue="en">Platform</nom></noms></genre></genres><developpeur>Sonic Team</developpeur><editeur>Sega</editeur><joueurs>2</joueurs><dates><date region="us">19910623</date></dates><medias><media type="box-2D" region="us">https://www.screenscraper.fr/media/sonic.png</media></medias></jeu></Data> diff --git a/fixtures/synthetic/README.md b/fixtures/synthetic/README.md new file mode 100644 index 0000000..2a0d1c8 --- /dev/null +++ b/fixtures/synthetic/README.md @@ -0,0 +1,3 @@ +# Synthetic fixtures + +All files in this tree are generated text or minimal deterministic headers and contain no copyrighted game data. The executable-looking fixture uses `.exe.fixture`, so it cannot be selected as an executable by normal platform behavior. Tests construct binary headers in memory and never launch fixture content. diff --git a/fixtures/synthetic/browserplay/README.md b/fixtures/synthetic/browserplay/README.md new file mode 100644 index 0000000..577d37b --- /dev/null +++ b/fixtures/synthetic/browserplay/README.md @@ -0,0 +1,40 @@ +# Browser-play fixture + +`minimal-ines.base64` decodes to a deterministic 16-byte iNES header with one declared PRG bank and no copyrighted program data. Tests use it only to prove the allowlisted `.nes` format boundary; it is intentionally not a playable commercial game. + +`playable-nrom.nes.gz.base64` is a gzip-compressed, deterministic 24,592-byte +iNES/NROM-128 cartridge created specifically for Ludarium. Its original 6502 +program initializes the PPU, selects a blue backdrop and loops forever; the CHR +bank is empty. It contains no third-party game code, graphics, audio or data. +The decompressed SHA-256 is +`6b87142fb0a7e125b13c5fcd7afa8b13066a44bc97150d24ade8bd2d0e5fd5e7`. +The live browser-play gate materializes it only below isolated candidate +appdata and mounts that directory read-only. + +Browser-play integration tests create the decoded bytes in an isolated temporary read-only fixture root. Executable, script, traversal, oversized and unsupported requests are generated separately and must fail closed. + +`generate-fixtures.mjs` deterministically creates original, minimal fixtures for +SNES, GB, GBC, GBA, N64, NDS and PSP. They contain only headers and an architecture-specific +infinite loop authored for Ludarium; there is no third-party game program, artwork, audio, +firmware or BIOS. A fixture is not sufficient to enable a core by itself: the candidate gate +must still prove a local core canvas, scoped Range delivery and non-empty savestate capture. +The generator refuses output if any pinned SHA-256 changes. The N64 fixture additionally embeds +libdragon's open-source IPL3 compatibility bootcode from commit +`35f85a0797324a5ed0c723203e33ab3c1da94fdd` under the Unlicense. It remains a negative fixture: +the bundled Mupen core did not complete the live gate, so Ludarium continues to fail closed for N64. + +The positive N64 release gate downloads Mupen64Plus' own GPL-licensed 2008 demo ROM from the +historical `mupen64plus-core` commit `57d08111ae9e95938c43ec160ac9b5a50f9da089`. +The compressed SHA-256 is `9dbe0a7b4d2473060a1745c0424abad5278d40f2ff99995a4142bb07286183af`; +the 1,048,576-byte `.v64` SHA-256 is +`b5fe9d650a67091c97838386f5102ad94c79232240f9c5bcc72334097d76224c`. +It is materialized only below isolated candidate appdata and proves N64Wasm WebGL startup, +capability-scoped delivery, state capture, cancellation and evidence-bound restore. + +The PSP fixture is an original open homebrew program in `psp/main.c`, built as `EBOOT.PBP` +with the official `pspdev/pspdev` container pinned to +`sha256:c1dd948b190e9141242cf321316eb2b54562b274c9be78e24a66fe6d30a5dc24`. +`SOURCE_DATE_EPOCH=0` and gzip `-n -9` make the checked-in payload reproducible. The PBP is +130,008 bytes with SHA-256 `2ff952ec933c67ee6911049d77201da572aa7241e3f2ee7ff935ed61646964f4`; +the compressed file SHA-256 is `6e405203a2bab4a7ab3d705e0b937d2ecc8350489f051dbfb58d194e577b02f4`. +It contains no Sony firmware, keys, SDK data or commercial content. diff --git a/fixtures/synthetic/browserplay/generate-fixtures.mjs b/fixtures/synthetic/browserplay/generate-fixtures.mjs new file mode 100644 index 0000000..0d7eb29 --- /dev/null +++ b/fixtures/synthetic/browserplay/generate-fixtures.mjs @@ -0,0 +1,170 @@ +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { gunzipSync } from "node:zlib"; +import { createHash } from "node:crypto"; + +// libdragon open-source IPL3 compatibility bootcode, pinned to commit +// 35f85a0797324a5ed0c723203e33ab3c1da94fdd (Unlicense), SHA-256 +// f522db2e31a701f82597f399e76d55c9487760d015aff9d90176b463ae39a068. +const libdragonIpl3 = gunzipSync(Buffer.from("H4sIAAAAAAACCu1WX2xbVxn/+dxrxw6mvTFu6rUpvW7OTaNuk9xitghd0SvbMdnUh6xKxxAS8ux0LatoBzywSUO9hFKKZJFqE4KnYeWfRuU2gaXAUKpF2Ui0qRUSTxWqRCiI9GkEAVIGXS+/79qhRVCJd2rp+Jx7zvm+832/76//eMoDfz5M/LffoS9WR7/y7LFTJ+27v3vPc6VfP+bumsLjV7au770SrALXndm30DP7Y2Rm55A2u1TVM4d403K8sePwxoY4nuE4D1dN5epPwdQKcwf2Yz21B1/g3gv1ApKWhy5K5bgqlXdjDejC0y+6UdiuCV/FMtlmBWhWgXoNGKniFOlMFfWz3WbwgZxbwPOy55pTMLsirj5jTMEjP2MO+rmPwI1OVeoTsHIFGGkv+FB0qVe531GBUnava/jQa4hjJZfFN/neJN+bgenGpsz6L5FJN4I7wkMXgqA5gaTnr5L9LepEukEbC09AhsVhc+R0AV56MPi7vCN33CjvlT0sEBoOm8PjID/46XLrnuByeA8seac5b6D5hmGmKJ4bnSP9TMinOWjEm0eMpB7k+WeNdHPYyCxcJm3BsBaO4F49I5t6uup0kC8E6xizs6hQR/8WcW6gTtr6iIE6+bkKfkrhUc5et0KSstiuQV7Vu5i5ag60VZxYeShaDw97iLgWfLkL2A7fztUn8QhM28vVEBGsSPcPjNoObXKjJQvxLBLPSnBHG9S3FynuQRsWmkWeG+RXs2VtamNrwDnuRoNAT78M12xARfuzZhe69RkTFoLviH05/5ZyvKyLFnEgLX1F12COVIM7sub5uvCQ9+27Dv7E8KFP2Hbx1OjRUbv6kn342a+eeMkWXIiTM+cD8z46+ieQSHt4SE8HG5iysnOMnnkPHW4Clj6DuO5cp8xTpo7TA3+U22ctIkoMrf4ZC+lFKDQsBxcsXT+GdHqVOn+Gckz09zaP079GYTaPIdnTCP4stjFWaZvQD4jbGJL/jtX98dmkSzcY1KYDiRvqfC3EO4rdXN/OT/vIzSAyTPmMkm1KLGoj5AP5xrf6s4j2+66aXMegleW7oC6GNq0wbpslMjP6OOgdJepk7FG5M4Q0XO+gbRBgNKNDWaqWo1UEbfvfETk2ZUTs4xzkUSRdbIfK1cgjXB8Q3QK8ndNYyjmCwcgYOkLZWnJtiFzN8j282jJTvw9EbuajWwMrOAjfk5yURmPIsegyOoYyx7Jr0Hgd232+p9GRWYKxe1Hepuy06C5P5A9l6diJXM1D+4xe0LN492yHn6v5LZnLlDMKw+wK3ovQ/7ts7Bznu+29t2SP80Ro09beD2UP6uAY1K4CY0Tl1hDB7we4t5NY0H7heres4611zxLXFlRqTL4ZizZuDjjyDlYtR7DYewUZ6j5HP3yBsehR9/zsPB6ZfQP9zMs283LP2s9Qmr2M3OxPmObexKG1n2No7afw1i7DXZvHAHOrSfleaeehNOMmzr2kxA/36+39LZKD+X1OG5GtoV+IT6pM3LUmN3Shf+CSkvzyAwfod7QZBKqykdA7GMMxGPll/zZxiMg+eXzdTU+RtoTl7ydukC4JnHMu0Y2lDhyg/Zl5fKguYvWxxdwyfWMpxDtC2pPajAShXVu4Ps8UtGmLsqyJk2Lc9orch4EoLL8otCJPn0mflG/Fb2WZ4qf0vRdJIf6guY7p2mN5sTFxzguu1NuSXDhODMeJ7TixHSeW48R2hXiuEM8VYrxCTFeI6QoxVTbpiX/bRsMpuq7gsilDyW9IbMFNNfDO5xKr6sD5bBnTZ4HXnTJmYqLHgRkEJf91wXwoxNRAp+B3ifHIYb5SQJS6BpPM+a+28j5jjjlY7FVj/FWCv8m6vG3RlXlQIVp+6GLhuyWxN74WnvG79L1WfS/7sMvdF79RxuWxZT9xo7wNnw7pIqTrnkbfZdqPPvb0RHC9jItnRf53vMQfZKbsiutbnLlufLTU2eK5zaP/ouK0feCvLRve+VD0Y+y+JrZoyx4ZYU0pb7/oberC83ehrAJwnD71jKO3wBO6/c9BfMgQXngy0wtj79us231dFXyKsdqLL9n7Rug9kUrFiTQyTqRg7SMuYdPjnEBk28ngfVSOO11+8Jv9ZxG0ZfqT8BacuZ4P1x5jrrhO32c+rwa/yt3cylqVQLoYXM3dxLrUMK7f5dq0vGCFfBKknQ5pVVrxPGoVsd2NmZ0S6+lV5tTlHovfKb2cjPN+jPd/IfdZe1hzEVd7rCztA7GRKp7PdheC263aCbuPmYCYDKg959jzBO8JrpzfF54HmZbcQ1evecmr1w6G96++pguPPoUxZJjNU8wxrEjpQpjXjJ3f1rVPvopi++zzVh/PPZ4vSf6GyvioZrKu+uOILpwYbeX161npQepFI7PZ//wvvY8evE/f8687rHODrI+tHot+S5pp1rtBhHWOmETytb9IrdusiWmpiYzL+/cMrMmM18xm/WEWj7db2A35k1pB2U7niz7yhfOQerHw5bBvsGjndt/EfqeEOHsei71Op9R1N7o7yE+H+eI064/OSy04Sn+rZh4eqbAfkjvSNx2l/06zb2qwb4paoT5h7zhKnY6y7jO3/md/hQvU38wXLmxhj2lR13iTZajZQE9Yr6PRI8QkySF+o8hH3vKZozOchzkntRFvYULZdaHTg7eRwIPfg9+D3//578nfHa2cffOf1j/uqQAQAAA=", "base64")); + +const output = process.argv[2]; +if (!output) throw new Error("output directory is required"); +mkdirSync(output, { recursive: true }); + +const expected = new Map([ + ["Ludarium SNES Fixture.sfc", "c5458603faf6e04e550d73c4724bb948fb75c5b54bf24280a006501c2ace52ed"], + ["Ludarium GB Fixture.gb", "7ed74ff088ce50d18ecd37847e3c0b81774e9b54e254149fc6502ed8ec084870"], + ["Ludarium GBC Fixture.gbc", "59111888ffff8c92c3f5bbd6bbe0f9182a7a53c634ec6b00d2ce8d9bd49f9f5a"], + ["Ludarium GBA Fixture.gba", "5a393a7b3a5b9beeae7f9a5e0495f1669e1855dcf6379a73bd97e7a974787813"], + ["Ludarium N64 Fixture.z64", "4f6e7fcb1962f02e59b9f896591254e9f1b68273e2179fc7a0c26e681500c326"], + ["Ludarium NDS Fixture.nds", "b797bfaa0e5e20cf2091f8268975bfa3cb820dd88c2e06134f0c9ce2fb1180cf"], + ["Ludarium PSP Fixture.pbp", "2ff952ec933c67ee6911049d77201da572aa7241e3f2ee7ff935ed61646964f4"], + ["Ludarium PSX Fixture.chd", "58e9754dd2c868d091cbd2401182b5026e436a7b199437fb7dfab039fccb89e7"], + ["Ludarium Genesis Fixture.md", "b07f1e19de6d517399ee398295e08d4b94f3cad8f5b0470b46ce37bdae0d508e"], + ["Ludarium Master System Fixture.sms", "0302cdf0f3e67488cf2ad11b655ea73d0c66e60b9e35153e0fe2b626cf358a0e"], + ["Ludarium Game Gear Fixture.gg", "f5fb90060122ec533a2200103b4d78c254f6bce72deb04ca11c065cb1d61af17"] +]); +const write = (name, bytes) => { + const digest = createHash("sha256").update(bytes).digest("hex"); + if (digest !== expected.get(name)) throw new Error(`${name} checksum changed: ${digest}`); + writeFileSync(join(output, name), bytes, { mode: 0o444 }); +}; +const ascii = (buffer, offset, value, length) => buffer.write(value.slice(0, length), offset, "ascii"); +const u32le = (buffer, offset, value) => buffer.writeUInt32LE(value >>> 0, offset); +const u32be = (buffer, offset, value) => buffer.writeUInt32BE(value >>> 0, offset); +const crc16 = bytes => { + let crc = 0xffff; + for (const byte of bytes) { + crc ^= byte; + for (let bit = 0; bit < 8; bit++) crc = (crc & 1) ? (crc >>> 1) ^ 0xa001 : crc >>> 1; + } + return crc; +}; + +const pspFixture = gunzipSync(readFileSync(join(import.meta.dirname, "psp", "ludarium-psp-fixture.pbp.gz"))); +const psxFixture = gunzipSync(readFileSync(join(import.meta.dirname, "psx", "ludarium-psx-fixture.chd.gz"))); + +function gameBoy(color) { + const rom = Buffer.alloc(32 * 1024, 0); + rom.set([0xc3, 0x50, 0x01], 0x100); // JP $0150; no boot ROM or native code is required. + rom.set([0xce, 0xed, 0x66, 0x66], 0x104); // Deterministic cartridge signature used by the analyzer. + ascii(rom, 0x134, color ? "LUDARIUM GBC" : "LUDARIUM GB", 15); + rom[0x143] = color ? 0x80 : 0x00; + rom[0x147] = 0x00; // ROM-only cartridge. + rom[0x148] = 0x00; // 32 KiB. + rom[0x149] = 0x00; + rom.set([0xf3, 0x31, 0xfe, 0xff, 0x18, 0xfe], 0x150); // DI; LD SP,$FFFE; JR forever. + let checksum = 0; + for (let index = 0x134; index <= 0x14c; index++) checksum = (checksum - rom[index] - 1) & 0xff; + rom[0x14d] = checksum; + return rom; +} + +function snes() { + const rom = Buffer.alloc(32 * 1024, 0); + rom.set([0x78, 0x18, 0xfb, 0xc2, 0x30, 0x80, 0xfe], 0); // Native-mode loop at $008000. + ascii(rom, 0x7fc0, "LUDARIUM SNES FIXTURE", 21); + rom[0x7fd5] = 0x20; // LoROM, slow ROM. + rom[0x7fd6] = 0x00; + rom[0x7fd7] = 0x05; + rom[0x7fd9] = 0x01; + for (const vector of [0x7fea, 0x7fec, 0x7fee, 0x7ffa, 0x7ffc, 0x7ffe]) rom.writeUInt16LE(0x8000, vector); + let checksum = 0; + for (const byte of rom) checksum = (checksum + byte) & 0xffff; + rom.writeUInt16LE(checksum ^ 0xffff, 0x7fdc); + rom.writeUInt16LE(checksum, 0x7fde); + return rom; +} + +function gba() { + const rom = Buffer.alloc(32 * 1024, 0); + u32le(rom, 0, 0xeafffffe); // ARM B . + rom.set([0x24, 0xff, 0xae, 0x51], 0x04); // Deterministic cartridge signature used by the analyzer. + ascii(rom, 0xa0, "LUDARIUMGBA", 12); + ascii(rom, 0xac, "LUDE", 4); + ascii(rom, 0xb0, "00", 2); + rom[0xb2] = 0x96; + let checksum = 0; + for (let index = 0xa0; index <= 0xbc; index++) checksum = (checksum - rom[index]) & 0xff; + rom[0xbd] = (checksum - 0x19) & 0xff; + return rom; +} + +function n64() { + const rom = Buffer.alloc(1024 * 1024, 0); + libdragonIpl3.copy(rom, 0); + u32be(rom, 8, 0x80000400); + u32be(rom, 0x10, 8); + ascii(rom, 0x20, "LUDARIUM N64 FIXTURE", 20); + ascii(rom, 0x3b, "LU", 2); + rom[0x3e] = 0x45; + u32be(rom, 0x1000, 0x1000ffff); // MIPS BEQ $zero,$zero,-1. + u32be(rom, 0x1004, 0x00000000); + return rom; +} + +function nds() { + const rom = Buffer.alloc(8 * 1024, 0); + ascii(rom, 0x00, "LUDARIUM NDS", 12); + ascii(rom, 0x0c, "LUDE", 4); + ascii(rom, 0x10, "00", 2); + u32le(rom, 0x20, 0x200); u32le(rom, 0x24, 0x02000000); u32le(rom, 0x28, 0x02000000); u32le(rom, 0x2c, 4); + u32le(rom, 0x30, 0x400); u32le(rom, 0x34, 0x02380000); u32le(rom, 0x38, 0x02380000); u32le(rom, 0x3c, 4); + u32le(rom, 0x80, rom.length); u32le(rom, 0x84, 0x200); + rom.set([0x24, 0xff, 0xae, 0x51], 0xc0); // Deterministic cartridge signature used by the analyzer. + u32le(rom, 0x200, 0xeafffffe); u32le(rom, 0x400, 0xeafffffe); + rom.writeUInt16LE(crc16(rom.subarray(0, 0x15e)), 0x15e); + return rom; +} + +// Sega hardware. A Mega Drive cartridge is identified by its console name at $100 and boots from the +// 68000 vector table at $0; an 8-bit Sega ROM is identified by the "TMR SEGA" header near the end of +// its first bank, whose region nibble separates Master System from Game Gear. +function megaDrive() { + const rom = Buffer.alloc(64 * 1024, 0); + u32be(rom, 0x00, 0x00fffe00); // Initial stack pointer. + u32be(rom, 0x04, 0x00000200); // Reset vector -> $200. + for (let vector = 0x08; vector < 0x100; vector += 4) u32be(rom, vector, 0x00000200); + ascii(rom, 0x100, "SEGA MEGA DRIVE ", 16); + ascii(rom, 0x110, "(C)LUDARIUM 2026", 16); + ascii(rom, 0x120, "LUDARIUM GENESIS FIXTURE".padEnd(48), 48); + ascii(rom, 0x150, "LUDARIUM GENESIS FIXTURE".padEnd(48), 48); + ascii(rom, 0x180, "GM 00000000-00", 14); + u32be(rom, 0x1a0, 0x00000000); + u32be(rom, 0x1a4, rom.length - 1); + u32be(rom, 0x1a8, 0x00ff0000); + u32be(rom, 0x1ac, 0x00ffffff); + ascii(rom, 0x1f0, "JUE", 3); + rom.writeUInt16BE(0x60fe, 0x200); // BRA.S * — a deterministic idle loop. + let checksum = 0; + for (let index = 0x200; index < rom.length; index += 2) checksum = (checksum + rom.readUInt16BE(index)) & 0xffff; + rom.writeUInt16BE(checksum, 0x18e); + return rom; +} + +function segaEightBit(gameGear) { + const rom = Buffer.alloc(32 * 1024, 0); + rom.set([0xf3, 0xed, 0x56, 0x18, 0xfe], 0x00); // DI; IM 1; JR * — a deterministic idle loop. + ascii(rom, 0x7ff0, "TMR SEGA", 8); + rom[0x7ffc] = 0x23; // Product code, low BCD pair. + rom[0x7ffd] = 0x01; // Product code, high BCD pair. + rom[0x7ffe] = 0x51; // Top product digit and revision 1. + rom[0x7fff] = (gameGear ? 0x60 : 0x40) | 0x0c; // Region nibble plus the 32 KiB size nibble. + let checksum = 0; + for (let index = 0; index < 0x7ff0; index++) checksum = (checksum + rom[index]) & 0xffff; + rom.writeUInt16LE(checksum, 0x7ffa); + return rom; +} + +write("Ludarium SNES Fixture.sfc", snes()); +write("Ludarium GB Fixture.gb", gameBoy(false)); +write("Ludarium GBC Fixture.gbc", gameBoy(true)); +write("Ludarium GBA Fixture.gba", gba()); +write("Ludarium N64 Fixture.z64", n64()); +write("Ludarium NDS Fixture.nds", nds()); +write("Ludarium Genesis Fixture.md", megaDrive()); +write("Ludarium Master System Fixture.sms", segaEightBit(false)); +write("Ludarium Game Gear Fixture.gg", segaEightBit(true)); +write("Ludarium PSP Fixture.pbp", pspFixture); +write("Ludarium PSX Fixture.chd", psxFixture); diff --git a/fixtures/synthetic/browserplay/minimal-ines.base64 b/fixtures/synthetic/browserplay/minimal-ines.base64 new file mode 100644 index 0000000..25ade28 --- /dev/null +++ b/fixtures/synthetic/browserplay/minimal-ines.base64 @@ -0,0 +1 @@ +TkVTGgEAAAAAAAAAAAAAAA== diff --git a/fixtures/synthetic/browserplay/playable-nrom.nes.gz.base64 b/fixtures/synthetic/browserplay/playable-nrom.nes.gz.base64 new file mode 100644 index 0000000..52f0569 --- /dev/null +++ b/fixtures/synthetic/browserplay/playable-nrom.nes.gz.base64 @@ -0,0 +1 @@ +H4sIAAAAAAACCu3QoQ0CQRRF0T8IsOtIUINH4hmDIxiaoYmZApDrtospgxLoAQFZBBVgSM4RN0+/8/GySSm+yvU+lrYu4+v2aJFbym0ou0Uenp9Mh7rMU8zZ1tW8IteUT/seAAAAAAAAAAAAAAAAAAAAwA/01KO7AQAAAAAAAAD+3xuAOQilEGAAAA== diff --git a/fixtures/synthetic/browserplay/ps2/Makefile b/fixtures/synthetic/browserplay/ps2/Makefile new file mode 100644 index 0000000..97b4ece --- /dev/null +++ b/fixtures/synthetic/browserplay/ps2/Makefile @@ -0,0 +1,12 @@ +EE_BIN = ludarium.elf +EE_OBJS = main.o +EE_LIBS = -lpacket -ldma -lgraph -ldraw -lc + +all: $(EE_BIN) + $(EE_STRIP) --strip-all $(EE_BIN) + +clean: + rm -f $(EE_BIN) $(EE_OBJS) + +include $(PS2SDK)/samples/Makefile.pref +include $(PS2SDK)/samples/Makefile.eeglobal diff --git a/fixtures/synthetic/browserplay/ps2/SYSTEM.CNF b/fixtures/synthetic/browserplay/ps2/SYSTEM.CNF new file mode 100644 index 0000000..26f6bf4 --- /dev/null +++ b/fixtures/synthetic/browserplay/ps2/SYSTEM.CNF @@ -0,0 +1,3 @@ +BOOT2 = cdrom0:\LUDARIUM.ELF;1 +VER = 1.00 +VMODE = NTSC diff --git a/fixtures/synthetic/browserplay/ps2/main.c b/fixtures/synthetic/browserplay/ps2/main.c new file mode 100644 index 0000000..dc5a567 --- /dev/null +++ b/fixtures/synthetic/browserplay/ps2/main.c @@ -0,0 +1,32 @@ +#include <kernel.h> +#include <dma.h> +#include <draw.h> +#include <graph.h> +#include <gs_psm.h> +#include <packet.h> + +int main(void) +{ + framebuffer_t frame = { + .address = 0, + .width = 640, + .height = 448, + .psm = GS_PSM_32, + .mask = 0 + }; + zbuffer_t depth = { .enable = 0, .address = 0, .zsm = 0, .mask = 0 }; + packet_t *packet = packet_init(32, PACKET_NORMAL); + + frame.address = graph_vram_allocate(frame.width, frame.height, frame.psm, GRAPH_ALIGN_PAGE); + dma_channel_initialize(DMA_CHANNEL_GIF, NULL, 0); + dma_channel_fast_waits(DMA_CHANNEL_GIF); + graph_initialize(frame.address, frame.width, frame.height, frame.psm, 0, 0); + + qword_t *command = draw_setup_environment(packet->data, 0, &frame, &depth); + command = draw_clear(command, 0, 0, 0, frame.width, frame.height, 18, 42, 96); + command = draw_finish(command); + dma_channel_send_normal(DMA_CHANNEL_GIF, packet->data, command - packet->data, 0, 0); + draw_wait_finish(); + + for (;;) graph_wait_vsync(); +} diff --git a/fixtures/synthetic/browserplay/psp/Makefile b/fixtures/synthetic/browserplay/psp/Makefile new file mode 100644 index 0000000..4a2a22f --- /dev/null +++ b/fixtures/synthetic/browserplay/psp/Makefile @@ -0,0 +1,13 @@ +TARGET = ludarium-fixture +OBJS = main.o + +CFLAGS = -O2 -G0 -Wall -Wextra +CXXFLAGS = $(CFLAGS) -fno-exceptions -fno-rtti +ASFLAGS = $(CFLAGS) +LIBS = -lpspdebug + +EXTRA_TARGETS = EBOOT.PBP +PSP_EBOOT_TITLE = Ludarium PSP Fixture + +PSPSDK = $(shell psp-config --pspsdk-path) +include $(PSPSDK)/lib/build.mak diff --git a/fixtures/synthetic/browserplay/psp/ludarium-psp-fixture.pbp.gz b/fixtures/synthetic/browserplay/psp/ludarium-psp-fixture.pbp.gz new file mode 100644 index 0000000..c2c39fa Binary files /dev/null and b/fixtures/synthetic/browserplay/psp/ludarium-psp-fixture.pbp.gz differ diff --git a/fixtures/synthetic/browserplay/psp/main.c b/fixtures/synthetic/browserplay/psp/main.c new file mode 100644 index 0000000..fd34ced --- /dev/null +++ b/fixtures/synthetic/browserplay/psp/main.c @@ -0,0 +1,20 @@ +#include <pspkernel.h> +#include <pspdebug.h> + +PSP_MODULE_INFO("LudariumFixture", PSP_MODULE_USER, 1, 0); +PSP_MAIN_THREAD_ATTR(PSP_THREAD_ATTR_USER); + +int main(void) +{ + pspDebugScreenInit(); + pspDebugScreenSetBackColor(0x00302010); + pspDebugScreenSetTextColor(0x00ffffff); + pspDebugScreenClear(); + pspDebugScreenSetXY(8, 10); + pspDebugScreenPrintf("Ludarium PSP release fixture\n"); + pspDebugScreenSetXY(8, 12); + pspDebugScreenPrintf("Original open homebrew - no firmware or game data"); + + for (;;) + sceKernelDelayThread(1000000); +} diff --git a/fixtures/synthetic/browserplay/psx/CMakeLists.txt b/fixtures/synthetic/browserplay/psx/CMakeLists.txt new file mode 100644 index 0000000..d8bb16f --- /dev/null +++ b/fixtures/synthetic/browserplay/psx/CMakeLists.txt @@ -0,0 +1,5 @@ +cmake_minimum_required(VERSION 3.21) +project(LudariumPsxFixture LANGUAGES C ASM) + +psn00bsdk_add_executable(ludarium GPREL main.c) +psn00bsdk_add_cd_image(fixture fixture iso.xml DEPENDS ludarium system.cnf) diff --git a/fixtures/synthetic/browserplay/psx/CMakePresets.json b/fixtures/synthetic/browserplay/psx/CMakePresets.json new file mode 100644 index 0000000..54667ed --- /dev/null +++ b/fixtures/synthetic/browserplay/psx/CMakePresets.json @@ -0,0 +1,17 @@ +{ + "version": 3, + "cmakeMinimumRequired": { "major": 3, "minor": 21, "patch": 0 }, + "configurePresets": [ + { + "name": "default", + "generator": "Ninja", + "toolchainFile": "$env{PSN00BSDK_LIBS}/cmake/sdk.cmake", + "binaryDir": "${sourceDir}/build", + "cacheVariables": { + "CMAKE_BUILD_TYPE": "MinSizeRel", + "PSN00BSDK_TC": "", + "PSN00BSDK_TARGET": "mipsel-none-elf" + } + } + ] +} diff --git a/fixtures/synthetic/browserplay/psx/README.md b/fixtures/synthetic/browserplay/psx/README.md new file mode 100644 index 0000000..616a666 --- /dev/null +++ b/fixtures/synthetic/browserplay/psx/README.md @@ -0,0 +1,22 @@ +# Original PSX browser-play fixture + +This fixture is original Ludarium test code. It initializes the PlayStation display to a +solid blue background and waits for vertical blank forever. It contains no Sony BIOS, +license sector, SDK binary, retail executable or game asset. + +Build provenance: + +- PSn00bSDK `v0.24`, commit `06e65bea3a778b2dae5af77a7935ae3868ddd4d3`; +- official `PSn00bSDK-0.24-Linux.zip`, SHA-256 + `5ada7c9478d795b22bde96ee23d6869290c14a740bcde3d57c01c342a26ded35`; +- official `gcc-mipsel-none-elf-12.3.0-linux.zip`, SHA-256 + `228f031a25cf2687d8845fd1421f625bafc211fa27da428e458e80d030a726f8`; +- PSn00bSDK `mkpsxiso`, followed by MAME `chdman` 0.264; +- final 15,411-byte CHD SHA-256 + `58e9754dd2c868d091cbd2401182b5026e436a7b199437fb7dfab039fccb89e7`; +- checked-in gzip SHA-256 + `7b1af8fcfcaf052d40fac4696710b1f8b17bc3d6d32f36abd04e4c564d2d0f9f`. + +`generate-fixtures.mjs` inflates the checked-in payload and refuses to emit it when the CHD +checksum differs. The live gate then verifies the exact 15,411-byte artifact via scoped +Range delivery before starting the actual pinned `pcsx_rearmed` core. diff --git a/fixtures/synthetic/browserplay/psx/iso.xml b/fixtures/synthetic/browserplay/psx/iso.xml new file mode 100644 index 0000000..06a7c38 --- /dev/null +++ b/fixtures/synthetic/browserplay/psx/iso.xml @@ -0,0 +1,13 @@ +<?xml version="1.0" encoding="utf-8"?> +<iso_project> + <track type="data"> + <identifiers system="PLAYSTATION" volume="LUDARIUM_FIXTURE" + volume_set="LUDARIUM_FIXTURE" publisher="LUDARIUM" + data_preparer="PSN00BSDK" application="PLAYSTATION" copyright="README.TXT;1" /> + <directory_tree> + <file name="SYSTEM.CNF" type="data" source="${PROJECT_SOURCE_DIR}/system.cnf" /> + <file name="LUDARIUM.EXE" type="data" source="ludarium.exe" /> + <dummy sectors="1024" /> + </directory_tree> + </track> +</iso_project> diff --git a/fixtures/synthetic/browserplay/psx/ludarium-psx-fixture.chd.gz b/fixtures/synthetic/browserplay/psx/ludarium-psx-fixture.chd.gz new file mode 100644 index 0000000..3f718f8 Binary files /dev/null and b/fixtures/synthetic/browserplay/psx/ludarium-psx-fixture.chd.gz differ diff --git a/fixtures/synthetic/browserplay/psx/main.c b/fixtures/synthetic/browserplay/psx/main.c new file mode 100644 index 0000000..7825228 --- /dev/null +++ b/fixtures/synthetic/browserplay/psx/main.c @@ -0,0 +1,19 @@ +#include <psxgpu.h> + +int main(void) +{ + DISPENV display; + DRAWENV draw; + + ResetGraph(0); + SetDefDispEnv(&display, 0, 0, 320, 240); + SetDefDrawEnv(&draw, 0, 0, 320, 240); + setRGB0(&draw, 18, 42, 96); + draw.isbg = 1; + PutDispEnv(&display); + PutDrawEnv(&draw); + SetDispMask(1); + + for (;;) + VSync(0); +} diff --git a/fixtures/synthetic/browserplay/psx/system.cnf b/fixtures/synthetic/browserplay/psx/system.cnf new file mode 100644 index 0000000..97abc7a --- /dev/null +++ b/fixtures/synthetic/browserplay/psx/system.cnf @@ -0,0 +1,4 @@ +BOOT=cdrom:\LUDARIUM.EXE;1 +TCB=4 +EVENT=10 +STACK=801FFFF0 diff --git a/fixtures/synthetic/cue/complete/game (Track 01).bin b/fixtures/synthetic/cue/complete/game (Track 01).bin new file mode 100644 index 0000000..97126a6 --- /dev/null +++ b/fixtures/synthetic/cue/complete/game (Track 01).bin @@ -0,0 +1 @@ +LUDARIUM SYNTHETIC TRACK — NOT GAME CONTENT diff --git a/fixtures/synthetic/cue/complete/game.cue b/fixtures/synthetic/cue/complete/game.cue new file mode 100644 index 0000000..0171fe4 --- /dev/null +++ b/fixtures/synthetic/cue/complete/game.cue @@ -0,0 +1,3 @@ +FILE "game (Track 01).bin" BINARY + TRACK 01 MODE1/2352 + INDEX 01 00:00:00 diff --git a/fixtures/synthetic/cue/missing/missing-track.cue b/fixtures/synthetic/cue/missing/missing-track.cue new file mode 100644 index 0000000..efc00d8 --- /dev/null +++ b/fixtures/synthetic/cue/missing/missing-track.cue @@ -0,0 +1,3 @@ +FILE "absent.bin" BINARY + TRACK 01 MODE1/2352 + INDEX 01 00:00:00 diff --git a/fixtures/synthetic/cue/traversal/escape.cue b/fixtures/synthetic/cue/traversal/escape.cue new file mode 100644 index 0000000..c344300 --- /dev/null +++ b/fixtures/synthetic/cue/traversal/escape.cue @@ -0,0 +1 @@ +FILE "../../outside.bin" BINARY diff --git a/fixtures/synthetic/m3u/complete/Disc 1.cue b/fixtures/synthetic/m3u/complete/Disc 1.cue new file mode 100644 index 0000000..b47a731 --- /dev/null +++ b/fixtures/synthetic/m3u/complete/Disc 1.cue @@ -0,0 +1 @@ +REM SYNTHETIC DISC 1 diff --git a/fixtures/synthetic/m3u/complete/Disc 2.cue b/fixtures/synthetic/m3u/complete/Disc 2.cue new file mode 100644 index 0000000..85d3d5c --- /dev/null +++ b/fixtures/synthetic/m3u/complete/Disc 2.cue @@ -0,0 +1 @@ +REM SYNTHETIC DISC 2 diff --git a/fixtures/synthetic/m3u/complete/collection.m3u b/fixtures/synthetic/m3u/complete/collection.m3u new file mode 100644 index 0000000..2daf249 --- /dev/null +++ b/fixtures/synthetic/m3u/complete/collection.m3u @@ -0,0 +1,3 @@ +#EXTM3U +Disc 1.cue +Disc 2.cue diff --git a/fixtures/synthetic/windows/installer/setup-1.bin b/fixtures/synthetic/windows/installer/setup-1.bin new file mode 100644 index 0000000..beac686 --- /dev/null +++ b/fixtures/synthetic/windows/installer/setup-1.bin @@ -0,0 +1 @@ +LUDARIUM SYNTHETIC PAYLOAD 1 diff --git a/fixtures/synthetic/windows/installer/setup-2.bin b/fixtures/synthetic/windows/installer/setup-2.bin new file mode 100644 index 0000000..c4521f7 --- /dev/null +++ b/fixtures/synthetic/windows/installer/setup-2.bin @@ -0,0 +1 @@ +LUDARIUM SYNTHETIC PAYLOAD 2 diff --git a/fixtures/synthetic/windows/installer/setup.exe b/fixtures/synthetic/windows/installer/setup.exe new file mode 100644 index 0000000..696cd5a --- /dev/null +++ b/fixtures/synthetic/windows/installer/setup.exe @@ -0,0 +1 @@ +MZLUDARIUM SYNTHETIC INVALID PE FIXTURE — NEVER EXECUTE diff --git a/fixtures/synthetic/windows/installer/setup.exe.fixture b/fixtures/synthetic/windows/installer/setup.exe.fixture new file mode 100644 index 0000000..01b7b23 --- /dev/null +++ b/fixtures/synthetic/windows/installer/setup.exe.fixture @@ -0,0 +1 @@ +MZ — deterministic inert text fixture; extension intentionally prevents execution. diff --git a/global.json b/global.json new file mode 100644 index 0000000..6d5813f --- /dev/null +++ b/global.json @@ -0,0 +1,7 @@ +{ + "sdk": { + "version": "10.0.302", + "rollForward": "latestPatch", + "allowPrerelease": false + } +} diff --git a/public-source.allowlist b/public-source.allowlist new file mode 100644 index 0000000..288524b --- /dev/null +++ b/public-source.allowlist @@ -0,0 +1,52 @@ +# Files that may enter the parentless public source repository. Keep this list explicit. +.dockerignore +.gitattributes +.gitignore +.gitleaks.toml +.gitea/workflows/public-validation.yml +CHANGELOG.md +CONTRIBUTING.md +Directory.Build.props +Dockerfile +Dockerfile.dolphin-controller +Dockerfile.eden-controller +Dockerfile.unraid +LICENSE +Ludarium.slnx +NuGet.config +README.md +SECURITY.md +THIRD_PARTY_NOTICES.md +VERSION +deploy/.env.example +deploy/README.md +deploy/audit-eden-runtime.sh +deploy/compose.dolphin.yml +deploy/compose.switch.yml +deploy/compose.yml +deploy/dolphin-controller-run +deploy/dolphin-controller.py +deploy/eden-controller-run +deploy/eden-controller.py +deploy/ludarium-entrypoint.sh +deploy/ludarium-unraid.xml +deploy/ludarium_sidecar.py +deploy/prepare-switch-appdata.sh +deploy/repair-postgres-collation.sh +deploy/run-candidate-gate.sh +deploy/run-eden-security-gate.sh +deploy/run-managed-validation.sh +deploy/run-security-gates.sh +deploy/unraid/Ludarium-icon.png +deploy/unraid/my-Ludarium.xml +deploy/verify-release-identity.sh +deploy/verify-source-manifest.sh +docs/PUBLIC_SOURCE_BOUNDARY.md +docs/screenshots/living-archive-audit/audit-home-desktop.png +fixtures/** +global.json +public-source.allowlist +scripts/export-public-source.mjs +scripts/validate-public-source.mjs +src/** +tests/** diff --git a/scripts/export-public-source.mjs b/scripts/export-public-source.mjs new file mode 100644 index 0000000..b3e63cd --- /dev/null +++ b/scripts/export-public-source.mjs @@ -0,0 +1,65 @@ +import { copyFile, mkdir, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { createHash } from "node:crypto"; +import { execFileSync } from "node:child_process"; +import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; +import process from "node:process"; + +const root = resolve(new URL("..", import.meta.url).pathname.replace(/^\/(.:)/, "$1")); +const destination = resolve(process.argv[2] ?? ""); +if (!process.argv[2] || destination === root || relative(root, destination).split(sep).includes("..") === false) + throw new Error("Choose an empty export directory outside the canonical repository."); +if (execFileSync("git", ["status", "--porcelain"], { cwd: root, encoding: "utf8" }).trim()) + throw new Error("The canonical worktree must be clean before export."); +await stat(join(root, "LICENSE")).catch(() => { throw new Error("Confirm and commit a root LICENSE before public export."); }); + +const rules = (await readFile(join(root, "public-source.allowlist"), "utf8")) + .split(/\r?\n/).map(line => line.trim()).filter(line => line && !line.startsWith("#")); +const tracked = execFileSync("git", ["ls-files", "-z"], { cwd: root, encoding: "utf8" }) + .split("\0").filter(Boolean).map(path => path.replaceAll("\\", "/")); +const allowed = path => rules.some(rule => rule.endsWith("/**") ? + path.startsWith(rule.slice(0, -3) + "/") : path === rule); +const files = tracked.filter(allowed).sort(); +const missing = rules.filter(rule => !rule.endsWith("/**") && !tracked.includes(rule)); +if (missing.length) throw new Error(`Allowlisted files are missing: ${missing.join(", ")}`); + +const proprietaryPayload = /\.(?:iso|rom|bin|cue|chd|rvz|wbfs|xci|nsp|keys|exe|nes|pbp)(?:\.|$)/i; +const privateMarkers = [ + ["192", "168", "10", "150"].join("."), + ["ludarium", "itworx", "tech"].join("."), + ["player", "ludarium", "itworx", "tech"].join("."), +]; +const binaryExtensions = new Set([".png", ".jpg", ".jpeg", ".webp", ".gif", ".ico", ".mp4", ".woff", ".woff2", ".7z"]); +const extension = path => path.slice(path.lastIndexOf(".")).toLowerCase(); + +await rm(destination, { recursive: true, force: true }); +await mkdir(destination, { recursive: true }); +const manifestFiles = []; +for (const path of files) { + if (isAbsolute(path) || path.split("/").includes("..")) throw new Error(`Unsafe allowlisted path: ${path}`); + const syntheticFixture = path.startsWith("fixtures/synthetic/"); + if (proprietaryPayload.test(path) && !syntheticFixture) + throw new Error(`Proprietary payload extension is not publishable: ${path}`); + const source = join(root, path); + const metadata = await stat(source); + if (!metadata.isFile() || metadata.size > 50 * 1024 * 1024 || (syntheticFixture && metadata.size > 1024 * 1024)) + throw new Error(`Irregular or oversized public file: ${path}`); + const data = await readFile(source); + if (!binaryExtensions.has(extension(path))) { + const text = data.toString("utf8"); + const marker = privateMarkers.find(value => text.toLowerCase().includes(value)); + if (marker) throw new Error(`Private infrastructure marker found in ${path}`); + } + const target = join(destination, path); + await mkdir(dirname(target), { recursive: true }); + await copyFile(source, target); + manifestFiles.push({ path, bytes: metadata.size, sha256: createHash("sha256").update(data).digest("hex") }); +} + +const sourceRevision = execFileSync("git", ["rev-parse", "HEAD"], { cwd: root, encoding: "utf8" }).trim(); +await writeFile(join(destination, "PUBLIC_SOURCE_MANIFEST.json"), JSON.stringify({ + schemaVersion: 1, + sourceRevision, + generatedAt: new Date().toISOString(), + files: manifestFiles, +}, null, 2) + "\n"); +console.log(`Exported ${files.length} reviewed files from ${sourceRevision} to ${destination}`); diff --git a/scripts/validate-public-source.mjs b/scripts/validate-public-source.mjs new file mode 100644 index 0000000..9c845be --- /dev/null +++ b/scripts/validate-public-source.mjs @@ -0,0 +1,33 @@ +import { readFile, readdir, stat } from "node:fs/promises"; +import { createHash } from "node:crypto"; +import { dirname, join, resolve } from "node:path"; + +const root = resolve(new URL("..", import.meta.url).pathname.replace(/^\/(.:)/, "$1")); +const manifest = JSON.parse(await readFile(join(root, "PUBLIC_SOURCE_MANIFEST.json"), "utf8")); +if (manifest.schemaVersion !== 1 || !/^[0-9a-f]{40}$/.test(manifest.sourceRevision) || !Array.isArray(manifest.files)) + throw new Error("The public source manifest is malformed."); + +const expected = new Map(manifest.files.map(entry => [entry.path, entry])); +if (expected.size !== manifest.files.length || !expected.has("LICENSE")) + throw new Error("The public source manifest has duplicate entries or no LICENSE."); +async function walk(directory, prefix = "") { + const found = []; + for (const entry of await readdir(directory, { withFileTypes: true })) { + if (!prefix && entry.name === ".git") continue; + const path = prefix ? `${prefix}/${entry.name}` : entry.name; + if (entry.isDirectory()) found.push(...await walk(join(directory, entry.name), path)); + else if (entry.isFile() && path !== "PUBLIC_SOURCE_MANIFEST.json") found.push(path); + else if (!entry.isFile()) throw new Error(`Irregular public entry: ${path}`); + } + return found; +} +const actual = (await walk(root)).sort(); +if (actual.length !== expected.size || actual.some(path => !expected.has(path))) + throw new Error("Files outside the reviewed public manifest are present."); +for (const path of actual) { + const data = await readFile(join(root, path)); + const entry = expected.get(path); + if ((await stat(join(root, path))).size !== entry.bytes || createHash("sha256").update(data).digest("hex") !== entry.sha256) + throw new Error(`Public source integrity check failed: ${path}`); +} +console.log(`Validated ${actual.length} public files from canonical revision ${manifest.sourceRevision}`); diff --git a/src/Ludarium.Api/ApiExceptionHandler.cs b/src/Ludarium.Api/ApiExceptionHandler.cs new file mode 100644 index 0000000..5f471ab --- /dev/null +++ b/src/Ludarium.Api/ApiExceptionHandler.cs @@ -0,0 +1,35 @@ +using Ludarium.Application; +using Ludarium.Infrastructure; +using Microsoft.AspNetCore.Diagnostics; +using Microsoft.AspNetCore.Mvc; + +namespace Ludarium.Api; + +public sealed class ApiExceptionHandler(ILogger<ApiExceptionHandler> logger) : IExceptionHandler +{ + public async ValueTask<bool> TryHandleAsync(HttpContext context, Exception exception, + CancellationToken cancellationToken) + { + var (status, title) = exception switch + { + ArgumentException or MediaValidationException or ArtworkValidationException or GameDataVaultValidationException or FirmwareValidationException => + (StatusCodes.Status400BadRequest, "The request was not accepted"), + KeyNotFoundException => (StatusCodes.Status404NotFound, "The requested resource was not found"), + ResourceConflictException or ConcurrencyException or SnapshotPreconditionException or GameDataIntegrityException or FirmwareIntegrityException => + (StatusCodes.Status409Conflict, "The request conflicts with current state"), + _ => (StatusCodes.Status500InternalServerError, "An unexpected error occurred") + }; + if (status >= 500) logger.LogError(exception, "Unhandled API request failure"); + else if (logger.IsEnabled(LogLevel.Information)) + logger.LogInformation("API request rejected with {StatusCode}: {Message}", status, exception.Message); + context.Response.StatusCode = status; + await context.Response.WriteAsJsonAsync(new ProblemDetails + { + Status = status, + Title = title, + Detail = status >= 500 ? "The operation could not be completed." : exception.Message, + Instance = context.Request.Path + }, cancellationToken); + return true; + } +} diff --git a/src/Ludarium.Api/ApiRateLimits.cs b/src/Ludarium.Api/ApiRateLimits.cs new file mode 100644 index 0000000..75bc67a --- /dev/null +++ b/src/Ludarium.Api/ApiRateLimits.cs @@ -0,0 +1,18 @@ +namespace Ludarium.Api; + +public sealed record ApiRateLimitProfile(string Partition, int PermitLimit, TimeSpan Window); + +public static class ApiRateLimits +{ + public static ApiRateLimitProfile For(string method, PathString path) + { + var expensive = path.StartsWithSegments("/api/v1/games/artwork/enrich") || + path.StartsWithSegments("/api/v1/games/metadata/enrich") || + path.StartsWithSegments("/api/v1/system/cheats/refresh") || + path.StartsWithSegments("/api/v1/exports"); + if (expensive) return new("expensive", 12, TimeSpan.FromMinutes(1)); + return HttpMethods.IsGet(method) + ? new("read", 1200, TimeSpan.FromMinutes(1)) + : new("mutation", 120, TimeSpan.FromMinutes(1)); + } +} diff --git a/src/Ludarium.Api/Ludarium.Api.csproj b/src/Ludarium.Api/Ludarium.Api.csproj new file mode 100644 index 0000000..bc9b8c6 --- /dev/null +++ b/src/Ludarium.Api/Ludarium.Api.csproj @@ -0,0 +1,20 @@ +<Project Sdk="Microsoft.NET.Sdk.Web"> + + <PropertyGroup> + <TargetFramework>net10.0</TargetFramework> + <Nullable>enable</Nullable> + <ImplicitUsings>enable</ImplicitUsings> + </PropertyGroup> + + <ItemGroup> + <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.11" /> + <PackageReference Include="Microsoft.OpenApi" Version="2.11.0" /> + <PackageReference Include="Yarp.ReverseProxy" Version="2.3.0" /> + </ItemGroup> + + <ItemGroup> + <ProjectReference Include="..\Ludarium.Application\Ludarium.Application.csproj" /> + <ProjectReference Include="..\Ludarium.Infrastructure\Ludarium.Infrastructure.csproj" /> + </ItemGroup> + +</Project> diff --git a/src/Ludarium.Api/Ludarium.Api.http b/src/Ludarium.Api/Ludarium.Api.http new file mode 100644 index 0000000..0d761f9 --- /dev/null +++ b/src/Ludarium.Api/Ludarium.Api.http @@ -0,0 +1,6 @@ +@Ludarium.Api_HostAddress = http://localhost:5135 + +GET {{Ludarium.Api_HostAddress}}/weatherforecast/ +Accept: application/json + +### diff --git a/src/Ludarium.Api/NativeLaunchResolvers.cs b/src/Ludarium.Api/NativeLaunchResolvers.cs new file mode 100644 index 0000000..ee4d84f --- /dev/null +++ b/src/Ludarium.Api/NativeLaunchResolvers.cs @@ -0,0 +1,126 @@ +using Ludarium.Application; +using Ludarium.Infrastructure; + +namespace Ludarium.Api; + +/// <summary>The exact title a native player may start, or the reason it may not.</summary> +public sealed record NativeLaunchResolution(string? RuntimePath, string? Platform, + string? RejectTitle = null, string? RejectDetail = null, int RejectStatus = 409) +{ + public bool Rejected => RuntimePath is null; +} + +/// <summary> +/// Per-player rules for turning a game id into one exact runtime path. +/// </summary> +/// <remarks> +/// Everything a native player shares — configuration, sessions, cookies, proxying, control, +/// error mapping — lives in <see cref="NativeRemotePlayerRuntime"/>. What genuinely differs is +/// which linked file may start and which prerequisites apply, and only that belongs here. +/// </remarks> +public interface INativeLaunchResolver +{ + string Key { get; } + + Task<NativeRemotePlayCapability> EvaluateAsync(Guid gameId, bool runtimeReady, bool automaticRestore, + DateTimeOffset checkedAt, CancellationToken ct); + + Task<NativeLaunchResolution> ResolveAsync(Guid gameId, NativeRemotePlayerControlClient control, + CancellationToken ct); +} + +public sealed class SwitchLaunchResolver( + ILudariumStore store, + IBrowserPlayStore browserPlayStore, + IFirmwareStore firmware, + SwitchRuntimeProvisioner provisioner) : INativeLaunchResolver +{ + public string Key => NativeRemotePlayerRegistry.Switch.Key; + + /// <summary>The Switch runtime also needs operator-supplied keys before it is usable at all.</summary> + public bool RuntimeProvisioned => provisioner.Configured; + + public async Task<NativeRemotePlayCapability> EvaluateAsync(Guid gameId, bool runtimeReady, + bool automaticRestore, DateTimeOffset checkedAt, CancellationToken ct) + { + var isSwitchGame = await IsSwitchGameAsync(gameId, ct); + var candidates = isSwitchGame ? await browserPlayStore.ListSwitchLaunchCandidatesAsync(gameId, ct) : []; + var keysReady = await ProductionKeysReadyAsync(ct); + return SwitchLaunchPolicy.EvaluateCapability(gameId, isSwitchGame, + runtimeReady && provisioner.Configured, keysReady, candidates, checkedAt, automaticRestore); + } + + public async Task<NativeLaunchResolution> ResolveAsync(Guid gameId, + NativeRemotePlayerControlClient control, CancellationToken ct) + { + if (!await IsSwitchGameAsync(gameId, ct)) + return new(null, null, "Unsupported platform", + "Only a Nintendo Switch game can start an Eden session.", StatusCodes.Status400BadRequest); + if (!await ProductionKeysReadyAsync(ct)) + return new(null, "switch", "Switch production keys required", + "Upload and select your own prod.keys in Settings.", StatusCodes.Status409Conflict); + var baseGame = SwitchLaunchPolicy.SelectBaseGame( + await browserPlayStore.ListSwitchLaunchCandidatesAsync(gameId, ct)); + if (baseGame is null) + return new(null, "switch", "Exact Switch base game is ambiguous", + "Ludarium could not identify one unique read-only base XCI/NSP. Review the game's linked Switch files.", + StatusCodes.Status409Conflict); + return new(SwitchLaunchPolicy.ToRuntimePath(baseGame.RelativePath, control.CatalogPrefix("switch")), "switch"); + } + + private async Task<bool> IsSwitchGameAsync(Guid gameId, CancellationToken ct) => + (await store.ListReleasesAsync(gameId, ct)) + .Any(release => string.Equals(release.Platform, "switch", StringComparison.OrdinalIgnoreCase)); + + private async Task<bool> ProductionKeysReadyAsync(CancellationToken ct) => + await firmware.GetSelectedFirmwareAssetAsync("switch", "prod-keys", ct) is not null; +} + +public sealed class DolphinLaunchResolver(ILudariumStore store, IBrowserPlayStore browserPlayStore) + : INativeLaunchResolver +{ + public string Key => NativeRemotePlayerRegistry.Dolphin.Key; + + public async Task<NativeRemotePlayCapability> EvaluateAsync(Guid gameId, bool runtimeReady, + bool automaticRestore, DateTimeOffset checkedAt, CancellationToken ct) + { + var platform = await ResolvePlatformAsync(gameId, ct); + var candidates = platform is null ? [] + : await browserPlayStore.ListDolphinLaunchCandidatesAsync(gameId, platform, ct); + return DolphinLaunchPolicy.EvaluateCapability(gameId, platform, runtimeReady, candidates, + checkedAt, automaticRestore); + } + + public async Task<NativeLaunchResolution> ResolveAsync(Guid gameId, + NativeRemotePlayerControlClient control, CancellationToken ct) + { + var platform = await ResolvePlatformAsync(gameId, ct); + if (platform is null) + return new(null, null, "Unsupported platform", + "Only a GameCube or Wii game can start a Dolphin session.", StatusCodes.Status400BadRequest); + var selected = DolphinLaunchPolicy.SelectGame( + await browserPlayStore.ListDolphinLaunchCandidatesAsync(gameId, platform, ct)); + if (selected is null) + return new(null, platform, "Exact Dolphin game is ambiguous", + "Ludarium could not identify one unique read-only compatible image.", StatusCodes.Status409Conflict); + return new(DolphinLaunchPolicy.ToRuntimePath(platform, selected.RelativePath, control.CatalogPrefix(platform)), + platform); + } + + private async Task<string?> ResolvePlatformAsync(Guid gameId, CancellationToken ct) + { + var platforms = (await store.ListReleasesAsync(gameId, ct)).Select(release => release.Platform); + var resolved = NativeRemotePlayerRegistry.ResolvePlatform(platforms); + return NativeRemotePlayerRegistry.Dolphin.Owns(resolved) ? resolved : null; + } +} + +public sealed class NativeLaunchResolvers(IEnumerable<INativeLaunchResolver> resolvers) +{ + private readonly Dictionary<string, INativeLaunchResolver> byKey = + resolvers.ToDictionary(resolver => resolver.Key, StringComparer.OrdinalIgnoreCase); + + public INativeLaunchResolver this[string key] => byKey.TryGetValue(key, out var resolver) + ? resolver + : throw new KeyNotFoundException($"No native launch resolver is registered for '{key}'."); +} diff --git a/src/Ludarium.Api/NativeRemotePlayerEndpoints.cs b/src/Ludarium.Api/NativeRemotePlayerEndpoints.cs new file mode 100644 index 0000000..5795934 --- /dev/null +++ b/src/Ludarium.Api/NativeRemotePlayerEndpoints.cs @@ -0,0 +1,357 @@ +using Ludarium.Application; +using Ludarium.Domain; +using Ludarium.Infrastructure; +using Yarp.ReverseProxy.Forwarder; + +namespace Ludarium.Api; + +public sealed record NativeRemotePlayerAction(string? Action); + +/// <summary> +/// Maps the identical route surface every native remote player exposes. +/// </summary> +/// <remarks> +/// Routes, cookies and status codes are derived from <see cref="NativeRemotePlayer"/>, so a new +/// player gets its capability, session, action, exit and proxy endpoints from its description alone. +/// Only <see cref="INativeLaunchResolver"/> is written per player. +/// </remarks> +public static class NativeRemotePlayerEndpoints +{ + private const int MaximumCapabilityBatch = 50; + + /// <summary>How many restore points a player offers. The vault keeps the full history.</summary> + private const int MaximumRestorePoints = 10; + + public static void MapNativeRemotePlayers(this WebApplication app, RouteGroupBuilder api) + { + var runtimes = app.Services.GetRequiredService<NativeRemotePlayerRuntimes>(); + foreach (var runtime in runtimes.All) + { + MapCapabilities(api, runtime); + MapSessions(app, api, runtime); + MapSaveData(app, api, runtime); + MapProxy(app, runtime); + } + } + + /// <summary>Source type recorded on every vault revision captured from a native sidecar.</summary> + public const string SaveDataSourceType = "NativeRemotePlayerV1"; + + /// <summary> + /// Brings native remote players into the Game Data Vault. + /// </summary> + /// <remarks> + /// Browser saves have always been versioned, SHA-256 evidenced revisions with a download history. + /// Native players kept theirs inside the sidecar's own volume, where Ludarium could neither back + /// them up nor restore them to a point. These two routes close that gap using the same vault. + /// </remarks> + private static void MapSaveData(WebApplication app, RouteGroupBuilder api, NativeRemotePlayerRuntime runtime) + { + var player = runtime.Player; + var entryName = $"{player.Emulator} save data"; + + api.MapGet($"/games/{{id:guid}}/{player.Key}-save-data", async (Guid id, + IGameDataVaultStore store, CancellationToken ct) => + { + var entryId = BrowserGameDataPolicy.StableEntryId(id, player.Key, GameDataKind.Save); + var entry = await store.GetGameDataAsync(entryId, ct); + if (entry is null || entry.Entry.GameId != id) + return Results.Ok(new { entryId, revisionCount = 0, revisions = Array.Empty<object>() }); + var revisions = await store.ListGameDataRevisionsAsync(entryId, 1, MaximumRestorePoints, ct); + return Results.Ok(new + { + entryId, + entry.Entry.RevisionCount, + revisions = revisions.Items.Select(revision => new + { + revision.Id, + revision.Sequence, + revision.Length, + revision.Sha256, + revision.CreatedAt, + current = revision.Id == entry.Entry.CurrentRevisionId + }) + }); + }); + + api.MapPost($"/games/{{id:guid}}/{player.Key}-save-data", async (Guid id, ILudariumStore catalog, + IGameDataVaultStore store, GameDataVaultFileStore files, CancellationToken ct) => + { + if (await catalog.GetGameAsync(id, ct) is null) return Results.NotFound(); + if (!runtime.Configured) + return Results.Problem(title: $"Embedded {player.Emulator} player unavailable", + detail: $"Enable the optional isolated {player.Emulator} deployment profile.", + statusCode: StatusCodes.Status503ServiceUnavailable); + + // A capture taken while a title runs sees whatever the emulator has flushed. That is + // usually the last in-game save, but it can be a partial write, so the revision says so + // rather than presenting every capture as equally trustworthy. + var running = (await runtime.Control.GetStatusAsync(ct)).Running; + + await using var archive = await runtime.Control.DownloadSaveDataAsync(ct); + if (archive is null) + return Results.Problem(title: $"{player.Emulator} save data is unavailable", + detail: $"The isolated {player.Emulator} runtime did not return its save directory, or its configured save directories are still empty.", + statusCode: StatusCodes.Status503ServiceUnavailable); + + var entryId = BrowserGameDataPolicy.StableEntryId(id, player.Key, GameDataKind.Save); + var existing = await store.GetGameDataAsync(entryId, ct); + if (existing is not null && existing.Entry.GameId != id) return Results.Conflict(); + var uploaded = await files.SaveAsync(id, entryId, Guid.NewGuid(), + $"{player.Key}-{DateTimeOffset.UtcNow:yyyyMMdd-HHmmss}.tar.gz", archive, null, + $"{player.Key}-player", ct, SaveDataSourceType, player.Key); + try + { + var saved = await store.SaveGameDataRevisionAsync(id, entryId, + new(GameDataKind.Save, entryName, player.Emulator, "remote-player", + running + ? $"Captured from the isolated {player.Emulator} runtime while a title was running." + : $"Captured from the isolated {player.Emulator} runtime."), + existing?.Entry.Version ?? 0, uploaded, $"{player.Key}-player", ct); + if (saved.CurrentRevision.Id != uploaded.Id) files.Delete(uploaded); + if (app.Logger.IsEnabled(LogLevel.Information)) + app.Logger.LogInformation("Captured {Emulator} save data revision {Sequence} for game {GameId}", + player.Emulator, saved.CurrentRevision.Sequence, id); + return Results.Ok(new + { + saved.Entry.Id, + saved.Entry.Version, + saved.Entry.RevisionCount, + saved.CurrentRevision.Sequence, + saved.CurrentRevision.Length, + saved.CurrentRevision.Sha256, + capturedWhileRunning = running + }); + } + catch + { + files.Delete(uploaded); + throw; + } + }); + + api.MapPost($"/games/{{id:guid}}/{player.Key}-save-data/restore", async (Guid id, Guid? revisionId, + IGameDataVaultStore store, GameDataVaultFileStore files, CancellationToken ct) => + { + if (!runtime.Configured) + return Results.Problem(title: $"Embedded {player.Emulator} player unavailable", + detail: $"Enable the optional isolated {player.Emulator} deployment profile.", + statusCode: StatusCodes.Status503ServiceUnavailable); + + var entryId = BrowserGameDataPolicy.StableEntryId(id, player.Key, GameDataKind.Save); + var entry = await store.GetGameDataAsync(entryId, ct); + if (entry is null || entry.Entry.GameId != id) return Results.NotFound(); + + var revision = revisionId is null ? entry.CurrentRevision + : await store.GetGameDataRevisionAsync(revisionId.Value, ct); + // A revision from another entry must never be pushed into this player's save directory. + if (revision is null || revision.EntryId != entryId) return Results.NotFound(); + + var stored = await files.OpenAsync(revision, ct); + if (stored is null) + return Results.Problem(title: "Stored save data is missing", + detail: "The vault no longer holds the payload for this revision.", + statusCode: StatusCodes.Status410Gone); + + await using var content = stored.Content; + if (!await runtime.Control.UploadSaveDataAsync(content, stored.Length, ct)) + return Results.Problem(title: $"{player.Emulator} save data could not be restored", + detail: $"The isolated {player.Emulator} runtime rejected the stored revision.", + statusCode: StatusCodes.Status503ServiceUnavailable); + + if (app.Logger.IsEnabled(LogLevel.Information)) + app.Logger.LogInformation("Restored {Emulator} save data revision {Sequence} for game {GameId}", + player.Emulator, revision.Sequence, id); + return Results.Ok(new { revision.Id, revision.Sequence, revision.Sha256, revision.Length }); + }); + } + + /// <summary> + /// A player is only usable when it is configured <em>and</em> its sidecar answers a live probe. + /// Configuration alone proves intent, not that the container is up. + /// </summary> + private static async Task<(bool Ready, bool AutomaticRestore)> ProbeAsync( + NativeRemotePlayerRuntime runtime, CancellationToken ct) + { + if (!runtime.Configured) return (false, false); + var status = await runtime.Control.GetStatusAsync(ct); + return (status.Reachable, status.Reachable && status.SaveDataSupported); + } + + private static void MapCapabilities(RouteGroupBuilder api, NativeRemotePlayerRuntime runtime) + { + var player = runtime.Player; + + api.MapGet($"/games/{{id:guid}}/{player.Key}-play-capability", + async (Guid id, NativeLaunchResolvers resolvers, CancellationToken ct) => + { + var (ready, automaticRestore) = await ProbeAsync(runtime, ct); + return Results.Ok(await resolvers[player.Key] + .EvaluateAsync(id, ready, automaticRestore, DateTimeOffset.UtcNow, ct)); + }); + + api.MapGet($"/games/{player.Key}-play-capabilities", + async (string? ids, NativeLaunchResolvers resolvers, CancellationToken ct) => + { + if (!TryParseBatch(ids, out var gameIds)) + return Results.BadRequest(new { message = $"Supply between 1 and {MaximumCapabilityBatch} valid game ids." }); + var (ready, automaticRestore) = await ProbeAsync(runtime, ct); + var checkedAt = DateTimeOffset.UtcNow; + var resolver = resolvers[player.Key]; + var capabilities = await Task.WhenAll(gameIds.Select(gameId => + resolver.EvaluateAsync(gameId, ready, automaticRestore, checkedAt, ct))); + return Results.Ok(capabilities); + }); + } + + private static void MapSessions(WebApplication app, RouteGroupBuilder api, NativeRemotePlayerRuntime runtime) + { + var player = runtime.Player; + + api.MapPost($"/games/{{id:guid}}/{player.SessionRoute}", async (Guid id, HttpContext context, + ILibraryExperienceStore experience, NativeLaunchResolvers resolvers, CancellationToken ct) => + { + if (!runtime.Configured) + return Results.Problem(title: $"Embedded {player.Emulator} player unavailable", + detail: $"Enable the optional isolated {player.Emulator} deployment profile and complete its runtime gate.", + statusCode: StatusCodes.Status503ServiceUnavailable); + + var resolution = await resolvers[player.Key].ResolveAsync(id, runtime.Control, ct); + if (resolution.Rejected) + return resolution.RejectStatus == StatusCodes.Status400BadRequest + ? Results.BadRequest(new { message = resolution.RejectDetail }) + : Results.Problem(title: resolution.RejectTitle, detail: resolution.RejectDetail, + statusCode: resolution.RejectStatus); + + try + { + var ticket = runtime.Sessions.Create(id, runtime.Proxy.BuildLaunchUrl(context.Request), + runtime.Proxy.BuildExternalUrl(context.Request), resolution.Platform); + try + { + await runtime.Control.LaunchAsync(resolution.RuntimePath!, ct); + } + catch + { + runtime.Sessions.End(ticket.Session.Id); + throw; + } + context.Response.Cookies.Append(player.CookieName, ticket.Token, + runtime.Sessions.Cookie(ticket.Session.ExpiresAt, + runtime.Proxy.SecureCookies(context.Request), + runtime.Proxy.CookieDomain(context.Request))); + if (app.Logger.IsEnabled(LogLevel.Information)) + app.Logger.LogInformation("Embedded {Emulator} player session {SessionId} started for game {GameId}", + player.Emulator, ticket.Session.Id, id); + try { await experience.RecordGamePlayedAsync(id, "local-admin", ct); } + catch (Exception exception) + { + app.Logger.LogWarning(exception, "Could not record {Emulator} play history for game {GameId}", + player.Emulator, id); + } + return Results.Created($"/api/v1/{player.SessionRoute}/{ticket.Session.Id}", ticket.Session); + } + catch (NativeRemotePlayerCapacityException exception) + { + return Results.Problem(title: $"{player.Emulator} player capacity reached", + detail: exception.Message, statusCode: StatusCodes.Status429TooManyRequests); + } + catch (NativeRemotePlayerControlException exception) + { + return Results.Problem(title: $"{player.Emulator} title could not start", + detail: exception.Message, statusCode: StatusCodes.Status503ServiceUnavailable); + } + }); + + api.MapPost($"/{player.SessionRoute}/{{id:guid}}/actions", + async (Guid id, NativeRemotePlayerAction input, CancellationToken ct) => + { + if (runtime.Sessions.Get(id) is null) return Results.NotFound(); + var action = input.Action?.Trim().ToLowerInvariant(); + if (action is null || !player.Actions.Contains(action)) + return Results.BadRequest(new { message = $"Unsupported {player.Emulator} player action." }); + try + { + await runtime.Control.ActionAsync(action, ct); + return Results.Accepted(); + } + catch (NativeRemotePlayerControlException exception) + { + return Results.Problem(title: $"{player.Emulator} player control failed", + detail: exception.Message, statusCode: StatusCodes.Status503ServiceUnavailable); + } + }); + + // This route is protected by the administrator bearer token. The player capability cookie is + // intentionally scoped to the proxy path and therefore cannot authorize an API request. + api.MapDelete($"/{player.SessionRoute}/{{id:guid}}", async (Guid id, HttpContext context, + CancellationToken ct) => + { + var ended = runtime.Sessions.End(id); + context.Response.Cookies.Delete(player.CookieName, + runtime.Sessions.ExpiredCookie(runtime.Proxy.SecureCookies(context.Request), + runtime.Proxy.CookieDomain(context.Request))); + // Revoking the capability must also end the title. A running emulator that outlives its + // session keeps a render device and the disc image busy with no way left to reach it. + if (ended && !runtime.Sessions.HasActiveSession) + { + var stopped = await runtime.Control.TryStopAsync(ct); + if (!stopped && app.Logger.IsEnabled(LogLevel.Warning)) + app.Logger.LogWarning("Could not stop the {Emulator} title after session {SessionId} ended", + player.Emulator, id); + } + if (ended && app.Logger.IsEnabled(LogLevel.Information)) + app.Logger.LogInformation("Embedded {Emulator} player session {SessionId} ended", + player.Emulator, id); + return ended ? Results.NoContent() : Results.NotFound(); + }); + } + + private static void MapProxy(WebApplication app, NativeRemotePlayerRuntime runtime) + { + var player = runtime.Player; + app.Map($"{player.ProxyPath}/{{**catchAll}}", async (HttpContext context) => + { + if (!runtime.Proxy.Configured) + { + context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable; + await context.Response.WriteAsync($"The embedded {player.Emulator} player is not configured.", + context.RequestAborted); + return; + } + if (!runtime.Proxy.AcceptsProxyRequest(context.Request)) + { + context.Response.StatusCode = StatusCodes.Status404NotFound; + return; + } + if (!runtime.Sessions.Authorizes(context.Request.Cookies[player.CookieName])) + { + context.Response.StatusCode = StatusCodes.Status401Unauthorized; + context.Response.ContentType = "text/plain; charset=utf-8"; + await context.Response.WriteAsync( + $"This {player.Emulator} player session is missing, expired or closed.", context.RequestAborted); + return; + } + var error = await runtime.Forwarder.SendAsync(context, runtime.Proxy); + if (error != ForwarderError.None && !context.Response.HasStarted) + { + context.Response.StatusCode = StatusCodes.Status502BadGateway; + await context.Response.WriteAsync($"The isolated {player.Emulator} runtime could not be reached.", + context.RequestAborted); + } + }); + } + + private static bool TryParseBatch(string? ids, out Guid[] gameIds) + { + var requested = (ids ?? string.Empty) + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (requested.Length is 0 or > MaximumCapabilityBatch || requested.Any(value => !Guid.TryParse(value, out _))) + { + gameIds = []; + return false; + } + gameIds = requested.Select(Guid.Parse).Distinct().ToArray(); + return true; + } +} diff --git a/src/Ludarium.Api/NativeRemotePlayerRuntime.cs b/src/Ludarium.Api/NativeRemotePlayerRuntime.cs new file mode 100644 index 0000000..4e4f5a2 --- /dev/null +++ b/src/Ludarium.Api/NativeRemotePlayerRuntime.cs @@ -0,0 +1,482 @@ +using System.Collections.Concurrent; +using System.Net; +using System.Net.Http.Headers; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Ludarium.Application; +using Yarp.ReverseProxy.Forwarder; + +namespace Ludarium.Api; + +/// <summary> +/// The transport every isolated native remote player shares: configuration, capability-scoped +/// sessions, the authenticated exact-title control client and the certificate-pinned proxy. +/// </summary> +/// <remarks> +/// This used to exist once per emulator. That is how the live Eden display fix never reached +/// Dolphin, and it is why adding 3DS, Xbox, Wii U or Vita meant copying a vertical slice. A player +/// is now described by <see cref="NativeRemotePlayer"/> and everything below is shared. +/// </remarks> +public sealed record NativeRemotePlayerSessionDescriptor(Guid Id, Guid GameId, string Player, string Emulator, + string? Platform, string LaunchUrl, string? ExternalUrl, DateTimeOffset CreatedAt, DateTimeOffset ExpiresAt, + bool ExactGameLaunch, string SaveMode); + +public sealed record NativeRemotePlayerSessionTicket(NativeRemotePlayerSessionDescriptor Session, string Token); + +public sealed class NativeRemotePlayerCapacityException(NativeRemotePlayer player) + : Exception($"The isolated {player.Emulator} player is already in use."); + +public sealed class NativeRemotePlayerControlException(NativeRemotePlayer player) + : Exception($"The isolated {player.Emulator} controller rejected the requested operation."); + +public sealed class NativeRemotePlayerProxyOptions +{ + public NativeRemotePlayer Player { get; } + public Uri? Destination { get; } + public Uri? ExternalPlayer { get; } + public Uri? PublicOrigin { get; } + public string? Username { get; } + public string? Password { get; } + public byte[]? CertificateSha256 { get; } + public int PublicPort { get; } + + public bool Configured => Destination is not null && !string.IsNullOrWhiteSpace(Username) && + !string.IsNullOrWhiteSpace(Password) && CertificateSha256 is { Length: 32 } && + PublicPort is > 0 and <= 65535; + + public NativeRemotePlayerProxyOptions(NativeRemotePlayer player, Uri? destination, Uri? externalPlayer, + string? username, string? password, int? publicPort = null, string? certificateSha256 = null, + Uri? publicOrigin = null) + { + Player = player; + // Only the sidecar's own internal name is ever a valid destination: the proxy must never be + // pointed at an arbitrary host by configuration. + Destination = destination is { Scheme: "https", UserInfo.Length: 0, Query.Length: 0, Fragment.Length: 0, AbsolutePath: "/" } && + string.Equals(destination.Host, player.ProxyHost, StringComparison.OrdinalIgnoreCase) ? destination : null; + ExternalPlayer = externalPlayer is { UserInfo.Length: 0 } && externalPlayer.Scheme is "http" or "https" + ? externalPlayer : null; + PublicOrigin = publicOrigin is { Scheme: "https", UserInfo.Length: 0, Query.Length: 0, Fragment.Length: 0, AbsolutePath: "/" } + ? publicOrigin : null; + Username = string.IsNullOrWhiteSpace(username) ? null : username; + Password = string.IsNullOrWhiteSpace(password) ? null : password; + PublicPort = publicPort ?? player.DefaultPublicPort; + CertificateSha256 = ParseSha256(certificateSha256); + } + + public static NativeRemotePlayerProxyOptions FromEnvironment(NativeRemotePlayer player) + { + string? Value(string name) => Environment.GetEnvironmentVariable($"{player.EnvironmentPrefix}_{name}"); + _ = Uri.TryCreate(Value("PROXY_URL"), UriKind.Absolute, out var destination); + _ = Uri.TryCreate(Value("PLAYER_URL"), UriKind.Absolute, out var external); + _ = Uri.TryCreate(Value("EMBED_ORIGIN"), UriKind.Absolute, out var origin); + var port = int.TryParse(Value("EMBED_PORT"), out var parsed) ? parsed : player.DefaultPublicPort; + return new(player, destination, external, Value("PROXY_USERNAME"), Value("PROXY_PASSWORD"), port, + Value("PROXY_CERT_SHA256"), origin); + } + + public string BuildLaunchUrl(HttpRequest request) + { + if (!Configured) throw new InvalidOperationException($"The embedded {Player.Emulator} player is not configured."); + if (PublicOrigin is not null) + { + if (!IsChildHost(PublicOrigin.Host, request.Host.Host)) + throw new InvalidOperationException($"The {Player.Emulator} player origin must be a subdomain of the Ludarium request host."); + return new Uri(PublicOrigin, Player.ProxyPath + "/").ToString(); + } + return new UriBuilder(request.Scheme, request.Host.Host, PublicPort, Player.ProxyPath + "/").Uri.ToString(); + } + + public string? BuildExternalUrl(HttpRequest request) => + PublicOrigin is null ? ExternalPlayer?.ToString() : BuildLaunchUrl(request); + + public string? CookieDomain(HttpRequest request) => PublicOrigin is null ? null : + IsChildHost(PublicOrigin.Host, request.Host.Host) ? request.Host.Host : + throw new InvalidOperationException($"The {Player.Emulator} player origin must be a subdomain of the Ludarium request host."); + + public bool AcceptsProxyRequest(HttpRequest request) => PublicOrigin is null || + string.Equals(request.Host.Host, PublicOrigin.Host, StringComparison.OrdinalIgnoreCase); + + public bool SecureCookies(HttpRequest request) => + request.IsHttps || PublicOrigin?.Scheme == Uri.UriSchemeHttps; + + private static bool IsChildHost(string child, string parent) => + child.EndsWith('.' + parent, StringComparison.OrdinalIgnoreCase); + + private static byte[]? ParseSha256(string? value) + { + if (string.IsNullOrWhiteSpace(value) || value.Length != 64) return null; + try { return Convert.FromHexString(value); } + catch (FormatException) { return null; } + } +} + +public sealed class NativeRemotePlayerSessionStore(NativeRemotePlayer player, TimeProvider timeProvider) +{ + public static readonly TimeSpan SessionLifetime = TimeSpan.FromHours(8); + private readonly ConcurrentDictionary<Guid, Entry> sessions = new(); + private readonly object gate = new(); + + public NativeRemotePlayer Player { get; } = player; + public string CookieName => Player.CookieName; + public string ProxyPath => Player.ProxyPath; + + public NativeRemotePlayerSessionTicket Create(Guid gameId, string launchUrl, string? externalUrl, + string? platform = null, bool exactGameLaunch = true) + { + lock (gate) + { + RemoveExpired(); + if (sessions.Count >= Player.MaximumSessions) throw new NativeRemotePlayerCapacityException(Player); + var now = timeProvider.GetUtcNow(); + var token = Base64Url(RandomNumberGenerator.GetBytes(32)); + var descriptor = new NativeRemotePlayerSessionDescriptor(Guid.NewGuid(), gameId, Player.Key, + Player.Emulator, platform, launchUrl, externalUrl, now, now.Add(SessionLifetime), + exactGameLaunch, Player.SaveMode); + sessions[descriptor.Id] = new Entry(descriptor, SHA256.HashData(Encoding.UTF8.GetBytes(token))); + return new(descriptor, token); + } + } + + public bool Authorizes(string? token) + { + if (string.IsNullOrWhiteSpace(token)) return false; + var supplied = SHA256.HashData(Encoding.UTF8.GetBytes(token)); + var now = timeProvider.GetUtcNow(); + foreach (var entry in sessions.Values) + if (entry.Descriptor.ExpiresAt > now && + CryptographicOperations.FixedTimeEquals(supplied, entry.TokenHash)) + return true; + return false; + } + + public NativeRemotePlayerSessionDescriptor? Get(Guid id) => sessions.TryGetValue(id, out var entry) && + entry.Descriptor.ExpiresAt > timeProvider.GetUtcNow() ? entry.Descriptor : null; + + public bool End(Guid id) => sessions.TryRemove(id, out _); + + /// <summary>True while any capability this store issued is still live.</summary> + public bool HasActiveSession + { + get + { + var now = timeProvider.GetUtcNow(); + return sessions.Values.Any(entry => entry.Descriptor.ExpiresAt > now); + } + } + + public CookieOptions Cookie(DateTimeOffset expires, bool secure, string? domain = null) => new() + { + HttpOnly = true, + IsEssential = true, + Path = ProxyPath, + SameSite = SameSiteMode.Strict, + Secure = secure, + Domain = domain, + Expires = expires + }; + + public CookieOptions ExpiredCookie(bool secure, string? domain = null) => + Cookie(DateTimeOffset.UnixEpoch, secure, domain); + + private void RemoveExpired() + { + var now = timeProvider.GetUtcNow(); + foreach (var entry in sessions) + if (entry.Value.Descriptor.ExpiresAt <= now) sessions.TryRemove(entry.Key, out _); + } + + private static string Base64Url(byte[] value) => + Convert.ToBase64String(value).TrimEnd('=').Replace('+', '-').Replace('/', '_'); + + private sealed record Entry(NativeRemotePlayerSessionDescriptor Descriptor, byte[] TokenHash); +} + +/// <summary>Live state reported by a sidecar's own control endpoint.</summary> +public sealed record NativeRemotePlayerRuntimeStatus(bool Reachable, bool Running, string? SaveMode, + bool SaveDataSupported = false); + +public sealed class NativeRemotePlayerControlClient +{ + private readonly HttpClient client; + private readonly string? token; + private readonly TimeProvider time; + private readonly Lock probeGate = new(); + private NativeRemotePlayerRuntimeStatus? cachedStatus; + private DateTimeOffset cachedAt = DateTimeOffset.MinValue; + + /// <summary>How long a live probe result is reused, so capability reads stay cheap.</summary> + public static readonly TimeSpan HealthCacheLifetime = TimeSpan.FromSeconds(10); + + /// <summary> + /// A readiness probe must never hold up a catalog page. A sidecar that accepts the connection but + /// does not answer would otherwise stall every capability read for the full control timeout. + /// </summary> + public static readonly TimeSpan HealthProbeTimeout = TimeSpan.FromSeconds(2); + + public NativeRemotePlayer Player { get; } + public IReadOnlyDictionary<string, string> CatalogPrefixes { get; } + public string? QualifiedFixtureSha256 { get; } + + public bool Configured => + client.BaseAddress is { Scheme: "http", AbsolutePath: "/" } address && + string.Equals(address.Host, Player.ControlHost, StringComparison.OrdinalIgnoreCase) && + token is not null && (!Player.RequiresFixtureDigest || QualifiedFixtureSha256 is not null); + + public NativeRemotePlayerControlClient(NativeRemotePlayer player, HttpClient client, string? token, + IReadOnlyDictionary<string, string> catalogPrefixes, string? fixtureSha256 = null, + TimeProvider? timeProvider = null) + { + Player = player; + this.client = client; + this.token = string.IsNullOrWhiteSpace(token) ? null : token; + this.time = timeProvider ?? TimeProvider.System; + CatalogPrefixes = catalogPrefixes; + QualifiedFixtureSha256 = IsValidSha256(fixtureSha256) ? fixtureSha256!.ToLowerInvariant() : null; + } + + public static NativeRemotePlayerControlClient FromEnvironment(NativeRemotePlayer player) + { + string? Value(string name) => Environment.GetEnvironmentVariable($"{player.EnvironmentPrefix}_{name}"); + _ = Uri.TryCreate(Value("CONTROL_URL"), UriKind.Absolute, out var address); + var prefixes = player.Platforms.ToDictionary( + platform => platform, + platform => Value(CatalogPrefixVariable(player, platform)) ?? $"roms/{platform}", + StringComparer.OrdinalIgnoreCase); + return new(player, new HttpClient { BaseAddress = address, Timeout = TimeSpan.FromSeconds(15) }, + Value("CONTROL_TOKEN"), prefixes, Value("FIXTURE_SHA256")); + } + + /// <summary> + /// A single-platform player keeps the flat variable it already ships with; a multi-platform + /// player names its platform, matching the deployed Dolphin configuration. + /// </summary> + public static string CatalogPrefixVariable(NativeRemotePlayer player, string platform) => + player.Platforms.Count == 1 ? "CATALOG_PREFIX" : $"{platform.ToUpperInvariant()}_CATALOG_PREFIX"; + + public static bool IsValidSha256(string? value) => value is { Length: 64 } && value.All(Uri.IsHexDigit); + + public string CatalogPrefix(string platform) => CatalogPrefixes.TryGetValue(platform, out var prefix) + ? prefix + : throw new InvalidOperationException($"No catalog prefix is configured for {platform}."); + + public Task LaunchAsync(string path, CancellationToken ct) => SendAsync("v1/launch", new { path }, ct); + + public Task ActionAsync(string action, CancellationToken ct) => SendAsync("v1/action", new { action }, ct); + + /// <summary>Best-effort stop used when a player session ends, so no title outlives its capability.</summary> + public async Task<bool> TryStopAsync(CancellationToken ct) + { + if (!Configured) return false; + try { await ActionAsync("stop", ct); return true; } + catch (NativeRemotePlayerControlException) { return false; } + catch (HttpRequestException) { return false; } + catch (TaskCanceledException) { return false; } + } + + /// <summary> + /// Ask the sidecar whether it is actually alive. Configuration alone only proves an operator + /// intended a runtime; it does not prove the container is up, so a capability that claims + /// availability must be backed by a recent live answer. + /// </summary> + public async Task<NativeRemotePlayerRuntimeStatus> GetStatusAsync(CancellationToken ct) + { + if (!Configured) return new(false, false, null); + var now = time.GetUtcNow(); + lock (probeGate) + if (cachedStatus is not null && now - cachedAt < HealthCacheLifetime) return cachedStatus; + + NativeRemotePlayerRuntimeStatus status; + using var probe = CancellationTokenSource.CreateLinkedTokenSource(ct); + probe.CancelAfter(HealthProbeTimeout); + try + { + using var request = new HttpRequestMessage(HttpMethod.Get, "v1/status"); + request.Headers.Add("X-Ludarium-Control-Token", token); + using var response = await client.SendAsync(request, probe.Token); + status = response.IsSuccessStatusCode + ? Parse(await response.Content.ReadAsStringAsync(probe.Token)) + : new(false, false, null); + } + catch (Exception exception) when (exception is HttpRequestException or OperationCanceledException or JsonException) + { + // A caller-cancelled request must not be cached as an unreachable runtime. + if (ct.IsCancellationRequested) throw; + status = new(false, false, null); + } + + lock (probeGate) + { + cachedStatus = status; + cachedAt = now; + } + return status; + + static NativeRemotePlayerRuntimeStatus Parse(string body) + { + using var document = JsonDocument.Parse(body); + var root = document.RootElement; + var running = root.TryGetProperty("running", out var value) && value.ValueKind == JsonValueKind.True; + var saveMode = root.TryGetProperty("saveMode", out var mode) ? mode.GetString() : null; + var saveData = root.TryGetProperty("saveData", out var data) && data.ValueKind == JsonValueKind.True; + return new(true, running, saveMode, saveData); + } + } + + /// <summary> + /// Read the sidecar's current app-owned save directory as one bounded archive. + /// </summary> + /// <remarks> + /// Native player saves live inside the sidecar's own volume, which the Ludarium container cannot + /// read. Moving them over the existing authenticated control channel is what lets them become + /// versioned, SHA-256 evidenced vault revisions like browser saves, instead of an opaque blob + /// with no export and no restore point. + /// </remarks> + public async Task<Stream?> DownloadSaveDataAsync(CancellationToken ct) + { + if (!Configured) return null; + using var request = new HttpRequestMessage(HttpMethod.Get, "v1/save-data"); + request.Headers.Add("X-Ludarium-Control-Token", token); + var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct); + if (!response.IsSuccessStatusCode) + { + response.Dispose(); + return null; + } + return await response.Content.ReadAsStreamAsync(ct); + } + + /// <summary>Push a stored revision back into the sidecar's save directory.</summary> + public async Task<bool> UploadSaveDataAsync(Stream content, long length, CancellationToken ct) + { + if (!Configured) return false; + using var request = new HttpRequestMessage(HttpMethod.Post, "v1/save-data") + { Content = new StreamContent(content) }; + request.Content.Headers.ContentLength = length; + request.Content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/gzip"); + request.Headers.Add("X-Ludarium-Control-Token", token); + using var response = await client.SendAsync(request, ct); + return response.IsSuccessStatusCode; + } + + private async Task SendAsync(string path, object body, CancellationToken ct) + { + if (!Configured) throw new InvalidOperationException($"The isolated {Player.Emulator} controller is not configured."); + using var request = new HttpRequestMessage(HttpMethod.Post, path) + { + Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json") + }; + request.Headers.Add("X-Ludarium-Control-Token", token); + using var response = await client.SendAsync(request, ct); + if (!response.IsSuccessStatusCode) throw new NativeRemotePlayerControlException(Player); + } +} + +public sealed class NativeRemotePlayerForwarder : IDisposable +{ + private static readonly ForwarderRequestConfig RequestConfig = new() { ActivityTimeout = TimeSpan.FromHours(12) }; + private readonly IHttpForwarder forwarder; + private readonly HttpMessageInvoker client; + private readonly Transformer transformer; + + public NativeRemotePlayerForwarder(IHttpForwarder forwarder, NativeRemotePlayerProxyOptions options) + { + this.forwarder = forwarder; + client = new HttpMessageInvoker(new SocketsHttpHandler + { + AllowAutoRedirect = false, + AutomaticDecompression = DecompressionMethods.None, + EnableMultipleHttp2Connections = true, + UseCookies = false, + UseProxy = false, + SslOptions = new System.Net.Security.SslClientAuthenticationOptions + { + RemoteCertificateValidationCallback = (_, certificate, _, _) => certificate is not null && + options.CertificateSha256 is not null && + CryptographicOperations.FixedTimeEquals( + certificate.GetCertHash(HashAlgorithmName.SHA256), options.CertificateSha256) + } + }); + transformer = new(options.Username, options.Password); + } + + public ValueTask<ForwarderError> SendAsync(HttpContext context, NativeRemotePlayerProxyOptions options) => + forwarder.SendAsync(context, options.Destination!.ToString(), client, RequestConfig, transformer); + + public void Dispose() => client.Dispose(); + + private sealed class Transformer : HttpTransformer + { + private readonly AuthenticationHeaderValue authorization; + + public Transformer(string? username, string? password) => authorization = new("Basic", + Convert.ToBase64String(Encoding.UTF8.GetBytes($"{username}:{password}"))); + + public override async ValueTask TransformRequestAsync(HttpContext context, HttpRequestMessage request, + string destinationPrefix, CancellationToken ct) + { + await base.TransformRequestAsync(context, request, destinationPrefix, ct); + request.Headers.Host = null; + request.Headers.Remove("Cookie"); + request.Headers.Authorization = authorization; + } + + public override async ValueTask<bool> TransformResponseAsync(HttpContext context, + HttpResponseMessage? response, CancellationToken ct) + { + var body = await base.TransformResponseAsync(context, response, ct); + context.Response.Headers.Remove("WWW-Authenticate"); + context.Response.Headers.Remove("X-Frame-Options"); + context.Response.Headers["X-Content-Type-Options"] = "nosniff"; + return body; + } + } +} + +/// <summary>Everything one configured native remote player needs at request time.</summary> +public sealed class NativeRemotePlayerRuntime( + NativeRemotePlayer player, + NativeRemotePlayerProxyOptions proxy, + NativeRemotePlayerSessionStore sessions, + NativeRemotePlayerControlClient control, + Func<NativeRemotePlayerProxyOptions, NativeRemotePlayerForwarder> forwarderFactory) : IDisposable +{ + private readonly Lock gate = new(); + private NativeRemotePlayerForwarder? forwarder; + + public NativeRemotePlayer Player { get; } = player; + public NativeRemotePlayerProxyOptions Proxy { get; } = proxy; + public NativeRemotePlayerSessionStore Sessions { get; } = sessions; + public NativeRemotePlayerControlClient Control { get; } = control; + + /// <summary>Configuration is complete for both the embedded proxy and exact-title control.</summary> + public bool Configured => Proxy.Configured && Control.Configured; + + public NativeRemotePlayerForwarder Forwarder + { + get + { + lock (gate) return forwarder ??= forwarderFactory(Proxy); + } + } + + public void Dispose() => forwarder?.Dispose(); +} + +/// <summary>All native remote players this deployment knows about, configured or not.</summary> +public sealed class NativeRemotePlayerRuntimes(IReadOnlyList<NativeRemotePlayerRuntime> runtimes) : IDisposable +{ + public IReadOnlyList<NativeRemotePlayerRuntime> All { get; } = runtimes; + + public NativeRemotePlayerRuntime this[string key] => + All.FirstOrDefault(runtime => runtime.Player.Key.Equals(key, StringComparison.OrdinalIgnoreCase)) + ?? throw new KeyNotFoundException($"No native remote player is registered for '{key}'."); + + public NativeRemotePlayerRuntime? ForPlatform(string? platform) => + platform is null ? null : All.FirstOrDefault(runtime => runtime.Player.Owns(platform)); + + public void Dispose() + { + foreach (var runtime in All) runtime.Dispose(); + } +} diff --git a/src/Ludarium.Api/Program.cs b/src/Ludarium.Api/Program.cs new file mode 100644 index 0000000..24ac97c --- /dev/null +++ b/src/Ludarium.Api/Program.cs @@ -0,0 +1,1309 @@ +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading.RateLimiting; +using Ludarium.Application; +using Ludarium.Domain; +using Ludarium.Infrastructure; +using Ludarium.Api; +using Yarp.ReverseProxy.Forwarder; + +var builder = WebApplication.CreateBuilder(args); +builder.Logging.ClearProviders(); +builder.Logging.AddJsonConsole(options => options.IncludeScopes = true); +builder.Services.AddExceptionHandler<ApiExceptionHandler>(); +builder.Services.AddProblemDetails(); +builder.Services.ConfigureHttpJsonOptions(options => options.SerializerOptions.Converters.Add(new JsonStringEnumConverter())); +builder.Services.AddOpenApi(); +builder.Services.AddHealthChecks(); +builder.Services.AddRateLimiter(options => +{ + options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; + options.OnRejected = async (context, cancellationToken) => + { + context.HttpContext.Response.Headers.RetryAfter = "60"; + await context.HttpContext.Response.WriteAsJsonAsync(new { title = "Request rate limit reached", status = 429 }, cancellationToken); + }; + options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(context => + { + var address = context.Connection.RemoteIpAddress?.ToString() ?? "local"; + var profile = ApiRateLimits.For(context.Request.Method, context.Request.Path); + return RateLimitPartition.GetFixedWindowLimiter($"{address}:{profile.Partition}", _ => new FixedWindowRateLimiterOptions + { PermitLimit = profile.PermitLimit, Window = profile.Window, QueueLimit = 0 }); + }); +}); +var connection = builder.Configuration.GetConnectionString("Ludarium") ?? builder.Configuration["LUDARIUM_DATABASE:CONNECTIONSTRING"]; +if (string.IsNullOrWhiteSpace(connection) && builder.Environment.IsDevelopment()) + connection = "Host=localhost;Database=ludarium;Username=ludarium;Password=ludarium"; +if (string.IsNullOrWhiteSpace(connection)) + throw new InvalidOperationException("A Ludarium PostgreSQL connection string is required."); +builder.Services.AddLudariumInfrastructure(connection, builder.Configuration["LUDARIUM_EXPORTS"]); +builder.Services.AddHostedService<ScanWorker>(); +builder.Services.AddHttpForwarder(); +// Every isolated native player is built from its registry description, so adding one is a +// registry entry plus a launch resolver rather than another copy of this whole vertical slice. +builder.Services.AddSingleton(provider => new NativeRemotePlayerRuntimes( + NativeRemotePlayerRegistry.All.Select(player => new NativeRemotePlayerRuntime( + player, + NativeRemotePlayerProxyOptions.FromEnvironment(player), + new NativeRemotePlayerSessionStore(player, TimeProvider.System), + NativeRemotePlayerControlClient.FromEnvironment(player), + options => new NativeRemotePlayerForwarder(provider.GetRequiredService<IHttpForwarder>(), options))) + .ToArray())); +builder.Services.AddSingleton<INativeLaunchResolver, SwitchLaunchResolver>(); +builder.Services.AddSingleton<INativeLaunchResolver, DolphinLaunchResolver>(); +builder.Services.AddSingleton<NativeLaunchResolvers>(); + +var configuredAdminToken = builder.Configuration["LUDARIUM_ADMIN_TOKEN"]; +if (!builder.Environment.IsDevelopment() && !AdminTokenPolicy.IsAcceptableConfiguration(configuredAdminToken)) + throw new InvalidOperationException("LUDARIUM_ADMIN_TOKEN must be a non-placeholder secret of at least 32 characters."); +var app = builder.Build(); +app.UseExceptionHandler(); +app.UseWebSockets(); +app.UseRateLimiter(); +app.Use(async (context, next) => +{ + if (context.Request.Path.Equals("/player.html") || + context.Request.Path.Equals("/ps2-player.html") || + context.Request.Path.Equals("/n64-player.html") || + context.Request.Path.StartsWithSegments("/playjs")) + { + context.Response.Headers["Cross-Origin-Opener-Policy"] = "same-origin"; + context.Response.Headers["Cross-Origin-Embedder-Policy"] = "require-corp"; + context.Response.Headers["Origin-Agent-Cluster"] = "?1"; + } + if (context.Request.Path.StartsWithSegments("/emulatorjs") || + context.Request.Path.StartsWithSegments("/playjs") || + context.Request.Path.StartsWithSegments("/n64wasm") || + context.Request.Path.Equals("/player.js") || + context.Request.Path.Equals("/ps2-player.js") || + context.Request.Path.Equals("/n64-player.js") || + context.Request.Path.StartsWithSegments("/api/v1/play-sessions")) + context.Response.Headers["Cross-Origin-Resource-Policy"] = "same-origin"; + if (NativeRemotePlayerRegistry.All.Any(player => context.Request.Path.StartsWithSegments(player.ProxyPath))) + context.Response.Headers["Cross-Origin-Resource-Policy"] = "same-site"; + var correlation = context.Request.Headers["X-Correlation-ID"].FirstOrDefault() ?? Guid.NewGuid().ToString("N"); + context.Response.Headers["X-Correlation-ID"] = correlation; + using var scope = app.Logger.BeginScope(new Dictionary<string, object> { ["CorrelationId"] = correlation }); + var started = System.Diagnostics.Stopwatch.GetTimestamp(); + var scopedPlayerRead = AdminTokenPolicy.IsScopedPlayerRead(context.Request.Path.Value); + if (!string.IsNullOrEmpty(configuredAdminToken) && context.Request.Path.StartsWithSegments("/api") && !scopedPlayerRead && + !AdminTokenPolicy.Authorizes(context.Request.Headers.Authorization.ToString(), configuredAdminToken)) + { + context.Response.StatusCode = StatusCodes.Status401Unauthorized; + await context.Response.WriteAsJsonAsync(new { title = "Authentication required", status = 401 }); + } + else await next(); + // A body the framework could not bind is answered with 400 and nothing else: no field name, no + // hint at what was expected, while every refusal Ludarium writes itself carries a message. Only + // an answer that wrote nothing at all is filled in here. + if (context.Response is { StatusCode: StatusCodes.Status400BadRequest, HasStarted: false } && + context.Request.Path.StartsWithSegments("/api")) + await context.Response.WriteAsJsonAsync(new + { + title = "The request could not be read", + status = 400, + message = "A field is missing, has the wrong type, or names a value this API does not define. " + + "Compare the request with the contract at /api/v1/openapi/v1.json.", + }); + var routineArtworkRead = HttpMethods.IsGet(context.Request.Method) && context.Request.Path.Value?.Contains("/artwork", StringComparison.OrdinalIgnoreCase) == true && context.Response.StatusCode < 400; + var routineNativePlayerRead = context.Response.StatusCode < 400 && + NativeRemotePlayerRegistry.All.Any(player => context.Request.Path.StartsWithSegments(player.ProxyPath)); + if (!routineArtworkRead && !routineNativePlayerRead && app.Logger.IsEnabled(LogLevel.Information)) + app.Logger.LogInformation("HTTP {Method} {Path} completed {StatusCode} in {ElapsedMilliseconds} ms", + context.Request.Method, context.Request.Path.Value, context.Response.StatusCode, + System.Diagnostics.Stopwatch.GetElapsedTime(started).TotalMilliseconds); +}); +app.MapOpenApi("/api/v1/openapi/{documentName}.json"); +app.MapHealthChecks("/health/live"); +app.MapGet("/health/ready", async (ILudariumStore store, CancellationToken ct) => +{ + try + { + var schemaVersion = await store.GetSchemaVersionAsync(ct); + return schemaVersion == PostgresStore.CurrentSchemaVersion + ? Results.Ok(new { status = "ready" }) + : Results.Problem(title: "Database is not ready", detail: "The database schema is not current.", statusCode: 503); + } + catch (Exception) { return Results.Problem(title: "Database is not ready", detail: "The database connection is unavailable.", statusCode: 503); } +}); + +var api = app.MapGroup("/api/v1"); +var automaticBrowserRestoreCores = BrowserGameDataPolicy.AutomaticRestoreCores; +static object FirmwareDto(FirmwareAsset asset) => new +{ + asset.Id, + asset.Platform, + asset.Slot, + asset.Kind, + asset.FileName, + asset.Length, + sha256Prefix = asset.Sha256[..12], + asset.Selected, + asset.CreatedAt, + asset.UpdatedAt, + asset.Version +}; +api.MapGet("/system/capabilities", (IGameDiscoveryService discovery, IGameProviderHub providers) => Results.Ok(new +{ + offlineCore = true, + telemetry = false, + sourceMutation = false, + executableLaunch = false, + browserPlay = new + { + provider = "Ludarium EmulatorJS", + bundled = true, + perGameDetection = true, + sourceStreaming = true, + explicitActionRequired = true, + releaseQualified = BrowserPlayPolicy.DescribePlatforms().Where(platform => platform.ReleaseQualified) + .Select(platform => new { platform = platform.Platform, core = platform.Core, extensions = platform.Extensions.Order().ToArray() }), + bundledNotReleaseQualified = BrowserPlayPolicy.DescribePlatforms().Where(platform => !platform.ReleaseQualified) + .Select(platform => new { platform = platform.Platform, core = platform.Core, extensions = platform.Extensions.Order().ToArray() }), + // Container formats Ludarium can deliver but has not proven in the real core. They are named + // so the interface can explain them exactly, and they stay unavailable until a fixture passes. + deliverableNotReleaseQualified = BrowserPlayPolicy.DescribePlatforms() + .Where(platform => platform.CandidateExtensions is { Count: > 0 }) + .Select(platform => new { platform = platform.Platform, core = platform.Core, extensions = platform.CandidateExtensions!.Order().ToArray() }) + }, + collections = true, + smartCollections = true, + tags = true, + personalGameState = true, + gameRelationships = true, + richMedia = new { externalHttps = true, appOwnedUploads = true, sourceLibraryMutation = false }, + gameDataVault = new { saves = true, saveStates = true, immutableRevisions = true, automaticBrowserRestore = automaticBrowserRestoreCores, nativeRemotePlayerCapture = NativeRemotePlayerRegistry.All.Select(player => player.Key).ToArray(), maximumUploadBytes = GameDataVaultFileStore.MaximumBytes, sourceLibrarySync = false }, + cheats = new + { + automaticExactTitleMatching = true, + provider = GameCheatCatalog.Source, + version = GameCheatCatalog.Version, + license = "CC-BY-SA-4.0", + browserPlatforms = GameCheatCatalog.BrowserPlatforms, + activationRequiresPlayerAction = true, + nativeRemotePlayers = false + }, + achievements = providers.DescribeProviders().Any(provider => provider.Capabilities.Contains("achievements")), + metadataProviders = providers.DescribeProviders(), + wishlist = new + { + onlineDiscovery = discovery.Configured, + discoveryProvider = discovery.Configured ? "RAWG" : null, + manualPriceTracking = true, + automaticRetailPrices = false, + localArtworkUpload = true, + scanReconciliation = true + }, + formats = new[] + { + new { id="ines", level="header-mapper-layout", supported=true, version="2" }, new { id="snes", level="checksum-header-metadata", supported=true, version="1" }, + new { id="gb-gbc", level="logo-header-metadata", supported=true, version="1" }, new { id="gba", level="logo-header-metadata", supported=true, version="2" }, + new { id="nds", level="logo-header-metadata", supported=true, version="1" }, new { id="n64", level="byte-order-header-metadata", supported=true, version="1" }, + new { id="sega-cartridge", level="header-metadata", supported=true, version="1" }, + new { id="cue", level="references", supported=true, version="1" }, new { id="m3u", level="ordered-references", supported=true, version="1" }, + new { id="iso9660-playstation", level="root-directory-system-config", supported=true, version="1" }, + new { id="psp-cso", level="container-signature", supported=true, version="1" }, + new { id="switch-xci-pfs0", level="container-signature", supported=true, version="1" }, + new { id="pe", level="headers-architecture-version-resources", supported=true, version="2" }, new { id="msi", level="summary-property-media-file-relations", supported=true, version="2" }, + new { id="zip", level="bounded-central-directory", supported=true, version="1" }, + new { id="7z", level="bounded-header-enumeration", supported=true, version="1" } + } +})); +api.MapGet("/system/remote-players", async (IFirmwareStore firmware, SwitchRuntimeProvisioner switchRuntime, + NativeRemotePlayerRuntimes runtimes, CancellationToken ct) => +{ + var prodKeys = await firmware.GetSelectedFirmwareAssetAsync("switch", "prod-keys", ct); + var titleKeys = await firmware.GetSelectedFirmwareAssetAsync("switch", "title-keys", ct); + var players = new Dictionary<string, object>(StringComparer.OrdinalIgnoreCase); + foreach (var runtime in runtimes.All) + { + var player = runtime.Player; + // Switch is the only player that additionally needs operator-supplied keys and a + // provisioned key root before it can start anything. + var provisioned = player.Key != "switch" || switchRuntime.Configured; + var keysReady = player.Key != "switch" || prodKeys is not null; + var status = runtime.Configured && provisioned + ? await runtime.Control.GetStatusAsync(ct) + : new NativeRemotePlayerRuntimeStatus(false, false, null); + players[player.Key] = new + { + key = player.Key, + configured = runtime.Configured && provisioned, + reachable = status.Reachable, + running = status.Running, + url = runtime.Proxy.ExternalPlayer?.ToString(), + emulator = player.Emulator, + platforms = player.Platforms, + sourceLibraryReadOnly = true, + exactGameLaunch = runtime.Control.Configured, + embeddedAvailable = runtime.Configured && provisioned, + supportsSavestates = player.SupportsSavestates, + automaticRestore = status.Reachable && status.SaveDataSupported, + saveMode = player.SaveMode, + prodKeysConfigured = player.Key == "switch" ? prodKeys is not null : (bool?)null, + titleKeysConfigured = player.Key == "switch" ? titleKeys is not null : (bool?)null, + message = !runtime.Proxy.Configured || !provisioned + ? $"Enable the optional isolated {player.Emulator} deployment profile for {string.Join(" and ", player.Platforms)} remote play." + : !keysReady + ? "Upload your own prod.keys in Settings before opening the isolated Switch player." + : !runtime.Control.Configured + ? $"The embedded player is ready, but exact-title control is not configured. Complete the deterministic {player.Emulator} runtime gate." + : !status.Reachable + ? $"The isolated {player.Emulator} runtime is configured but is not answering its control endpoint." + : $"Linked titles launch directly in isolated {player.Emulator}; native saves persist in app-owned storage." + }; + } + return Results.Ok(players); +}); +app.MapNativeRemotePlayers(api); + +api.MapGet("/system/status", async (ILudariumStore store, CancellationToken ct) => +{ + var summary = await store.GetDashboardSummaryAsync(ct); + return Results.Ok(new + { + version = ReleaseIdentity.Version, + schemaVersion = PostgresStore.CurrentSchemaVersion, + summary.Libraries, + summary.Artifacts, + summary.PresentArtifacts, + summary.Games, + summary.Releases, + summary.Bundles, + summary.TotalBytes, + summary.HashedArtifacts, + summary.RecognizedArtifacts, + summary.UnknownArtifacts, + summary.MissingArtifacts, + summary.DuplicateArtifacts, + summary.DuplicateBytes, + summary.OpenReviews, + summary.OpenReviewRecords, + summary.ArtworkNeedingReview, + summary.CriticalFindings, + summary.ActiveScans, + summary.RootsRequiringAttention, + summary.ExcludedSupportGames, + summary.UnresolvedGameCandidates, + summary.LastFullScanAt, + summary.LastIncrementalScanAt, + summary.CatalogImportedWithoutScan, + summary.ConfidencePercent + }); +}); +api.MapGet("/system/metrics", async (ILudariumStore store, CancellationToken ct) => +{ + var jobsTask = store.ListJobsAsync(ct); + var scansTask = store.ListScansAsync(ct); + var reviewTask = store.ListReviewGroupsAsync(ReviewState.Open, ct); + var artworkTask = store.ListArtworkNeedingReviewAsync(ct); + var providersTask = store.ListProviderSnapshotsAsync(ct); + await Task.WhenAll(jobsTask, scansTask, reviewTask, artworkTask, providersTask); + var jobs = await jobsTask; var scans = await scansTask; var reviews = await reviewTask; + var artwork = await artworkTask; var providers = await providersTask; + var completedScans = scans.Where(scan => scan.State == ScanState.Completed && scan.StartedAt is not null && scan.FinishedAt is not null).ToArray(); + return Results.Ok(new + { + capturedAt = DateTimeOffset.UtcNow, + jobs = new { queued = jobs.Count(job => job.State == JobState.Queued), active = jobs.Count(job => job.State == JobState.Leased), failed = jobs.Count(job => job.State is JobState.Failed or JobState.DeadLetter), total = jobs.Count }, + scans = new { active = scans.Count(scan => scan.State is ScanState.Discovering or ScanState.Processing or ScanState.Verifying), failed = scans.Count(scan => scan.State == ScanState.Failed), averageItemsPerSecond = completedScans.Length == 0 ? 0 : Math.Round(completedScans.Average(scan => scan.ItemsCompleted / Math.Max(1, (scan.FinishedAt!.Value - scan.StartedAt!.Value).TotalSeconds)), 2) }, + review = new { groups = reviews.Count, items = reviews.Sum(group => group.Count), estimatedBytes = reviews.Sum(group => group.EstimatedBytes) }, + artwork = new { needingReview = artwork.Count, averageQuality = artwork.Count == 0 ? 100 : Math.Round(artwork.Average(item => item.QualityScore), 2) }, + providers = new { configured = providers.Count, degraded = providers.Count(provider => provider.State is "Degraded" or "Offline" || provider.ExpiresAt <= DateTimeOffset.UtcNow), lastSuccessfulSync = providers.Where(provider => provider.LastSuccessfulSyncAt is not null).Select(provider => provider.LastSuccessfulSyncAt).OrderDescending().FirstOrDefault() } + }); +}); +api.MapGet("/system/storage", (ILudariumStore store, CancellationToken ct) => store.GetStorageSummaryAsync(ct)); +api.MapGet("/system/health", (ILudariumStore store, CancellationToken ct) => store.GetHealthSummaryAsync(ct)); +api.MapGet("/system/playability", (ILudariumStore store, CancellationToken ct) => store.GetPlayabilitySummaryAsync(ct)); +api.MapGet("/system/settings", (ILudariumStore store, CancellationToken ct) => store.GetOperationalSettingsAsync(ct)); +api.MapGet("/system/cheats", (GameCheatCatalog cheats, CancellationToken ct) => cheats.StatusAsync(ct)); +api.MapPost("/system/cheats/refresh", (GameCheatCatalog cheats, CancellationToken ct) => cheats.RefreshAsync(ct)); +api.MapPut("/system/settings", async (UpdateOperationalSettings request, ILudariumStore store, CancellationToken ct) => +{ + try + { + var settings = new OperationalSettings(request.ReviewRetentionDays, request.AuditRetentionDays, request.ExportRetentionDays, + request.DefaultPageSize, request.DefaultScanMode, request.ExpectedVersion, DateTimeOffset.UtcNow); + return Results.Ok(await store.SaveOperationalSettingsAsync(settings, request.ExpectedVersion, ct)); + } + catch (ConcurrencyException exception) { return Results.Conflict(new { title = "Settings changed", detail = exception.Message, status = 409 }); } +}); + +api.MapGet("/libraries", (ILudariumStore store, CancellationToken ct) => store.ListLibrariesAsync(ct)); +api.MapGet("/libraries/health", (ILudariumStore store, CancellationToken ct) => store.ListLibraryHealthAsync(ct)); +api.MapPost("/libraries", async (CreateLibrary request, ILudariumStore store, CancellationToken ct) => +{ + var root = LibraryRoot.Create(request.Name, request.Path, request.Kind, request.Recursive, request.HashPolicy, request.InspectArchives, request.MaxConcurrency); + await store.UpsertLibraryAsync(root, ct); return Results.Created($"/api/v1/libraries/{root.Id}", root); +}); +api.MapGet("/libraries/{id:guid}", async (Guid id, ILudariumStore store, CancellationToken ct) => + await store.GetLibraryAsync(id, ct) is { } root ? Results.Ok(root) : Results.NotFound()); +api.MapPatch("/libraries/{id:guid}", async (Guid id, UpdateLibrary request, ILudariumStore store, CancellationToken ct) => +{ + var root = await store.GetLibraryAsync(id, ct); if (root is null) return Results.NotFound(); + if (root.Version != request.ExpectedVersion) return Results.Conflict(new { title = "The library changed after it was loaded." }); + var updated = root with + { + Name = request.Name.Trim(), + Enabled = request.Enabled, + HashPolicy = request.HashPolicy, + InspectArchives = request.InspectArchives, + MaxConcurrency = Math.Clamp(request.MaxConcurrency, 1, 8), + Version = root.Version + 1 + }; + await store.UpsertLibraryAsync(updated, ct); return Results.Ok(updated); +}); +api.MapPost("/libraries/{id:guid}/verify", async (Guid id, ILudariumStore store, IReadOnlyLibraryFileSystem fs, CancellationToken ct) => +{ + var root = await store.GetLibraryAsync(id, ct); if (root is null) return Results.NotFound(); + var result = fs.Verify(root.Path); var updated = root with { IsAvailable = result.Available, IsReadOnly = result.IsReadOnly, HasEntries = result.HasEntries, LastVerifiedAt = DateTimeOffset.UtcNow, Version = root.Version + 1 }; + await store.UpsertLibraryAsync(updated, ct); return Results.Ok(new { library = updated, verification = result }); +}); +api.MapPost("/libraries/{id:guid}/scans", async (Guid id, StartScan request, HttpContext http, IScanCoordinator scans, CancellationToken ct) => +{ + if (!ScanRequests.TryParseMode(request.Mode, out var mode)) + return Results.BadRequest(new { message = ScanRequests.UnknownModeMessage }); + var scan = await scans.RequestAsync(id, mode, http.Request.Headers["Idempotency-Key"].FirstOrDefault(), ct); + return Results.Accepted($"/api/v1/scans/{scan.Id}", scan); +}); +api.MapGet("/scans", (ILudariumStore store, CancellationToken ct) => store.ListScansAsync(ct)); +api.MapGet("/scans/{id:guid}", async (Guid id, ILudariumStore store, CancellationToken ct) => await store.GetScanAsync(id, ct) is { } scan ? Results.Ok(scan) : Results.NotFound()); +api.MapPost("/scans/{id:guid}/cancel", async (Guid id, IScanCoordinator scans, CancellationToken ct) => { await scans.CancelAsync(id, ct); return Results.Accepted(); }); +api.MapPost("/scans/{id:guid}/pause", async (Guid id, ILudariumStore store, CancellationToken ct) => +{ + var scan = await store.GetScanAsync(id, ct); if (scan is null) return Results.NotFound(); + if (scan.State is ScanState.Discovering or ScanState.Processing) await store.UpdateScanAsync(scan with { State = ScanState.Paused, Stage = "Paused" }, ct); + return Results.Accepted(); +}); +api.MapPost("/scans/{id:guid}/resume", async (Guid id, ILudariumStore store, CancellationToken ct) => +{ + var scan = await store.GetScanAsync(id, ct); if (scan is null) return Results.NotFound(); + if (scan.State == ScanState.Paused) await store.UpdateScanAsync(scan with { State = ScanState.Discovering, Stage = "Resuming discovery" }, ct); + return Results.Accepted(); +}); +api.MapPost("/scans/{id:guid}/retry", async (Guid id, ILudariumStore store, IScanCoordinator scans, CancellationToken ct) => +{ + var scan = await store.GetScanAsync(id, ct); if (scan is null) return Results.NotFound(); + if (scan.State is not (ScanState.Failed or ScanState.Cancelled)) return Results.Conflict(new { title = "Only failed or cancelled scans can be retried." }); + var retry = await scans.RequestAsync(scan.LibraryId, scan.Mode, $"retry:{id:N}:{DateTimeOffset.UtcNow:O}", ct); + return Results.Accepted($"/api/v1/scans/{retry.Id}", retry); +}); +api.MapGet("/artifacts", (string? q, int? page, int? pageSize, ILudariumStore store, CancellationToken ct) => store.SearchArtifactsAsync(q, page ?? 1, pageSize ?? 50, ct)); +api.MapGet("/artifacts/{id:guid}", async (Guid id, ILudariumStore store, CancellationToken ct) => await store.GetArtifactDetailsAsync(id, ct) is { } details ? Results.Ok(details) : Results.NotFound()); +api.MapGet("/artifacts/duplicates", (ILudariumStore store, CancellationToken ct) => store.GetDuplicatesAsync(ct)); +api.MapGet("/artifacts/{id:guid}/archive-members", (Guid id, ILudariumStore store, CancellationToken ct) => store.ListArchiveMembersAsync(id, ct)); +api.MapGet("/bundles", (ILudariumStore store, CancellationToken ct) => store.ListBundlesAsync(ct)); +api.MapGet("/review-items", (ReviewState? state, string? reason, int? page, int? pageSize, ILudariumStore store, CancellationToken ct) => store.SearchReviewsAsync(state, reason, page ?? 1, pageSize ?? 50, ct)); +api.MapGet("/review-groups", (ReviewState? state, ILudariumStore store, CancellationToken ct) => store.ListReviewGroupsAsync(state ?? ReviewState.Open, ct)); +api.MapGet("/review-group-items", (ReviewState? state, string? category, string? q, string? library, + string? platform, Severity? severity, DateTimeOffset? since, ReviewGroupOrder? order, + int? page, int? pageSize, ILudariumStore store, CancellationToken ct) => + store.SearchReviewGroupsAsync(new ReviewGroupQuery + { + State = state ?? ReviewState.Open, + Category = category, + Search = q, + Library = library, + Platform = platform, + Severity = severity, + Since = since, + Order = order ?? ReviewGroupOrder.Recommended, + Page = page ?? 1, + PageSize = pageSize ?? 50 + }, ct)); +api.MapPost("/review-groups/resolve", async (ResolveReviewGroup request, ILudariumStore store, CancellationToken ct) => +{ + try { return Results.Ok(await store.ResolveReviewGroupAsync(request.Ids, request.ExpectedCount, request.Resolution, "local-admin", ct)); } + catch (ConcurrencyException exception) { return Results.Conflict(new { title = exception.Message }); } +}); +api.MapPost("/review-groups/defer", async (ResolveReviewGroup request, ILudariumStore store, CancellationToken ct) => +{ + try { return Results.Ok(await store.DeferReviewGroupAsync(request.Ids, request.ExpectedCount, request.Resolution, "local-admin", ct)); } + catch (ConcurrencyException exception) { return Results.Conflict(new { title = exception.Message }); } +}); +api.MapPost("/review-groups/reopen", async (ReopenReviewGroup request, ILudariumStore store, CancellationToken ct) => +{ + try { return Results.Ok(await store.ReopenReviewGroupAsync(request.Ids, request.ExpectedCount, request.SourceState, request.Resolution, "local-admin", ct)); } + catch (ConcurrencyException exception) { return Results.Conflict(new { title = exception.Message }); } + catch (ArgumentException exception) { return Results.BadRequest(new { title = exception.Message }); } +}); +api.MapPost("/review-groups/apply", async (ApplyReviewGroupAction request, ILudariumStore store, CancellationToken ct) => +{ + try { return Results.Ok(await store.ApplyReviewGroupActionAsync(request.Ids, request.ExpectedCount, request.Action, request.Platform, request.PathPattern, "local-admin", ct)); } + catch (ConcurrencyException exception) { return Results.Conflict(new { title = exception.Message }); } + catch (ArgumentException exception) { return Results.BadRequest(new { title = exception.Message }); } +}); +api.MapPost("/review-operations/{id:guid}/undo", async (Guid id, ILudariumStore store, CancellationToken ct) => { await store.UndoReviewOperationAsync(id, "local-admin", ct); return Results.NoContent(); }); +api.MapPost("/review-items/{id:guid}/resolve", async (Guid id, ResolveReview request, ILudariumStore store, CancellationToken ct) => +{ + try { return Results.Ok(await store.ResolveReviewAsync(id, request.Resolution, "local-admin", ct)); } + catch (ConcurrencyException exception) { return Results.Conflict(new { title = exception.Message }); } + catch (ArgumentException exception) { return Results.BadRequest(new { title = exception.Message }); } +}); +api.MapPost("/review-items/{id:guid}/defer", async (Guid id, ILudariumStore store, CancellationToken ct) => +{ + try { return Results.Ok(await store.SetReviewStateAsync(id, ReviewState.Deferred, "local-admin", ct)); } + catch (ConcurrencyException exception) { return Results.Conflict(new { title = exception.Message }); } +}); +api.MapPost("/review-items/{id:guid}/reopen", async (Guid id, ILudariumStore store, CancellationToken ct) => +{ + try { return Results.Ok(await store.SetReviewStateAsync(id, ReviewState.Open, "local-admin", ct)); } + catch (ConcurrencyException exception) { return Results.Conflict(new { title = exception.Message }); } +}); +api.MapPost("/resolutions/{id:guid}/undo", async (Guid id, ILudariumStore store, CancellationToken ct) => { await store.UndoResolutionAsync(id, "local-admin", ct); return Results.Ok(); }); +api.MapGet("/games", (string? q, string? platform, string? sort, bool? favorite, GamePlayStatus? status, + Guid? collectionId, string? tag, int? minimumRating, bool? playable, int? page, int? pageSize, + ILudariumStore store, CancellationToken ct) => + store.SearchGamesAsync(new GameQuery + { + Search = q, + Platform = platform, + Sort = sort, + Favorite = favorite, + Status = status, + CollectionId = collectionId, + Tag = tag, + MinimumRating = minimumRating, + PlayableOnly = playable ?? false, + Page = page ?? 1, + PageSize = pageSize ?? 50 + }, ct)); +api.MapGet("/games/{id:guid}", async (Guid id, ILudariumStore store, CancellationToken ct) => + await store.GetGameAsync(id, ct) is { } game ? Results.Ok(game) : Results.NotFound()); +api.MapGet("/games/recently-played", (int? limit, ILibraryExperienceStore store, CancellationToken ct) => + store.ListRecentlyPlayedGamesAsync(limit ?? 6, ct)); +api.MapGet("/wishlist", (string? q, string? platform, WishlistPriority? priority, WishlistStatus? status, string? sort, int? page, int? pageSize, ILudariumStore store, CancellationToken ct) => + store.SearchWishlistAsync(q, platform, priority, status, sort, page ?? 1, pageSize ?? 50, ct)); +api.MapGet("/wishlist/summary", (ILudariumStore store, CancellationToken ct) => store.GetWishlistSummaryAsync(ct)); +api.MapGet("/wishlist/discover", (string q, IGameDiscoveryService discovery, CancellationToken ct) => discovery.SearchAsync(q, ct)); +api.MapPost("/wishlist", async (WishlistInput request, ILudariumStore store, CancellationToken ct) => +{ + try { var item = await store.CreateWishlistItemAsync(request, "local-admin", ct); return Results.Created($"/api/v1/wishlist/{item.Id}", item); } + catch (ResourceConflictException ex) { return Results.Conflict(new { title = ex.Message }); } + catch (ArgumentException ex) { return Results.BadRequest(new { title = ex.Message }); } +}); +api.MapPut("/wishlist/{id:guid}", async (Guid id, UpdateWishlistItem request, ILudariumStore store, CancellationToken ct) => +{ + try { return Results.Ok(await store.UpdateWishlistItemAsync(id, request.Item, request.ExpectedVersion, "local-admin", ct)); } + catch (KeyNotFoundException) { return Results.NotFound(); } + catch (ResourceConflictException ex) { return Results.Conflict(new { title = ex.Message }); } + catch (ConcurrencyException ex) { return Results.Conflict(new { title = ex.Message }); } + catch (ArgumentException ex) { return Results.BadRequest(new { title = ex.Message }); } +}); +api.MapGet("/wishlist/{id:guid}/artwork", async (Guid id, GameArtworkStore artwork, CancellationToken ct) => await artwork.OpenAsync(id, ct) is { } stored ? Results.Stream(stored.Content, stored.ContentType) : Results.NoContent()); +api.MapPut("/wishlist/{id:guid}/artwork", async (Guid id, HttpRequest request, GameArtworkStore artwork, CancellationToken ct) => +{ + try + { + var store = request.HttpContext.RequestServices.GetRequiredService<ILudariumStore>(); + var exists = (await store.SearchWishlistAsync(null, null, null, null, null, 1, 10_000, ct)).Items.Any(item => item.Id == id); + if (!exists) return Results.NotFound(); + return Results.Ok(new { contentType = await artwork.SaveAsync(id, request.Body, request.ContentLength, ct) }); + } + catch (ArtworkValidationException ex) { return Results.BadRequest(new { title = ex.Message }); } +}); +api.MapDelete("/wishlist/{id:guid}/artwork", (Guid id, GameArtworkStore artwork) => artwork.Delete(id) ? Results.NoContent() : Results.NotFound()); +api.MapGet("/wishlist/export", async (string? format, ILudariumStore store, CancellationToken ct) => +{ + var items = (await store.SearchWishlistAsync(null, null, null, null, "title", 1, 10000, ct)).Items; + if (format?.Equals("csv", StringComparison.OrdinalIgnoreCase) == true) + { + static string Csv(string? value) => $"\"{(value ?? "").Replace("\"", "\"\"")}\""; + var lines = items.Select(x => $"{Csv(x.Title)},{Csv(x.Platform)},{Csv(x.Status.ToString())},{Csv(x.Priority.ToString())},{x.DesiredPrice?.ToString(System.Globalization.CultureInfo.InvariantCulture)},{x.CurrentPrice?.ToString(System.Globalization.CultureInfo.InvariantCulture)},{Csv(x.Currency)},{Csv(x.StoreName)},{Csv(x.Edition)},{Csv(x.Region)},{Csv(x.Notes)}") + .Prepend("title,platform,status,priority,desiredPrice,currentPrice,currency,store,edition,region,notes"); + return Results.Text(string.Join('\n', lines), "text/csv"); + } + return Results.Json(items); +}); +api.MapPost("/wishlist/import", async (string? format, HttpRequest request, ILudariumStore store, CancellationToken ct) => +{ + const int maximumCharacters = 1_000_000; + if (request.ContentLength is > maximumCharacters) return Results.Problem(title: "Wishlist import is too large", detail: "Text imports are limited to 1 MB.", statusCode: 413); + using var reader = new StreamReader(request.Body); + var buffer = new char[8192]; var imported = new System.Text.StringBuilder(); + while (true) + { + var read = await reader.ReadAsync(buffer.AsMemory(0, Math.Min(buffer.Length, maximumCharacters + 1 - imported.Length)), ct); + if (read == 0) break; + imported.Append(buffer, 0, read); + if (imported.Length > maximumCharacters) return Results.Problem(title: "Wishlist import is too large", detail: "Text imports are limited to 1 MB.", statusCode: 413); + } + var parsed = Program.ParseWishlistImport(imported.ToString(), format ?? request.ContentType); + var created = 0; var skipped = 0; + foreach (var item in parsed.Take(1000)) + { + try { await store.CreateWishlistItemAsync(item, "wishlist-import", ct); created++; } + catch (ResourceConflictException) { skipped++; } + } + return Results.Ok(new { created, skipped, truncated = parsed.Count > 1000 }); +}); +api.MapDelete("/wishlist/{id:guid}", async (Guid id, long expectedVersion, ILudariumStore store, GameArtworkStore artwork, CancellationToken ct) => +{ + try { await store.DeleteWishlistItemAsync(id, expectedVersion, "local-admin", ct); artwork.Delete(id); return Results.NoContent(); } + catch (KeyNotFoundException) { return Results.NotFound(); } + catch (ConcurrencyException ex) { return Results.Conflict(new { title = ex.Message }); } +}); +api.MapGet("/platforms", (ILudariumStore store, CancellationToken ct) => store.ListPlatformsAsync(ct)); +api.MapGet("/platform-definitions", (ILibraryExperienceStore store, CancellationToken ct) => store.ListPlatformDefinitionsAsync(ct)); +api.MapPost("/platform-definitions", async (PlatformDefinitionInput request, ILibraryExperienceStore store, CancellationToken ct) => +{ + var created = await store.CreatePlatformDefinitionAsync(request, "local-admin", ct); + return Results.Created($"/api/v1/platform-definitions/{created.Id}", created); +}); +api.MapPut("/platform-definitions/{id}", (string id, UpdatePlatformDefinition request, ILibraryExperienceStore store, CancellationToken ct) => + store.UpdatePlatformDefinitionAsync(id, request.Platform, request.ExpectedVersion, "local-admin", ct)); +api.MapGet("/metadata/providers", (IGameProviderHub providers) => Results.Ok(providers.DescribeProviders())); +api.MapPost("/metadata/providers/{providerId}/validate", (string providerId, IGameProviderHub providers, + CancellationToken ct) => providers.ValidateAsync(providerId, ct)); + +api.MapGet("/collections", (ILibraryExperienceStore store, CancellationToken ct) => store.ListCollectionsAsync(ct)); +api.MapGet("/collections/{id:guid}", async (Guid id, ILibraryExperienceStore store, CancellationToken ct) => + await store.GetCollectionAsync(id, ct) is { } collection ? Results.Ok(collection) : Results.NotFound()); +api.MapPost("/collections", async (GameCollectionInput request, ILibraryExperienceStore store, CancellationToken ct) => +{ + var created = await store.CreateCollectionAsync(request, "local-admin", ct); + return Results.Created($"/api/v1/collections/{created.Id}", created); +}); +api.MapPut("/collections/{id:guid}", (Guid id, UpdateGameGroup request, ILibraryExperienceStore store, CancellationToken ct) => + store.UpdateCollectionAsync(id, request.Collection, request.ExpectedVersion, "local-admin", ct)); +api.MapDelete("/collections/{id:guid}", async (Guid id, long expectedVersion, ILibraryExperienceStore store, CancellationToken ct) => +{ + await store.DeleteCollectionAsync(id, expectedVersion, "local-admin", ct); + return Results.NoContent(); +}); +api.MapGet("/collections/{id:guid}/games", (Guid id, int? page, int? pageSize, ILibraryExperienceStore store, CancellationToken ct) => + store.SearchCollectionGamesAsync(id, page ?? 1, pageSize ?? 50, ct)); +api.MapPost("/collections/{id:guid}/games", async (Guid id, CollectionGame request, ILibraryExperienceStore store, CancellationToken ct) => +{ + await store.AddCollectionGameAsync(id, request.GameId, "local-admin", ct); + return Results.NoContent(); +}); +api.MapDelete("/collections/{id:guid}/games/{gameId:guid}", async (Guid id, Guid gameId, ILibraryExperienceStore store, CancellationToken ct) => +{ + await store.RemoveCollectionGameAsync(id, gameId, "local-admin", ct); + return Results.NoContent(); +}); +api.MapGet("/tags", (ILibraryExperienceStore store, CancellationToken ct) => store.ListTagsAsync(ct)); +api.MapPost("/games", async (CreateGame request, ILudariumStore store, CancellationToken ct) => Results.Created("/api/v1/games", await store.CreateGameAsync(request.Title, "local-admin", ct))); +api.MapPatch("/games/{id:guid}", async (Guid id, UpdateGame request, ILudariumStore store, CancellationToken ct) => +{ + try { return Results.Ok(await store.UpdateGameAsync(id, request.Title, request.ExpectedVersion, "local-admin", ct)); } + catch (ConcurrencyException ex) { return Results.Conflict(new { title = "The game changed", detail = ex.Message, status = 409 }); } +}); +api.MapDelete("/games/{id:guid}", async (Guid id, long expectedVersion, ILudariumStore store, + GameMediaStore mediaFiles, GameArtworkStore artworkFiles, GameDataVaultFileStore vaultFiles, + CancellationToken ct) => +{ + var deleted = await store.DeleteGameAsync(id, expectedVersion, "local-admin", ct); + foreach (var media in deleted.Media) mediaFiles.Delete(media); + vaultFiles.Delete(deleted.DataRevisions); + artworkFiles.DeleteAll(id); + return Results.NoContent(); +}); +api.MapGet("/games/{id:guid}/claims", (Guid id, ILudariumStore store, CancellationToken ct) => store.ListClaimsAsync("Game", id, ct)); +api.MapGet("/games/{id:guid}/tags", (Guid id, ILibraryExperienceStore store, CancellationToken ct) => store.ListGameTagsAsync(id, ct)); +api.MapPost("/games/{id:guid}/tags", (Guid id, TagInput request, ILibraryExperienceStore store, CancellationToken ct) => + store.AddGameTagAsync(id, request.Name, "local-admin", ct)); +api.MapDelete("/games/{id:guid}/tags/{tagId:guid}", async (Guid id, Guid tagId, ILibraryExperienceStore store, CancellationToken ct) => +{ + await store.DeleteGameTagAsync(id, tagId, "local-admin", ct); + return Results.NoContent(); +}); +api.MapGet("/games/{id:guid}/state", (Guid id, ILibraryExperienceStore store, CancellationToken ct) => store.GetGameUserStateAsync(id, ct)); +api.MapPut("/games/{id:guid}/state", (Guid id, UpdateGameUserState request, ILibraryExperienceStore store, CancellationToken ct) => + store.SaveGameUserStateAsync(id, request.State, request.ExpectedVersion, "local-admin", ct)); +api.MapGet("/games/{id:guid}/relations", (Guid id, ILibraryExperienceStore store, CancellationToken ct) => store.ListGameRelationsAsync(id, ct)); +api.MapPost("/games/{id:guid}/relations", (Guid id, GameRelationInput request, ILibraryExperienceStore store, CancellationToken ct) => + store.AddGameRelationAsync(id, request, "local-admin", ct)); +api.MapDelete("/games/{id:guid}/relations/{relationId:guid}", async (Guid id, Guid relationId, ILibraryExperienceStore store, CancellationToken ct) => +{ + await store.DeleteGameRelationAsync(id, relationId, "local-admin", ct); + return Results.NoContent(); +}); +api.MapGet("/games/{id:guid}/media", (Guid id, ILibraryExperienceStore store, CancellationToken ct) => store.ListGameMediaAsync(id, ct)); +api.MapPost("/games/{id:guid}/media/external", (Guid id, GameMediaInput request, ILibraryExperienceStore store, CancellationToken ct) => + store.AddExternalGameMediaAsync(id, request, "local-admin", ct)); +api.MapPut("/games/{id:guid}/media/{mediaId:guid}", async (Guid id, Guid mediaId, GameMediaKind kind, string title, + HttpRequest request, GameMediaStore mediaStore, ILibraryExperienceStore store, CancellationToken ct) => +{ + var uploaded = await mediaStore.SaveAsync(id, mediaId, kind, title, request.Body, request.ContentLength, ct); + var existing = await store.ListGameMediaAsync(id, ct); + uploaded = uploaded with { Selected = existing.All(item => item.Kind != kind || !item.Selected) }; + try { await store.UpsertGameMediaAsync(uploaded, ct); } + catch { mediaStore.Delete(uploaded); throw; } + return Results.Created($"/api/v1/games/{id}/media/{mediaId}", uploaded); +}); +api.MapGet("/games/{id:guid}/media/{mediaId:guid}/content", async (Guid id, Guid mediaId, + ILibraryExperienceStore store, GameMediaStore mediaStore, CancellationToken ct) => +{ + var media = await store.GetGameMediaAsync(mediaId, ct); + if (media is null || media.GameId != id) return Results.NotFound(); + if (await mediaStore.OpenAsync(media, ct) is not { } stored) return Results.NotFound(); + return Results.Stream(stored.Content, stored.ContentType, enableRangeProcessing: true, + entityTag: new Microsoft.Net.Http.Headers.EntityTagHeaderValue($"\"{media.Sha256}\"")); +}); +api.MapDelete("/games/{id:guid}/media/{mediaId:guid}", async (Guid id, Guid mediaId, + ILibraryExperienceStore store, GameMediaStore mediaStore, CancellationToken ct) => +{ + var media = await store.GetGameMediaAsync(mediaId, ct); + if (media is null || media.GameId != id) return Results.NotFound(); + await store.DeleteGameMediaAsync(id, mediaId, "local-admin", ct); + mediaStore.Delete(media); + return Results.NoContent(); +}); +api.MapGet("/games/{id:guid}/achievements", async (Guid id, ILibraryExperienceStore store, CancellationToken ct) => + await store.GetAchievementProgressAsync(id, ct) is { } progress ? Results.Ok(progress) : Results.NoContent()); +api.MapGet("/game-data/summary", (IGameDataVaultStore store, CancellationToken ct) => + store.GetGameDataSummaryAsync(null, ct)); +api.MapGet("/games/{id:guid}/data", (Guid id, IGameDataVaultStore store, CancellationToken ct) => + store.ListGameDataAsync(id, ct)); +api.MapGet("/games/{id:guid}/data/summary", (Guid id, IGameDataVaultStore store, CancellationToken ct) => + store.GetGameDataSummaryAsync(id, ct)); +api.MapGet("/games/{id:guid}/data/{entryId:guid}/revisions", async (Guid id, Guid entryId, + int? page, int? pageSize, IGameDataVaultStore store, CancellationToken ct) => +{ + var entry = await store.GetGameDataAsync(entryId, ct); + return entry is null || entry.Entry.GameId != id + ? Results.NotFound() + : Results.Ok(await store.ListGameDataRevisionsAsync(entryId, page ?? 1, pageSize ?? 25, ct)); +}); +api.MapPut("/games/{id:guid}/data/{entryId:guid}/revisions/{revisionId:guid}", async ( + Guid id, Guid entryId, Guid revisionId, HttpRequest request, + IGameDataVaultStore store, GameDataVaultFileStore files, CancellationToken ct) => +{ + var existing = await store.GetGameDataRevisionAsync(revisionId, ct); + GameDataItem? existingEntry = null; + if (existing is not null) + { + existingEntry = await store.GetGameDataAsync(existing.EntryId, ct); + if (existing.EntryId != entryId || existingEntry?.Entry.GameId != id) + return Results.Conflict(new { title = "The revision id is already in use." }); + } + var metadata = UploadGameData.Parse(request.Headers); + var uploaded = await files.SaveAsync(id, entryId, revisionId, metadata.FileName, request.Body, + request.ContentLength, "local-admin", ct); + if (existing is not null) + { + if (existing.Length != uploaded.Length || + !existing.Sha256.Equals(uploaded.Sha256, StringComparison.OrdinalIgnoreCase)) + { + files.Delete(uploaded); + throw new ResourceConflictException("The revision id is already used by different content."); + } + return Results.Ok(existingEntry); + } + try + { + var saved = await store.SaveGameDataRevisionAsync(id, entryId, + new(metadata.Kind, metadata.Name, metadata.Emulator, metadata.Device, metadata.Notes), + metadata.ExpectedVersion, uploaded, "local-admin", ct); + if (saved.CurrentRevision.Id != uploaded.Id) files.Delete(uploaded); + return Results.Created($"/api/v1/games/{id}/data/{entryId}", saved); + } + catch + { + files.Delete(uploaded); + throw; + } +}).WithMetadata(new Microsoft.AspNetCore.Mvc.RequestSizeLimitAttribute(GameDataVaultFileStore.MaximumBytes)); +api.MapPut("/games/{id:guid}/data/{entryId:guid}", (Guid id, Guid entryId, + UpdateGameData request, IGameDataVaultStore store, CancellationToken ct) => + store.UpdateGameDataAsync(id, entryId, request.Metadata, request.ExpectedVersion, "local-admin", ct)); +api.MapGet("/games/{id:guid}/data/{entryId:guid}/revisions/{revisionId:guid}/content", async ( + Guid id, Guid entryId, Guid revisionId, HttpContext context, IGameDataVaultStore store, + GameDataVaultFileStore files, CancellationToken ct) => +{ + var entry = await store.GetGameDataAsync(entryId, ct); + var revision = await store.GetGameDataRevisionAsync(revisionId, ct); + if (entry is null || entry.Entry.GameId != id || revision is null || revision.EntryId != entryId) + return Results.NotFound(); + if (await files.OpenAsync(revision, ct) is not { } stored) return Results.NotFound(); + context.Response.Headers.CacheControl = "private, no-store"; + context.Response.Headers.XContentTypeOptions = "nosniff"; + return Results.Stream(stored.Content, "application/octet-stream", revision.FileName, + entityTag: new Microsoft.Net.Http.Headers.EntityTagHeaderValue($"\"{revision.Sha256}\""), + enableRangeProcessing: true); +}); +api.MapDelete("/games/{id:guid}/data/{entryId:guid}", async (Guid id, Guid entryId, + long expectedVersion, IGameDataVaultStore store, GameDataVaultFileStore files, CancellationToken ct) => +{ + var revisions = await store.DeleteGameDataAsync(id, entryId, expectedVersion, "local-admin", ct); + files.Delete(revisions); + return Results.NoContent(); +}); +api.MapGet("/games/{id:guid}/play-capability", (Guid id, IBrowserPlayService service, CancellationToken ct) => + service.GetCapabilityAsync(id, ct)); +api.MapGet("/games/play-capabilities", async (string? ids, IBrowserPlayService service, CancellationToken ct) => +{ + var requested = (ids ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (requested.Length is 0 or > 50 || requested.Any(value => !Guid.TryParse(value, out _))) + return Results.BadRequest(new { message = "Supply between 1 and 50 valid game ids." }); + var gameIds = requested.Select(Guid.Parse).Distinct().ToArray(); + var capabilities = await Task.WhenAll(gameIds.Select(id => service.GetCapabilityAsync(id, ct))); + return Results.Ok(capabilities); +}); +api.MapGet("/firmware", async (IFirmwareStore store, CancellationToken ct) => +{ + var assets = await store.ListFirmwareAssetsAsync(ct); + return Results.Ok(FirmwarePolicy.Describe().Select(requirement => new + { + requirement.Platform, + requirement.Slot, + requirement.Kind, + requirement.Label, + requirement.RuntimeState, + requirement.BrowserDelivery, + requirement.MaximumBytes, + extensions = requirement.Extensions.Order().ToArray(), + asset = assets.FirstOrDefault(asset => asset.Selected && asset.Platform == requirement.Platform && asset.Slot == requirement.Slot) is { } selected + ? FirmwareDto(selected) : null + })); +}); +api.MapPut("/firmware/{platform}/{slot}", async (string platform, string slot, HttpRequest request, + IFirmwareStore store, FirmwareVaultFileStore files, SwitchRuntimeProvisioner switchRuntime, CancellationToken ct) => +{ + var requirement = FirmwarePolicy.Get(platform, slot); + if (!request.Headers.TryGetValue("X-Ludarium-Filename", out var names) || names.Count != 1) + throw new FirmwareValidationException("The X-Ludarium-Filename header is required."); + var previous = await store.GetSelectedFirmwareAssetAsync(requirement.Platform, requirement.Slot, ct); + var uploaded = await files.SaveAsync(requirement, Guid.NewGuid(), names[0]!, request.Body, request.ContentLength, ct); + try { await store.SaveFirmwareAssetAsync(uploaded, "local-admin", ct); } + catch { files.Delete(uploaded); throw; } + await switchRuntime.ProvisionAsync(uploaded, files, ct); + if (previous is not null) files.Delete(previous); + return Results.Ok(FirmwareDto(uploaded)); +}).WithMetadata(new Microsoft.AspNetCore.Mvc.DisableRequestSizeLimitAttribute()); +api.MapDelete("/firmware/{id:guid}", async (Guid id, long expectedVersion, IFirmwareStore store, + FirmwareVaultFileStore files, SwitchRuntimeProvisioner switchRuntime, CancellationToken ct) => +{ + var removed = await store.DeleteFirmwareAssetAsync(id, expectedVersion, "local-admin", ct); + foreach (var asset in removed) { switchRuntime.Remove(asset); files.Delete(asset); } + return Results.NoContent(); +}); +api.MapPost("/games/{id:guid}/play-sessions", async (Guid id, IBrowserPlayService service, + ILibraryExperienceStore experience, CancellationToken ct) => +{ + var launch = await service.StartAsync(id, "local-admin", ct); + try { await experience.RecordGamePlayedAsync(id, "local-admin", ct); } + catch (Exception exception) { app.Logger.LogWarning(exception, "Could not record browser play history for game {GameId}", id); } + return Results.Created($"/api/v1/play-sessions/{launch.Session.Id}", launch.Session); +}); +api.MapGet("/play-sessions/{id:guid}", async (Guid id, IBrowserPlayService service, CancellationToken ct) => + await service.GetSessionAsync(id, ct) is { } session ? Results.Ok(session) : Results.NotFound()); +api.MapGet("/play-sessions/{id:guid}/content", async (Guid id, string? token, HttpContext context, + IBrowserPlayService service, CancellationToken ct) => +{ + if (await service.OpenContentAsync(id, token ?? string.Empty, ct) is not { } source) + return Results.NotFound(); + context.Response.Headers.CacheControl = "private, no-store"; + context.Response.Headers.XContentTypeOptions = "nosniff"; + context.Response.Headers.ContentSecurityPolicy = "default-src 'none'; sandbox"; + return Results.Stream(source.Content, "application/octet-stream", source.FileName, + entityTag: string.IsNullOrWhiteSpace(source.Sha256) ? null : + new Microsoft.Net.Http.Headers.EntityTagHeaderValue($"\"{source.Sha256}\""), + enableRangeProcessing: true); +}); +api.MapGet("/play-sessions/{id:guid}/firmware", async (Guid id, string? token, HttpContext context, + IBrowserPlayService service, CancellationToken ct) => +{ + if (await service.OpenFirmwareAsync(id, token ?? string.Empty, ct) is not { } firmware) + return Results.NotFound(); + context.Response.Headers.CacheControl = "private, no-store"; + context.Response.Headers.XContentTypeOptions = "nosniff"; + context.Response.Headers.ContentSecurityPolicy = "default-src 'none'; sandbox"; + return Results.Stream(firmware.Content, "application/octet-stream", + entityTag: new Microsoft.Net.Http.Headers.EntityTagHeaderValue($"\"{firmware.Sha256}\""), + enableRangeProcessing: true); +}); +api.MapGet("/play-sessions/{id:guid}/cheats", async (Guid id, string? token, + IBrowserPlayService service, ILudariumStore store, GameCheatCatalog cheats, CancellationToken ct) => +{ + var session = await service.AuthorizeAsync(id, token ?? string.Empty, ct); + if (session is null) return Results.NotFound(); + var game = await store.GetGameAsync(session.GameId, ct); + if (game is null) return Results.NotFound(); + var result = await cheats.FindAsync(game.Title, session.Platform, ct); + return result.Available ? Results.Ok(result) : Results.NoContent(); +}); +api.MapGet("/play-sessions/{id:guid}/data/{kind}", async (Guid id, string kind, string? token, + HttpContext context, IBrowserPlayService service, IGameDataVaultStore store, + GameDataVaultFileStore files, CancellationToken ct) => +{ + var source = await service.AuthorizeSourceAsync(id, token ?? string.Empty, ct); + if (source is null) return Results.NotFound(); + if (!BrowserGameDataPolicy.SupportsAutomaticRestore(source.Session.Core)) return Results.NoContent(); + if (!Enum.TryParse<GameDataKind>(kind, true, out var dataKind) || !Enum.IsDefined(dataKind)) + return Results.BadRequest(new { title = "Only Save and SaveState data are accepted." }); + var session = source.Session; + var entryId = BrowserGameDataPolicy.StableEntryId(session.GameId, session.Core, dataKind); + var item = await store.GetGameDataAsync(entryId, ct); + if (item is null || item.Entry.GameId != session.GameId || item.Entry.Kind != dataKind || + !string.Equals(item.Entry.Emulator, session.Core, StringComparison.OrdinalIgnoreCase)) + return Results.NoContent(); + if (!BrowserGameDataPolicy.Matches(item.CurrentRevision.SourceType, item.CurrentRevision.SourceId, + session, source.Details.Artifact)) + return Results.NoContent(); + var stored = await files.OpenAsync(item.CurrentRevision, ct); + if (stored is null) return Results.NoContent(); + context.Response.Headers.CacheControl = "private, no-store"; + context.Response.Headers.XContentTypeOptions = "nosniff"; + return Results.Stream(stored.Content, "application/octet-stream", + entityTag: new Microsoft.Net.Http.Headers.EntityTagHeaderValue($"\"{item.CurrentRevision.Sha256}\""), + enableRangeProcessing: true); +}); +api.MapPut("/play-sessions/{id:guid}/data/{kind}", async (Guid id, string kind, string? token, + HttpRequest request, IBrowserPlayService service, IGameDataVaultStore store, + GameDataVaultFileStore files, CancellationToken ct) => +{ + var source = await service.AuthorizeSourceAsync(id, token ?? string.Empty, ct); + if (source is null) return Results.NotFound(); + var session = source.Session; + if (!Enum.TryParse<GameDataKind>(kind, true, out var dataKind) || !Enum.IsDefined(dataKind)) + return Results.BadRequest(new { title = "Only Save and SaveState data are accepted." }); + + var entryId = BrowserGameDataPolicy.StableEntryId(session.GameId, session.Core, dataKind); + var existing = await store.GetGameDataAsync(entryId, ct); + if (existing is not null && existing.Entry.GameId != session.GameId) return Results.Conflict(); + var sourceType = "BrowserPlayerUnverified"; + var sourceId = session.Core; + if (BrowserGameDataPolicy.SupportsAutomaticRestore(session.Core)) + { + sourceType = BrowserGameDataPolicy.SourceType; + sourceId = BrowserGameDataPolicy.EncodeBinding(source.Details.Artifact.Id, + source.Details.Artifact.Version, session.Core, source.Details.Artifact.Sha256); + } + var revisionId = Guid.NewGuid(); + var extension = dataKind == GameDataKind.Save ? ".srm" : ".state"; + var uploaded = await files.SaveAsync(session.GameId, entryId, revisionId, + $"{session.Core}-{DateTimeOffset.UtcNow:yyyyMMdd-HHmmss}{extension}", request.Body, + request.ContentLength, "browser-player", ct, sourceType, sourceId); + try + { + var saved = await store.SaveGameDataRevisionAsync(session.GameId, entryId, + new(dataKind, $"Browser {dataKind}", session.Core, "browser", "Captured by Ludarium EmulatorJS."), + existing?.Entry.Version ?? 0, uploaded, "browser-player", ct); + if (saved.CurrentRevision.Id != uploaded.Id) files.Delete(uploaded); + return Results.Ok(new + { + saved.Entry.Id, + saved.Entry.Version, + saved.Entry.RevisionCount, + saved.CurrentRevision.Sha256 + }); + } + catch + { + files.Delete(uploaded); + throw; + } +}).WithMetadata(new Microsoft.AspNetCore.Mvc.RequestSizeLimitAttribute(GameDataVaultFileStore.MaximumBytes)); +api.MapDelete("/play-sessions/{id:guid}", async (Guid id, IBrowserPlayService service, CancellationToken ct) => +{ + await service.CancelAsync(id, "local-admin", ct); + return Results.NoContent(); +}); +api.MapPut("/games/{id:guid}/metadata/{field}", async (Guid id, string field, UpdateGameMetadata request, ILudariumStore store, CancellationToken ct) => +{ + var allowed = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "officialTitle", "alternateTitles", "description", "releaseDate", "developer", "publisher", "genre", "players", "region", "franchise", "edition" }; + if (!allowed.Contains(field) || string.IsNullOrWhiteSpace(request.Value)) return Results.BadRequest(new { title = "Unsupported or empty metadata field." }); + var claim = new MetadataClaim(Guid.NewGuid(), "Game", id, field, request.Value.Trim(), "Manual", "local-admin", Confidence.Deterministic, + [new Evidence("metadata.manual", field, "operator", "1", Confidence.Deterministic, DateTimeOffset.UtcNow)], true, DateTimeOffset.UtcNow); + await store.AddClaimAsync(claim, ct); return Results.Ok(claim); +}); +api.MapGet("/games/{id:guid}/artwork", async (Guid id, Guid? candidate, string? platform, GameArtworkStore artworkStore, ILudariumStore store, IReadOnlyLibraryFileSystem files, CancellationToken ct) => +{ + var selectedArtwork = candidate is null + ? await store.GetSelectedGameArtworkAsync(id, ct, platform) + : (await store.ListGameArtworkAsync(id, ct)).SingleOrDefault(item => item.Id == candidate); + if (candidate is not null && selectedArtwork is null) return Results.NotFound(); + var accepted = candidate is not null || selectedArtwork is not null && ArtworkQuality.MatchesRetailProfile(selectedArtwork, platform); + if (accepted && await artworkStore.OpenAsync(id, ct, selectedArtwork?.Id, + fallbackToCurrent: candidate is null && selectedArtwork is null && platform is null) is { } uploaded) + { + return Results.Stream(uploaded.Content, uploaded.ContentType, enableRangeProcessing: true, entityTag: new Microsoft.Net.Http.Headers.EntityTagHeaderValue($"\"uploaded-{uploaded.Length}\"")); + } + if (candidate is not null) return Results.NotFound(); + // Platform-filtered shelves never fall back to console menu tiles such as sce_sys/icon0 or + // to a cover selected for another release of the same title. + if (platform is not null) return Results.NoContent(); + var details = await store.GetGameArtworkAsync(id, ct); + if (details is null) return Results.NoContent(); + if (details.Artifact.Size > 20 * 1024 * 1024) return Results.NotFound(); + var extension = Path.GetExtension(details.Artifact.RelativePath).ToLowerInvariant(); + var contentType = extension switch { ".jpg" or ".jpeg" => "image/jpeg", ".png" => "image/png", ".webp" => "image/webp", _ => null }; + if (contentType is null) return Results.NotFound(); + var stream = await files.OpenReadAsync(details.Library, details.Artifact.RelativePath, ct); + return Results.Stream(stream, contentType, enableRangeProcessing: true); +}); +api.MapGet("/games/{id:guid}/artwork-info", async (Guid id, string? platform, GameArtworkStore artworkStore, ILudariumStore store, CancellationToken ct) => +{ + var selected = await store.GetSelectedGameArtworkAsync(id, ct, platform); + if (selected is not null && await artworkStore.OpenAsync(id, ct, selected.Id, fallbackToCurrent: false) is { } uploaded) + { + await uploaded.Content.DisposeAsync(); + return Results.Ok(new { source = selected.Source, contentType = uploaded.ContentType, uploaded.Length, selected.Platform }); + } + if (platform is not null) return Results.Ok(new { source = "Generated", contentType = (string?)null, length = (long?)null, platform }); + var local = await store.GetGameArtworkAsync(id, ct); + return Results.Ok(local is null + ? new { source = "Generated", contentType = (string?)null, length = (long?)null } + : new { source = "Library", contentType = (string?)Path.GetExtension(local.Artifact.RelativePath).ToLowerInvariant(), length = (long?)local.Artifact.Size }); +}); +api.MapPut("/games/{id:guid}/artwork", async (Guid id, string? platform, HttpRequest request, GameArtworkStore artworkStore, ILudariumStore store, CancellationToken ct) => +{ + try + { + var contentType = await artworkStore.SaveAsync(id, request.Body, request.ContentLength, ct); + if (await artworkStore.DescribeAsync(id, "Uploaded", "operator-upload", Confidence.Deterministic, ct, platform) is { } artwork) + await store.UpsertGameArtworkAsync(artwork, ct); + return Results.Ok(new { source = "Uploaded", contentType }); + } + catch (ArtworkValidationException exception) + { + return Results.Problem(title: "Artwork was not accepted", detail: exception.Message, statusCode: 400); + } +}); +api.MapDelete("/games/{id:guid}/artwork", (Guid id, GameArtworkStore artworkStore) => + artworkStore.Delete(id) ? Results.NoContent() : Results.NotFound()); +api.MapGet("/games/{id:guid}/artwork-quality", async (Guid id, string? platform, ILudariumStore store, CancellationToken ct) => + await store.GetSelectedGameArtworkAsync(id, ct, platform) is { } artwork ? Results.Ok(artwork) : Results.NoContent()); +api.MapGet("/games/{id:guid}/artwork-candidates", async (Guid id, string? platform, ILudariumStore store, CancellationToken ct) => + (await store.ListGameArtworkAsync(id, ct)).Where(item => platform is null || + item.Platform?.Equals(platform, StringComparison.OrdinalIgnoreCase) == true || + item.Platform is null && item.Source.Equals("Uploaded", StringComparison.OrdinalIgnoreCase))); +api.MapPost("/games/{id:guid}/artwork-candidates/{artworkId:guid}/select", async (Guid id, Guid artworkId, ILudariumStore store, CancellationToken ct) => +{ + try { await store.SelectGameArtworkAsync(id, artworkId, ct); return Results.NoContent(); } + catch (KeyNotFoundException) { return Results.NotFound(); } +}); +api.MapGet("/artwork/review", (ILudariumStore store, CancellationToken ct) => store.ListArtworkNeedingReviewAsync(ct)); +api.MapGet("/artwork/review-items", (ILudariumStore store, CancellationToken ct) => store.ListArtworkReviewItemsAsync(ct)); +api.MapPost("/games/artwork/enrich", async (ILudariumStore store, CancellationToken ct) => + Results.Accepted("/api/v1/jobs", await store.EnqueueJobAsync("ArtworkEnrichment", "{}", $"artwork:platform-retail-v3:{DateTimeOffset.UtcNow:yyyyMMddHH}", ct))); +api.MapGet("/jobs", (ILudariumStore store, CancellationToken ct) => store.ListJobsAsync(ct)); +api.MapGet("/providers", (ILudariumStore store, CancellationToken ct) => store.ListProviderSnapshotsAsync(ct)); +api.MapPost("/games/metadata/enrich", async (ILudariumStore store, CancellationToken ct) => + Results.Accepted("/api/v1/jobs", await store.EnqueueJobAsync("MetadataEnrichment", "{}", $"metadata:{DateTimeOffset.UtcNow:yyyyMMddHH}", ct))); +api.MapPost("/jobs/{id:guid}/cancel", async (Guid id, ILudariumStore store, CancellationToken ct) => { await store.RequestJobCancellationAsync(id, ct); return Results.Accepted(); }); +api.MapPost("/jobs/{id:guid}/retry", async (Guid id, ILudariumStore store, CancellationToken ct) => +{ + try { return Results.Accepted("/api/v1/jobs", await store.RetryJobAsync(id, ct)); } + catch (ConcurrencyException exception) { return Results.Conflict(new { title = exception.Message }); } +}); +// Scoping to the games actually on screen matters: the library used to load every release in the +// catalog on each search, page and filter change purely to label its tiles. +api.MapGet("/releases", async (Guid? gameId, string? ids, ILudariumStore store, CancellationToken ct) => +{ + if (string.IsNullOrWhiteSpace(ids)) return Results.Ok(await store.ListReleasesAsync(gameId, ct)); + var requested = ids.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (requested.Length > 200 || requested.Any(value => !Guid.TryParse(value, out _))) + return Results.BadRequest(new { message = "Supply at most 200 valid game ids." }); + return Results.Ok(await store.ListReleasesForGamesAsync(requested.Select(Guid.Parse).Distinct().ToArray(), ct)); +}); +api.MapPost("/releases", async (CreateRelease request, ILudariumStore store, CancellationToken ct) => Results.Created("/api/v1/releases", await store.CreateReleaseAsync(request.GameId, request.Title, request.Platform, request.Region, request.Revision, "local-admin", ct))); +api.MapPut("/releases/{id:guid}", (Guid id, UpdateRelease request, ILudariumStore store, CancellationToken ct) => + store.UpdateReleaseAsync(id, request.Title, request.Platform, request.Region, request.Revision, + request.ExpectedVersion, "local-admin", ct)); +api.MapDelete("/releases/{id:guid}", async (Guid id, long expectedVersion, ILudariumStore store, CancellationToken ct) => +{ + await store.DeleteReleaseAsync(id, expectedVersion, "local-admin", ct); + return Results.NoContent(); +}); +api.MapGet("/findings", (ILudariumStore store, CancellationToken ct) => store.ListFindingsAsync(ct)); +api.MapGet("/catalog-sources", (ICatalogStore store, CancellationToken ct) => store.ListCatalogSourcesAsync(ct)); +api.MapPost("/catalog-sources/logiqx", async (string name, string version, HttpRequest request, ICatalogImporter importer, CancellationToken ct) => +{ + if (request.ContentLength is > 536_870_912) return Results.Problem("Catalog exceeds the 512 MiB import limit.", statusCode: 413); + var source = await importer.ImportLogiqxAsync(name, version, request.Body, ct); + return Results.Created($"/api/v1/catalog-sources/{source.Id}", source); +}); +api.MapGet("/match-candidates", (ICatalogStore store, CancellationToken ct) => store.ListMatchCandidatesAsync(ct)); +api.MapPost("/snapshots", async (CreateSnapshot request, ILudariumStore store, CancellationToken ct) => Results.Created("/api/v1/snapshots", await store.CreateSnapshotAsync(request.Name, ct))); +api.MapGet("/snapshots", (ILudariumStore store, CancellationToken ct) => store.ListSnapshotsAsync(ct)); +api.MapPost("/snapshots/compare", async (CompareSnapshots request, ILudariumStore store, ISnapshotComparer comparer, CancellationToken ct) => +{ + var before = await store.GetSnapshotAsync(request.BeforeId, ct); var after = await store.GetSnapshotAsync(request.AfterId, ct); + return before is null || after is null ? Results.NotFound() : Results.Ok(comparer.Compare(before, after)); +}); +api.MapGet("/events", async (HttpContext context, ILudariumStore store, CancellationToken ct) => +{ + context.Response.Headers.ContentType = "text/event-stream"; + while (!ct.IsCancellationRequested) + { + var scans = await store.ListScansAsync(ct); + await context.Response.WriteAsync($"event: progress\ndata: {JsonSerializer.Serialize(scans.Take(20))}\n\n", ct); + await context.Response.Body.FlushAsync(ct); await Task.Delay(TimeSpan.FromSeconds(2), ct); + } +}); +api.MapPost("/exports", async (ExportRequest request, IInventoryExportService service, CancellationToken ct) => + Results.Ok(await service.CreateAsync(request.Format, ct))); +api.MapPost("/game-catalog/exports", async (ExportRequest request, IGameCatalogExportService service, CancellationToken ct) => + Results.Ok(await service.CreateAsync(request.Format, ct))); +api.MapGet("/game-catalog/exports/{fileName}", async (string fileName, IGameCatalogExportService service, CancellationToken ct) => +{ + if (await service.OpenAsync(fileName, ct) is not { } export) return Results.NotFound(); + return Results.Stream(export.Content, export.ContentType, fileDownloadName: export.FileName, + enableRangeProcessing: true, entityTag: new Microsoft.Net.Http.Headers.EntityTagHeaderValue($"\"{export.Length:x}\"")); +}); +api.MapPost("/support-bundles", async (ISupportBundleService service, CancellationToken ct) => Results.Ok(await service.CreateAsync(ct))); + +var webRoot = Path.Combine(app.Environment.ContentRootPath, "wwwroot"); +app.MapGet("/favicon.ico", () => Results.Redirect("/favicon.svg", permanent: true)); +if (Directory.Exists(webRoot)) +{ + app.UseDefaultFiles(); + app.UseStaticFiles(new StaticFileOptions { ContentTypeProvider = StaticAssetContentTypes.Create() }); + app.MapFallbackToFile("index.html"); +} + +await app.Services.GetRequiredService<ILudariumStore>().InitializeAsync(CancellationToken.None); +try +{ + var vaultStore = app.Services.GetRequiredService<IGameDataVaultStore>(); + var vaultFiles = app.Services.GetRequiredService<GameDataVaultFileStore>(); + var knownLocations = await vaultStore.ListGameDataRevisionLocationsAsync(CancellationToken.None); + var comparer = OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal; + var reconciliation = vaultFiles.Reconcile(new HashSet<string>(knownLocations, comparer)); + if (app.Logger.IsEnabled(LogLevel.Information)) + app.Logger.LogInformation( + "Game Data Vault reconciliation removed {UploadCount} incomplete uploads and {OrphanCount} orphan revisions, reclaiming {ReclaimedBytes} bytes", + reconciliation.RemovedUploads, reconciliation.RemovedOrphans, reconciliation.ReclaimedBytes); +} +catch (Exception exception) +{ + app.Logger.LogWarning(exception, "Game Data Vault reconciliation could not complete; no source-library content was affected"); +} +try +{ + var firmwareStore = app.Services.GetRequiredService<IFirmwareStore>(); + var firmwareFiles = app.Services.GetRequiredService<FirmwareVaultFileStore>(); + var known = (await firmwareStore.ListFirmwareAssetsAsync(CancellationToken.None)) + .Select(asset => asset.Location).ToHashSet(OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal); + var reconciliation = firmwareFiles.Reconcile(known); + if (app.Logger.IsEnabled(LogLevel.Information)) + app.Logger.LogInformation("Firmware vault reconciliation removed {UploadCount} incomplete uploads and {OrphanCount} orphan files, reclaiming {ReclaimedBytes} bytes", + reconciliation.RemovedUploads, reconciliation.RemovedOrphans, reconciliation.ReclaimedBytes); +} +catch (Exception exception) +{ + app.Logger.LogWarning(exception, "Firmware vault reconciliation could not complete; no source-library content was affected"); +} +app.Run(); + +public sealed record CreateLibrary(string Name, string Path, LibraryKind Kind, bool Recursive = true, HashPolicy HashPolicy = HashPolicy.Sha256, bool InspectArchives = true, int MaxConcurrency = 1); +public sealed record UpdateLibrary(string Name, bool Enabled, HashPolicy HashPolicy, bool InspectArchives, int MaxConcurrency, long ExpectedVersion); +public sealed record StartScan(string? Mode); + +/// <summary> +/// Reads the scan mode a client asked for. +/// </summary> +/// <remarks> +/// Binding it as an enum answered an unrecognised mode with an empty 400: no message, no hint at +/// what was expected. Reading the name here lets the answer name the modes that exist. +/// </remarks> +public static class ScanRequests +{ + public static string UnknownModeMessage { get; } = + $"Unknown scan mode. Use one of: {string.Join(", ", Enum.GetNames<ScanMode>())}."; + + public static bool TryParseMode(string? value, out ScanMode mode) => + Enum.TryParse(value, ignoreCase: true, out mode) && Enum.IsDefined(mode); +} +public sealed record ResolveReview(string Resolution); +public sealed record ResolveReviewGroup(IReadOnlyList<Guid> Ids, int ExpectedCount, string Resolution); +public sealed record ReopenReviewGroup(IReadOnlyList<Guid> Ids, int ExpectedCount, ReviewState SourceState, string Resolution); +public sealed record ApplyReviewGroupAction(IReadOnlyList<Guid> Ids, int ExpectedCount, ReviewBulkAction Action, string? Platform, string? PathPattern); +public sealed record ExportRequest(string Format); +public sealed record CreateSnapshot(string Name); +public sealed record CompareSnapshots(Guid BeforeId, Guid AfterId); +public sealed record CreateGame(string Title); +public sealed record UpdateWishlistItem(WishlistInput Item, long ExpectedVersion); +public sealed record UpdateGame(string Title, long ExpectedVersion); +public sealed record UpdateGameMetadata(string Value); +public sealed record UpdateGameGroup(GameCollectionInput Collection, long ExpectedVersion); +public sealed record CollectionGame(Guid GameId); +public sealed record TagInput(string Name); +public sealed record UpdateGameUserState(GameUserStateInput State, long ExpectedVersion); +public sealed record UpdatePlatformDefinition(PlatformDefinitionInput Platform, long ExpectedVersion); +public sealed record CreateRelease(Guid GameId, string Title, string? Platform, string? Region, string? Revision); +public sealed record UpdateRelease(string Title, string? Platform, string? Region, string? Revision, long ExpectedVersion); +public sealed record UpdateOperationalSettings(int ReviewRetentionDays, int AuditRetentionDays, int ExportRetentionDays, + int DefaultPageSize, ScanMode DefaultScanMode, long ExpectedVersion); + +public sealed class ScanWorker(IServiceProvider services, ILogger<ScanWorker> logger) : BackgroundService +{ + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + while (!stoppingToken.IsCancellationRequested) + { + try + { + using var scope = services.CreateScope(); var store = scope.ServiceProvider.GetRequiredService<ILudariumStore>(); + var coordinator = scope.ServiceProvider.GetRequiredService<IScanCoordinator>(); + var next = (await store.ListScansAsync(stoppingToken)).FirstOrDefault(x => x.State is ScanState.Queued or ScanState.Verifying or ScanState.Discovering or ScanState.Processing or ScanState.Cancelling); + if (next is not null) { await coordinator.ExecuteAsync(next.Id, stoppingToken); continue; } + var job = await store.LeaseJobAsync(Environment.MachineName, TimeSpan.FromMinutes(2), stoppingToken); + if (job is null) { await Task.Delay(500, stoppingToken); continue; } + if (job.Kind is not ("ArtworkEnrichment" or "MetadataEnrichment")) + { + await store.UpdateJobAsync(job with { State = JobState.DeadLetter, Error = "No handler is registered for this job kind.", LeaseOwner = null, LeaseUntil = null, UpdatedAt = DateTimeOffset.UtcNow }, stoppingToken); + continue; + } + try + { + var games = await store.ListGamesAsync(stoppingToken); + var releases = await store.ListReleasesAsync(null, stoppingToken); + Func<int, int, Task> progress = async (completed, total) => + { + if ((await store.GetJobAsync(job.Id, stoppingToken))?.CancellationRequested == true) + throw new OperationCanceledException("Enrichment was cancelled by the operator."); + job = job with { ItemsCompleted = completed, ItemsTotal = total, LeaseUntil = DateTimeOffset.UtcNow.AddMinutes(2), UpdatedAt = DateTimeOffset.UtcNow }; + await store.UpdateJobAsync(job, stoppingToken); + }; + if (job.Kind == "ArtworkEnrichment") + await scope.ServiceProvider.GetRequiredService<GameArtworkEnricher>().EnrichAsync(games, releases, stoppingToken, progress); + else + await scope.ServiceProvider.GetRequiredService<GameMetadataEnricher>().EnrichAsync(games, releases, stoppingToken, progress); + await store.UpdateJobAsync(job with { State = JobState.Completed, ItemsCompleted = games.Count, ItemsTotal = games.Count, LeaseOwner = null, LeaseUntil = null, UpdatedAt = DateTimeOffset.UtcNow }, stoppingToken); + } + catch (OperationCanceledException) when (!stoppingToken.IsCancellationRequested) + { + await store.UpdateJobAsync(job with { State = JobState.Cancelled, CancellationRequested = true, LeaseOwner = null, LeaseUntil = null, UpdatedAt = DateTimeOffset.UtcNow }, stoppingToken); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + var terminal = job.Attempts >= job.MaxAttempts; + await store.UpdateJobAsync(job with + { + State = terminal ? JobState.DeadLetter : JobState.Queued, + Error = ex.Message, + LeaseOwner = null, + LeaseUntil = null, + ScheduledAt = DateTimeOffset.UtcNow.AddSeconds(terminal ? 0 : Math.Pow(2, job.Attempts)), + UpdatedAt = DateTimeOffset.UtcNow + }, stoppingToken); + } + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { } + catch (Exception ex) { logger.LogError(ex, "Scan worker iteration failed"); await Task.Delay(2000, stoppingToken); } + } + } +} + +public partial class Program; + +public static class AdminTokenPolicy +{ + private static readonly string[] KnownPlaceholders = + ["change-me", "changeme", "replace", "example", "secret", "password", "ludarium"]; + + public static bool IsAcceptableConfiguration(string? configuredToken) => + configuredToken is { Length: >= 32 } && + !KnownPlaceholders.Any(placeholder => configuredToken.Contains(placeholder, StringComparison.OrdinalIgnoreCase)); + + public static bool IsScopedPlayerRead(string? path) => + path?.StartsWith("/api/v1/play-sessions/", StringComparison.Ordinal) == true && + (path.EndsWith("/content", StringComparison.Ordinal) || + path.EndsWith("/firmware", StringComparison.Ordinal) || + path.EndsWith("/cheats", StringComparison.Ordinal) || + path.Contains("/data/", StringComparison.Ordinal)); + + public static bool Authorizes(string? authorization, string configuredToken) + { + if (string.IsNullOrWhiteSpace(configuredToken) || string.IsNullOrWhiteSpace(authorization) || + !authorization.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) return false; + var supplied = authorization[7..]; + if (string.IsNullOrWhiteSpace(supplied) || supplied.Any(char.IsWhiteSpace)) return false; + var actual = System.Security.Cryptography.SHA256.HashData(Encoding.UTF8.GetBytes(supplied)); + var expected = System.Security.Cryptography.SHA256.HashData(Encoding.UTF8.GetBytes(configuredToken)); + return System.Security.Cryptography.CryptographicOperations.FixedTimeEquals(actual, expected); + } +} + +public static class StaticAssetContentTypes +{ + public static Microsoft.AspNetCore.StaticFiles.FileExtensionContentTypeProvider Create() + { + var provider = new Microsoft.AspNetCore.StaticFiles.FileExtensionContentTypeProvider(); + // EmulatorJS stable releases package each local core as a .data payload. ASP.NET does not + // know that extension by default and would otherwise return 404, triggering an unsafe CDN + // fallback even though the checksum-pinned core is present in the image. + provider.Mappings[".data"] = "application/octet-stream"; + return provider; + } +} + +public sealed record UpdateGameData(GameDataMetadataInput Metadata, long ExpectedVersion); + +public sealed record UploadGameData(GameDataKind Kind, string Name, string FileName, string? Emulator, + string? Device, string? Notes, long ExpectedVersion) +{ + public const string HeaderName = "X-Ludarium-Game-Data"; + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) + { + Converters = { new JsonStringEnumConverter(allowIntegerValues: false) } + }; + + public static UploadGameData Parse(IHeaderDictionary headers) + { + if (!headers.TryGetValue(HeaderName, out var values) || values.Count != 1 || + string.IsNullOrWhiteSpace(values[0]) || values[0]!.Length > 8192) + throw new GameDataVaultValidationException("Game-data upload metadata is missing or too large."); + try + { + var json = Convert.FromBase64String(values[0]!); + return JsonSerializer.Deserialize<UploadGameData>(json, JsonOptions) + ?? throw new GameDataVaultValidationException("Game-data upload metadata is invalid."); + } + catch (Exception exception) when (exception is FormatException or JsonException) + { + throw new GameDataVaultValidationException("Game-data upload metadata is invalid."); + } + } +} + +public partial class Program +{ + private static readonly System.Text.Json.JsonSerializerOptions WishlistJsonOptions = new(System.Text.Json.JsonSerializerDefaults.Web); + + public static IReadOnlyList<WishlistInput> ParseWishlistImport(string text, string? format) + { + if (format?.Contains("json", StringComparison.OrdinalIgnoreCase) == true) + { + var items = System.Text.Json.JsonSerializer.Deserialize<List<WishlistItem>>(text, WishlistJsonOptions) ?? []; + return items.Where(item => !string.IsNullOrWhiteSpace(item.Title)).Select(item => new WishlistInput(null, item.Title, item.Platform, item.Priority, item.Notes, + item.Status, item.DesiredPrice, item.CurrentPrice, item.Currency, item.StoreName, item.StoreUrl, item.Edition, item.Region, item.ReleaseDate, + item.ExternalProvider, item.ExternalId, item.ArtworkUrl)).ToArray(); + } + if (format?.Contains("csv", StringComparison.OrdinalIgnoreCase) == true) + { + var rows = text.Replace("\r", "", StringComparison.Ordinal).Split('\n', StringSplitOptions.RemoveEmptyEntries).Select(ParseCsvRow).ToArray(); + if (rows.Length == 0) return []; + var header = rows[0].Select((value, index) => new { value, index }).ToDictionary(item => item.value, item => item.index, StringComparer.OrdinalIgnoreCase); + string? Value(string[] row, string name) => header.TryGetValue(name, out var index) && index < row.Length ? row[index] : null; + return rows.Skip(1).Where(row => !string.IsNullOrWhiteSpace(Value(row, "title"))).Select(row => new WishlistInput(null, Value(row, "title")!.Trim(), Value(row, "platform"), + Enum.TryParse<WishlistPriority>(Value(row, "priority"), true, out var priority) ? priority : WishlistPriority.Normal, Value(row, "notes"), + Enum.TryParse<WishlistStatus>(Value(row, "status"), true, out var status) ? status : WishlistStatus.Interested, + decimal.TryParse(Value(row, "desiredPrice"), System.Globalization.NumberStyles.Number, System.Globalization.CultureInfo.InvariantCulture, out var desired) ? desired : null, + decimal.TryParse(Value(row, "currentPrice"), System.Globalization.NumberStyles.Number, System.Globalization.CultureInfo.InvariantCulture, out var current) ? current : null, + Value(row, "currency"), Value(row, "store"), null, Value(row, "edition"), Value(row, "region"))).ToArray(); + } + return text.Replace("\r", "", StringComparison.Ordinal).Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Distinct(StringComparer.OrdinalIgnoreCase).Select(title => new WishlistInput(null, title, null, WishlistPriority.Normal, null)).ToArray(); + } + + private static string[] ParseCsvRow(string line) + { + var values = new List<string>(); var value = new System.Text.StringBuilder(); var quoted = false; + for (var index = 0; index < line.Length; index++) + { + var character = line[index]; + if (character == '"' && quoted && index + 1 < line.Length && line[index + 1] == '"') { value.Append(character); index++; } + else if (character == '"') quoted = !quoted; + else if (character == ',' && !quoted) { values.Add(value.ToString()); value.Clear(); } + else value.Append(character); + } + values.Add(value.ToString()); return values.ToArray(); + } +} diff --git a/src/Ludarium.Api/Properties/launchSettings.json b/src/Ludarium.Api/Properties/launchSettings.json new file mode 100644 index 0000000..b0954b5 --- /dev/null +++ b/src/Ludarium.Api/Properties/launchSettings.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5135", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/Ludarium.Api/appsettings.Development.json b/src/Ludarium.Api/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/src/Ludarium.Api/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/src/Ludarium.Api/appsettings.json b/src/Ludarium.Api/appsettings.json new file mode 100644 index 0000000..10f68b8 --- /dev/null +++ b/src/Ludarium.Api/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/src/Ludarium.Api/packages.lock.json b/src/Ludarium.Api/packages.lock.json new file mode 100644 index 0000000..228a305 --- /dev/null +++ b/src/Ludarium.Api/packages.lock.json @@ -0,0 +1,71 @@ +{ + "version": 1, + "dependencies": { + "net10.0": { + "Microsoft.AspNetCore.OpenApi": { + "type": "Direct", + "requested": "[10.0.11, )", + "resolved": "10.0.11", + "contentHash": "R/1EATnPLU+gRfB6lwVkMcymmyAY5ppBBdRN/5lhNEiT3xP1sWccuSFkU/f1lQvN/WgRq5Vn8AhCdE3fqgsL/w==", + "dependencies": { + "Microsoft.OpenApi": "[2.7.5, 3.0.0)" + } + }, + "Microsoft.OpenApi": { + "type": "Direct", + "requested": "[2.11.0, )", + "resolved": "2.11.0", + "contentHash": "/ignjfdeKT2SGLIR7QEv19KnI0rvoxRG/TYDOZdK9EsWLjKK9IK8i1Mo5NRm9PRV3i64DzlTqnIflWvoyfljLg==" + }, + "Yarp.ReverseProxy": { + "type": "Direct", + "requested": "[2.3.0, )", + "resolved": "2.3.0", + "contentHash": "gxtkN3a+9biu9V9Zd5NaTO6VZWXAnS2mhQ0R/VXmSPoTuiQNZsakKikrKpDtKxrL5nUYzbRsHtl40WNq+ZBKKg==", + "dependencies": { + "System.IO.Hashing": "8.0.0" + } + }, + "Npgsql": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "7nb5YzXuvWWJxB0J8DiyL3we+X4FOctZrt0fIBnucOIaIevFEEwGQVZKtiu9olXdlNAK1eNgqSral6r/jlhI4w==" + }, + "OpenMcdf": { + "type": "Transitive", + "resolved": "3.2.0", + "contentHash": "n/iojS7V77YjM6IBbXaP0ZI8EELhJ2j6XRjx9NxMyUtjcM4a0yr0fWqO1y7lgd3CgYwnDugaKlO9c7Os8VxNtg==" + }, + "SharpCompress": { + "type": "Transitive", + "resolved": "0.50.4", + "contentHash": "/hxjUR7DEX6mky8/LQXyrnrKioOL6D6veAID1EZpro+q4s02x5dHEYBV3qjEN6lDYYilDoQQ76BcmQj+lRx51w==" + }, + "System.IO.Hashing": { + "type": "Transitive", + "resolved": "10.0.11", + "contentHash": "OzKDcIRkeNJeC8qAsbn8yJXnfTLP1dtkWILe+T56Gf/z+IkAASi7sMqLqJQat08j5z/mRN5xVtoAwbkMNMoBUQ==" + }, + "ludarium.application": { + "type": "Project", + "dependencies": { + "Ludarium.Domain": "[1.0.0, )", + "OpenMcdf": "[3.2.0, )", + "SharpCompress": "[0.50.4, )", + "System.IO.Hashing": "[10.0.11, )" + } + }, + "ludarium.domain": { + "type": "Project" + }, + "ludarium.infrastructure": { + "type": "Project", + "dependencies": { + "Ludarium.Application": "[1.0.0, )", + "Ludarium.Domain": "[1.0.0, )", + "Npgsql": "[10.0.3, )" + } + } + } + } +} \ No newline at end of file diff --git a/src/Ludarium.Application/Analysis.cs b/src/Ludarium.Application/Analysis.cs new file mode 100644 index 0000000..ad5c252 --- /dev/null +++ b/src/Ludarium.Application/Analysis.cs @@ -0,0 +1,646 @@ +using System.Buffers; +using System.Buffers.Binary; +using System.Globalization; +using System.IO.Hashing; +using System.Reflection.Metadata; +using System.Reflection.PortableExecutable; +using System.Security.Cryptography; +using System.Text; +using System.Text.RegularExpressions; +using Ludarium.Domain; +using OpenMcdf; + +namespace Ludarium.Application; + +public static partial class ArtifactAnalysis +{ + private static readonly Dictionary<string, string> PlatformDirectories = new(StringComparer.OrdinalIgnoreCase) + { + ["pc"] = "windows", + ["windows"] = "windows", + ["nes"] = "nes", + ["snes"] = "snes", + ["n64"] = "n64", + ["gb"] = "gb", + ["gbc"] = "gbc", + ["gba"] = "gba", + ["nds"] = "nds", + ["3ds"] = "3ds", + ["ngc"] = "gamecube", + ["gamecube"] = "gamecube", + ["wii"] = "wii", + ["switch"] = "switch", + ["ps1"] = "psx", + ["psx"] = "psx", + ["ps2"] = "ps2", + ["ps3"] = "ps3", + ["ps4"] = "ps4", + ["ps5"] = "ps5", + ["psp"] = "psp", + ["psv"] = "psvita", + ["psvita"] = "psvita", + ["wiiu"] = "wiiu", + ["xbox"] = "xbox", + ["xbox360"] = "xbox360", + // Sega directories vary widely between collections; every common spelling resolves to the + // canonical platform so a hardware-named folder is never silently ignored. + ["genesis"] = "genesis", + ["megadrive"] = "genesis", + ["mega-drive"] = "genesis", + ["sega-genesis"] = "genesis", + ["segagenesis"] = "genesis", + ["md"] = "genesis", + ["sms"] = "mastersystem", + ["mastersystem"] = "mastersystem", + ["master-system"] = "mastersystem", + ["gg"] = "gamegear", + ["gamegear"] = "gamegear", + ["game-gear"] = "gamegear", + ["32x"] = "sega32x", + ["sega32x"] = "sega32x", + ["segacd"] = "segacd", + ["saturn"] = "saturn", + ["dreamcast"] = "dreamcast" + }; + + /// <summary> + /// Platform values written by earlier Ludarium versions, mapped to their canonical name so a + /// catalog scanned before the Sega platforms existed keeps resolving after an upgrade. + /// </summary> + private static readonly Dictionary<string, string> PlatformAliases = new(StringComparer.OrdinalIgnoreCase) + { + ["sega-genesis"] = "genesis", + ["megadrive"] = "genesis", + ["sms"] = "mastersystem", + ["gg"] = "gamegear" + }; + + /// <summary>Resolve any historical or shorthand platform value to its canonical name.</summary> + public static string? CanonicalPlatform(string? platform) => platform is null ? null + : PlatformAliases.TryGetValue(platform, out var canonical) ? canonical : platform; + + private static readonly Dictionary<string, (MediaType Type, string? Platform)> Extensions = new(StringComparer.OrdinalIgnoreCase) + { + [".nes"] = (MediaType.Rom, "nes"), + [".sfc"] = (MediaType.Rom, "snes"), + [".smc"] = (MediaType.Rom, "snes"), + [".z64"] = (MediaType.Rom, "n64"), + [".n64"] = (MediaType.Rom, "n64"), + [".v64"] = (MediaType.Rom, "n64"), + [".gb"] = (MediaType.Rom, "gb"), + [".gbc"] = (MediaType.Rom, "gbc"), + [".gba"] = (MediaType.Rom, "gba"), + [".nds"] = (MediaType.Rom, "nds"), + [".cue"] = (MediaType.DiscDescriptor, null), + [".m3u"] = (MediaType.DiscDescriptor, null), + [".iso"] = (MediaType.DiscImage, null), + [".chd"] = (MediaType.DiscImage, null), + [".cso"] = (MediaType.DiscImage, null), + [".isz"] = (MediaType.DiscImage, null), + [".rvz"] = (MediaType.DiscImage, null), + [".wbfs"] = (MediaType.DiscImage, null), + // GameCube and Wii container formats the Dolphin player accepts. Without these a linked + // .gcm/.gcz/.wia disc classified as Unknown and could never become a launch candidate. + [".gcm"] = (MediaType.DiscImage, null), + [".gcz"] = (MediaType.DiscImage, null), + [".wia"] = (MediaType.DiscImage, null), + [".wud"] = (MediaType.DiscImage, "wiiu"), + [".wux"] = (MediaType.DiscImage, "wiiu"), + [".gdi"] = (MediaType.DiscDescriptor, null), + [".cdi"] = (MediaType.DiscImage, "dreamcast"), + [".pbp"] = (MediaType.DiscImage, null), + [".3ds"] = (MediaType.Rom, "3ds"), + [".cia"] = (MediaType.Rom, "3ds"), + [".vpk"] = (MediaType.Archive, "psvita"), + [".md"] = (MediaType.Rom, "genesis"), + [".gen"] = (MediaType.Rom, "genesis"), + [".smd"] = (MediaType.Rom, "genesis"), + [".32x"] = (MediaType.Rom, "sega32x"), + [".sms"] = (MediaType.Rom, "mastersystem"), + [".gg"] = (MediaType.Rom, "gamegear"), + [".sg"] = (MediaType.Rom, "sg1000"), + [".xci"] = (MediaType.DiscImage, "switch"), + [".nsp"] = (MediaType.DiscImage, "switch"), + [".zip"] = (MediaType.Archive, null), + [".7z"] = (MediaType.Archive, null), + [".exe"] = (MediaType.WindowsPackage, "windows"), + [".msi"] = (MediaType.WindowsPackage, "windows"), + [".msix"] = (MediaType.WindowsPackage, "windows"), + [".appx"] = (MediaType.WindowsPackage, "windows"), + [".pdf"] = (MediaType.Document, null), + [".txt"] = (MediaType.Document, null) + }; + + /// <summary> + /// Platforms whose dumps are raw disc tracks. A bare BIN or IMG is only a disc track inside one + /// of these libraries; elsewhere it is far more likely to be an installer payload, so it stays + /// unclassified rather than being promoted to a disc image on a guess. + /// </summary> + private static readonly HashSet<string> DiscTrackPlatforms = new(StringComparer.OrdinalIgnoreCase) + { + "psx", "ps2", "segacd", "saturn", "dreamcast", "3do", "pcecd" + }; + + /// <summary> + /// Every extension the identifier recognises, without its leading dot. + /// </summary> + /// <remarks> + /// A derived title has to strip the same set, and that list used to be maintained separately in + /// SQL. It drifted: a Mega Drive ROM became "Sonic md" because the query had never heard of the + /// extension the identifier reads a Mega Drive header from. + /// </remarks> + public static IReadOnlyCollection<string> KnownExtensions { get; } = + Extensions.Keys.Select(key => key.TrimStart('.')).ToHashSet(StringComparer.OrdinalIgnoreCase); + + public static Classification Classify(string relativePath, ReadOnlySpan<byte> header, string? platformHint = null) + { + platformHint = CanonicalPlatform(platformHint); + var ext = Path.GetExtension(relativePath); + var evidence = new List<Evidence>(); + var now = DateTimeOffset.UtcNow; + MediaType? signatureType = null; + string? signature = null; + if (header.StartsWith("MZ"u8)) { signatureType = MediaType.WindowsPackage; signature = "PE/MZ"; } + else if (header.StartsWith("PK\x03\x04"u8)) { signatureType = MediaType.Archive; signature = "ZIP"; } + else if (header.StartsWith("NES\x1A"u8)) { signatureType = MediaType.Rom; signature = "iNES"; } + else if (header.StartsWith("MComprHD"u8)) { signatureType = MediaType.DiscImage; signature = "CHD"; } + else if (header.StartsWith("CISO"u8)) { signatureType = MediaType.DiscImage; signature = "PSP CSO"; } + else if (header.StartsWith("\0PBP"u8)) { signatureType = MediaType.DiscImage; signature = "PlayStation PBP"; } + else if (header.StartsWith("PFS0"u8)) { signatureType = MediaType.DiscImage; signature = "Switch PFS0"; } + else if (header.Length >= 0x104 && header.Slice(0x100, 4).SequenceEqual("HEAD"u8)) { signatureType = MediaType.DiscImage; signature = "Switch XCI"; } + else if (header.Length >= 8 && header.Slice(4, 4).SequenceEqual(new byte[] { 0x24, 0xFF, 0xAE, 0x51 })) { signatureType = MediaType.Rom; signature = "GBA"; } + else if (header.Length >= 4 && header[..4].SequenceEqual(new byte[] { 0x80, 0x37, 0x12, 0x40 })) { signatureType = MediaType.Rom; signature = "N64 big-endian"; } + else if (header.Length >= 4 && header[..4].SequenceEqual(new byte[] { 0x37, 0x80, 0x40, 0x12 })) { signatureType = MediaType.Rom; signature = "N64 byte-swapped"; } + else if (header.Length >= 4 && header[..4].SequenceEqual(new byte[] { 0x40, 0x12, 0x37, 0x80 })) { signatureType = MediaType.Rom; signature = "N64 little-endian"; } + // Nintendo optical media carry an exact magic word. Without it a bare .iso can only be told + // apart by its directory name, which is what left GameCube and Wii games unresolvable. + else if (header.Length >= 0x20 && BinaryPrimitives.ReadUInt32BigEndian(header.Slice(0x18, 4)) == 0x5D1C9EA3) + { signatureType = MediaType.DiscImage; signature = "Wii disc"; } + else if (header.Length >= 0x20 && BinaryPrimitives.ReadUInt32BigEndian(header.Slice(0x1C, 4)) == 0xC2339F3D) + { signatureType = MediaType.DiscImage; signature = "GameCube disc"; } + else if (header.Length >= 0x108 && header.Slice(0x100, 4).SequenceEqual("SEGA"u8)) { signatureType = MediaType.Rom; signature = "Sega cartridge"; } + // Master System and Game Gear share the "TMR SEGA" header. Its offset depends on ROM size, + // and the region nibble at +0x0F is what separates the two systems. + else if (TryFindSegaEightBitHeader(header, out var segaEightBit)) { signatureType = MediaType.Rom; signature = segaEightBit; } + else if (header.Length >= 0x150 && header.Slice(0x104, 4).SequenceEqual(new byte[] { 0xCE, 0xED, 0x66, 0x66 })) { signatureType = MediaType.Rom; signature = "Game Boy cartridge"; } + else if (header.Length >= 0x160 && header.Slice(0xC0, 4).SequenceEqual(new byte[] { 0x24, 0xFF, 0xAE, 0x51 })) { signatureType = MediaType.Rom; signature = "Nintendo DS cartridge"; } + else if (TryFindSnesHeader(header, out _)) { signatureType = MediaType.Rom; signature = "SNES cartridge"; } + if (signature is not null) evidence.Add(new("signature", signature, "builtin", "1", Confidence.Deterministic, now)); + if (signature is not null) AddRomSemanticEvidence(signature, header, evidence, now); + // MediaType.Rom is the default enum value, so a missing extension must be detected by the + // lookup result rather than by comparing the media type against default. Conflating the two + // classified every ROM extension without a matching header signature as Unknown. + var knownExtension = Extensions.TryGetValue(ext, out var byExtension); + if (!knownExtension && + (ext.Equals(".bin", StringComparison.OrdinalIgnoreCase) || ext.Equals(".img", StringComparison.OrdinalIgnoreCase)) && + platformHint is not null && DiscTrackPlatforms.Contains(platformHint)) + { + byExtension = (MediaType.DiscImage, platformHint); + knownExtension = true; + } + if (knownExtension) evidence.Add(new("extension", ext.ToLowerInvariant(), "builtin", "1", Confidence.Medium, now)); + var extensionConflictsWithDirectory = signatureType is null && platformHint is not null && byExtension.Platform is not null && + !platformHint.Equals(byExtension.Platform, StringComparison.OrdinalIgnoreCase); + if (extensionConflictsWithDirectory) + evidence.Add(new("platform.conflict", $"directory:{platformHint};extension:{byExtension.Platform}", "builtin", "1", Confidence.High, now)); + var type = signatureType ?? (!knownExtension || extensionConflictsWithDirectory ? MediaType.Unknown : byExtension.Type); + var signaturePlatform = signature switch + { + "iNES" => "nes", + "GBA" => "gba", + "N64 big-endian" or "N64 byte-swapped" or "N64 little-endian" => "n64", + "Sega cartridge" => SegaCartridgePlatform(header), + "Sega Master System cartridge" => "mastersystem", + "Sega Game Gear cartridge" => "gamegear", + "GameCube disc" => "gamecube", + "Wii disc" => "wii", + "Game Boy cartridge" => ext.Equals(".gbc", StringComparison.OrdinalIgnoreCase) ? "gbc" : "gb", + "Nintendo DS cartridge" => "nds", + "SNES cartridge" => "snes", + "PSP CSO" => "psp", + "Switch PFS0" or "Switch XCI" => "switch", + _ => null + }; + var platform = signaturePlatform ?? platformHint ?? byExtension.Platform; + if (signaturePlatform is not null && platformHint is not null && + !signaturePlatform.Equals(platformHint, StringComparison.OrdinalIgnoreCase)) + evidence.Add(new("platform.conflict", $"directory:{platformHint};signature:{signaturePlatform}", + "builtin", "1", Confidence.High, now)); + if (platformHint is not null) + evidence.Add(new("directory.platform", platformHint, "configured-path", "1", + signaturePlatform is null ? Confidence.High : Confidence.Medium, now)); + var confidence = signatureType is not null ? Confidence.Deterministic + : extensionConflictsWithDirectory ? Confidence.Low + : !knownExtension ? Confidence.None : Confidence.Medium; + return new(type, platform, confidence, evidence, type != MediaType.Unknown, $"classify:{ext.TrimStart('.').ToLowerInvariant()}"); + } + + private static void AddRomSemanticEvidence(string signature, ReadOnlySpan<byte> header, List<Evidence> evidence, DateTimeOffset observedAt) + { + void Add(string kind, string value) + { + if (!string.IsNullOrWhiteSpace(value)) evidence.Add(new($"rom.{kind}", value, "builtin-header", "1", Confidence.Deterministic, observedAt)); + } + switch (signature) + { + case "iNES" when header.Length >= 16: + Add("mapper", (((header[7] & 0xF0) | (header[6] >> 4))).ToString(CultureInfo.InvariantCulture)); + Add("prgBytes", (header[4] * 16 * 1024).ToString(CultureInfo.InvariantCulture)); + Add("chrBytes", (header[5] * 8 * 1024).ToString(CultureInfo.InvariantCulture)); + Add("mirroring", (header[6] & 0x08) != 0 ? "four-screen" : (header[6] & 1) != 0 ? "vertical" : "horizontal"); + break; + case "GBA" when header.Length >= 0xBD: + Add("title", HeaderText(header.Slice(0xA0, 12))); Add("gameCode", HeaderText(header.Slice(0xAC, 4))); + Add("makerCode", HeaderText(header.Slice(0xB0, 2))); Add("revision", header[0xBC].ToString(CultureInfo.InvariantCulture)); + Add("region", NintendoRegion(header[0xAF])); + break; + case "Nintendo DS cartridge" when header.Length >= 0x1F: + Add("title", HeaderText(header[..12])); Add("gameCode", HeaderText(header.Slice(12, 4))); + Add("makerCode", HeaderText(header.Slice(16, 2))); Add("unitCode", header[18].ToString(CultureInfo.InvariantCulture)); + Add("revision", header[30].ToString(CultureInfo.InvariantCulture)); Add("region", NintendoRegion(header[15])); + break; + case "Game Boy cartridge" when header.Length >= 0x150: + Add("title", HeaderText(header.Slice(0x134, 15))); Add("makerCode", HeaderText(header.Slice(0x144, 2))); + Add("cartridgeType", $"0x{header[0x147]:x2}"); Add("revision", header[0x14C].ToString(CultureInfo.InvariantCulture)); + Add("region", header[0x14A] == 0 ? "japan" : "world"); + break; + case "N64 big-endian" when header.Length >= 0x40: + Add("title", HeaderText(header.Slice(0x20, 20))); Add("gameCode", HeaderText(header.Slice(0x3B, 4))); + Add("region", NintendoRegion(header[0x3E])); + break; + case "Sega cartridge" when header.Length >= 0x200: + Add("console", HeaderText(header.Slice(0x100, 16))); + Add("domesticTitle", HeaderText(header.Slice(0x120, 48))); Add("internationalTitle", HeaderText(header.Slice(0x150, 48))); + Add("serial", HeaderText(header.Slice(0x180, 14))); Add("region", HeaderText(header.Slice(0x1F0, 3))); + break; + case "Sega Master System cartridge" or "Sega Game Gear cartridge" + when TryFindSegaEightBitHeader(header, out _) && TryFindSegaEightBitOffset(header, out var segaOffset): + // "TMR SEGA" is followed by a reserved word, a checksum, a five-digit BCD product + // code whose top digit shares a byte with the version, and the region/size byte. + Add("checksum", $"{header[segaOffset + 0x0B]:x2}{header[segaOffset + 0x0A]:x2}"); + Add("productCode", $"{header[segaOffset + 0x0E] >> 4:x1}{header[segaOffset + 0x0D]:x2}{header[segaOffset + 0x0C]:x2}"); + Add("revision", (header[segaOffset + 0x0E] & 0x0F).ToString(CultureInfo.InvariantCulture)); + Add("region", SegaEightBitRegion(header[segaOffset + 0x0F] >> 4)); + break; + case "GameCube disc" or "Wii disc" when header.Length >= 0x500: + Add("gameCode", HeaderText(header[..4])); + Add("makerCode", HeaderText(header.Slice(4, 2))); + Add("discNumber", header[6].ToString(CultureInfo.InvariantCulture)); + Add("revision", header[7].ToString(CultureInfo.InvariantCulture)); + Add("title", HeaderText(header.Slice(0x20, 64))); + Add("region", NintendoDiscRegion(header[3])); + break; + case "SNES cartridge" when TryFindSnesHeader(header, out var offset): + Add("title", HeaderText(header.Slice(offset, 21))); Add("mapMode", $"0x{header[offset + 0x15]:x2}"); + Add("cartridgeType", $"0x{header[offset + 0x16]:x2}"); Add("region", SnesRegion(header[offset + 0x19])); + Add("makerCode", $"0x{header[offset + 0x1A]:x2}"); Add("revision", header[offset + 0x1B].ToString(CultureInfo.InvariantCulture)); + break; + } + } + + /// <summary> + /// Separate a Mega Drive/Genesis cartridge from a 32X or Sega CD dump. All three carry "SEGA" + /// at 0x100; the rest of the console name field names the actual hardware. + /// </summary> + private static string SegaCartridgePlatform(ReadOnlySpan<byte> header) + { + if (header.Length < 0x110) return "genesis"; + var console = HeaderText(header.Slice(0x100, 16)).ToUpperInvariant(); + if (console.Contains("32X", StringComparison.Ordinal)) return "sega32x"; + if (console.Contains("SEGACD", StringComparison.Ordinal) || + console.Contains("MEGA CD", StringComparison.Ordinal)) return "segacd"; + return "genesis"; + } + + /// <summary> + /// Locate the "TMR SEGA" header an 8-bit Sega ROM places near the end of its first bank. The + /// region nibble in the byte after the header separates Master System from Game Gear. + /// </summary> + private static bool TryFindSegaEightBitOffset(ReadOnlySpan<byte> header, out int offset) + { + foreach (var candidate in new[] { 0x7FF0, 0x3FF0, 0x1FF0 }) + if (candidate + 0x10 <= header.Length && header.Slice(candidate, 8).SequenceEqual("TMR SEGA"u8)) + { offset = candidate; return true; } + offset = 0; + return false; + } + + private static string SegaEightBitRegion(int region) => region switch + { + 0x3 => "japan", + 0x4 => "export", + 0x5 => "japan", + 0x6 => "export", + 0x7 => "international", + _ => "unknown" + }; + + /// <summary>The fourth character of a GameCube or Wii game code is its region.</summary> + private static string NintendoDiscRegion(byte code) => (char)code switch + { + 'E' => "usa", + 'J' => "japan", + 'P' => "europe", + 'D' => "germany", + 'F' => "france", + 'I' => "italy", + 'S' => "spain", + 'K' or 'T' => "korea", + 'U' => "australia", + _ => "unknown" + }; + + private static bool TryFindSegaEightBitHeader(ReadOnlySpan<byte> header, out string signature) + { + foreach (var candidate in new[] { 0x7FF0, 0x3FF0, 0x1FF0 }) + { + if (candidate + 0x10 > header.Length) continue; + if (!header.Slice(candidate, 8).SequenceEqual("TMR SEGA"u8)) continue; + var region = header[candidate + 0x0F] >> 4; + signature = region is 0x5 or 0x6 or 0x7 + ? "Sega Game Gear cartridge" + : "Sega Master System cartridge"; + return true; + } + signature = string.Empty; + return false; + } + + private static bool TryFindSnesHeader(ReadOnlySpan<byte> header, out int offset) + { + foreach (var candidate in new[] { 0x7FC0, 0x81C0, 0xFFC0, 0x101C0 }) + { + if (candidate + 0x20 > header.Length) continue; + var mapMode = header[candidate + 0x15] & 0x3f; + var complement = BinaryPrimitives.ReadUInt16LittleEndian(header.Slice(candidate + 0x1C, 2)); + var checksum = BinaryPrimitives.ReadUInt16LittleEndian(header.Slice(candidate + 0x1E, 2)); + if (mapMode is 0x20 or 0x21 or 0x22 or 0x23 or 0x25 or 0x30 or 0x31 or 0x32 or 0x35 && + checksum is not 0 and not 0xffff && (checksum ^ complement) == 0xffff) + { offset = candidate; return true; } + } + offset = -1; + return false; + } + + private static string HeaderText(ReadOnlySpan<byte> bytes) => + new string(Encoding.ASCII.GetString(bytes).Where(character => character is >= ' ' and not '\u007f').ToArray()).Trim(' ', '\xff'); + private static string NintendoRegion(byte code) => char.ToUpperInvariant((char)code) switch + { + 'J' => "japan", + 'E' => "usa", + 'P' or 'D' or 'F' or 'I' or 'S' or 'H' or 'X' or 'Y' => "europe", + 'K' => "korea", + 'C' => "china", + 'A' => "world", + _ => "unknown" + }; + private static string SnesRegion(byte code) => code switch + { + 0 => "japan", + 1 or 13 or 15 => "usa", + 2 or 3 or 6 or 7 or 8 or 9 or 10 or 11 => "europe", + 4 => "scandinavia", + 5 => "sweden", + 12 => "canada", + 14 => "korea", + 16 => "brazil", + 17 => "australia", + _ => "unknown" + }; + + public static string? InferPlatformHint(string libraryName, string libraryPath, string relativePath) + { + foreach (var candidate in new[] { Path.GetFileName(Path.TrimEndingDirectorySeparator(libraryPath)), libraryName }) + if (candidate is not null && PlatformDirectories.TryGetValue(NormalizePlatformDirectory(candidate), out var rootPlatform)) + return rootPlatform; + + var segments = relativePath.Replace('\\', '/').Split('/', StringSplitOptions.RemoveEmptyEntries); + if (segments.Length == 0) return null; + var platformIndex = segments[0].Equals("roms", StringComparison.OrdinalIgnoreCase) ? 1 : 0; + return platformIndex < segments.Length && PlatformDirectories.TryGetValue(NormalizePlatformDirectory(segments[platformIndex]), out var platform) + ? platform : null; + } + + private static string NormalizePlatformDirectory(string value) => value.Trim().Replace("-Games", string.Empty, StringComparison.OrdinalIgnoreCase); + + public static async Task<HashResult> HashAsync(Stream stream, bool compatibilityHashes, CancellationToken cancellationToken) + { + using var sha256 = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); + using var sha1 = IncrementalHash.CreateHash(HashAlgorithmName.SHA1); + var crc = new Crc32(); + var buffer = ArrayPool<byte>.Shared.Rent(1024 * 1024); + long total = 0; + try + { + int read; + while ((read = await stream.ReadAsync(buffer.AsMemory(0, buffer.Length), cancellationToken)) > 0) + { + sha256.AppendData(buffer, 0, read); + if (compatibilityHashes) { sha1.AppendData(buffer, 0, read); crc.Append(buffer.AsSpan(0, read)); } + total += read; + } + return new(Convert.ToHexString(sha256.GetHashAndReset()).ToLowerInvariant(), + compatibilityHashes ? Convert.ToHexString(sha1.GetHashAndReset()).ToLowerInvariant() : string.Empty, + compatibilityHashes ? Convert.ToHexString(crc.GetCurrentHash()).ToLowerInvariant() : string.Empty, total); + } + finally { ArrayPool<byte>.Shared.Return(buffer); } + } + + public static IReadOnlyList<string> ParseCueReferences(string text) => CueFileRegex().Matches(text) + .Select(m => m.Groups[1].Success ? m.Groups[1].Value : m.Groups[2].Value).Where(x => !string.IsNullOrWhiteSpace(x)).ToArray(); + + public static IReadOnlyList<string> ParseM3uReferences(string text) => text.Replace("\r", string.Empty, StringComparison.Ordinal) + .Split('\n').Select(x => x.Trim()).Where(x => x.Length > 0 && !x.StartsWith('#')).ToArray(); + + public static bool IsSafeRelativeReference(string value) => !string.IsNullOrWhiteSpace(value) && value[0] is not '/' and not '\\' && !Path.IsPathFullyQualified(value) && + !value.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar).Any(x => x == ".."); + + [GeneratedRegex("(?im)^\\s*FILE\\s+(?:\"([^\"]+)\"|(\\S+))")] + private static partial Regex CueFileRegex(); +} + +public sealed record PeInspection(string Machine, string Kind, bool IsDll, bool IsManaged, + bool HasResources, string? Subsystem, IReadOnlyDictionary<string, string> VersionInfo, + Confidence Confidence, IReadOnlyList<Evidence> Evidence); + +public static partial class WindowsPackageAnalysis +{ + public static IReadOnlyDictionary<string, string> InspectMsiSummary(string path) + { + if (string.IsNullOrWhiteSpace(path) || !Path.IsPathFullyQualified(path)) throw new ArgumentException("MSI path must be absolute.", nameof(path)); + using var root = RootStorage.OpenRead(path); + using var summary = root.OpenStream("\u0005SummaryInformation"); + if (summary.Length > 1024 * 1024) throw new InvalidDataException("MSI summary information exceeds the configured limit."); + var bytes = new byte[summary.Length]; + summary.ReadExactly(bytes); + return ParsePropertySet(bytes); + } + + private static Dictionary<string, string> ParsePropertySet(ReadOnlySpan<byte> bytes) + { + if (bytes.Length < 48 || BinaryPrimitives.ReadUInt16LittleEndian(bytes) != 0xfffe) + throw new InvalidDataException("MSI summary information has an invalid property-set header."); + var sectionOffset = BinaryPrimitives.ReadInt32LittleEndian(bytes.Slice(44, 4)); + if (sectionOffset < 0 || sectionOffset + 8 > bytes.Length) throw new InvalidDataException("MSI summary section is out of bounds."); + var propertyCount = BinaryPrimitives.ReadInt32LittleEndian(bytes.Slice(sectionOffset + 4, 4)); + if (propertyCount is < 0 or > 256 || sectionOffset + 8 + propertyCount * 8 > bytes.Length) + throw new InvalidDataException("MSI summary property table exceeds its bounds."); + var names = new Dictionary<int, string> + { + [2] = "Title", + [3] = "Subject", + [4] = "Author", + [5] = "Keywords", + [6] = "Comments", + [7] = "Template", + [8] = "LastSavedBy", + [9] = "PackageCode", + [18] = "CreatingApplication" + }; + var result = new Dictionary<string, string>(StringComparer.Ordinal); + for (var index = 0; index < propertyCount; index++) + { + var entry = sectionOffset + 8 + index * 8; + var propertyId = BinaryPrimitives.ReadInt32LittleEndian(bytes.Slice(entry, 4)); + if (!names.TryGetValue(propertyId, out var name)) continue; + var valueOffset = sectionOffset + BinaryPrimitives.ReadInt32LittleEndian(bytes.Slice(entry + 4, 4)); + if (valueOffset < 0 || valueOffset + 8 > bytes.Length) continue; + var type = BinaryPrimitives.ReadInt32LittleEndian(bytes.Slice(valueOffset, 4)); + var length = BinaryPrimitives.ReadInt32LittleEndian(bytes.Slice(valueOffset + 4, 4)); + if (length is <= 0 or > 65536) continue; + string? value = type switch + { + 30 when valueOffset + 8 + length <= bytes.Length => Encoding.Latin1.GetString(bytes.Slice(valueOffset + 8, length)).TrimEnd('\0'), + 31 when valueOffset + 8 + length * 2 <= bytes.Length => Encoding.Unicode.GetString(bytes.Slice(valueOffset + 8, length * 2)).TrimEnd('\0'), + _ => null + }; + if (!string.IsNullOrWhiteSpace(value)) result[name] = value; + } + if (result.TryGetValue("Template", out var template)) + { + var parts = template.Split(';', 2); + if (parts[0].Length > 0) result["Architecture"] = parts[0]; + if (parts.Length > 1 && parts[1].Length > 0) result["Language"] = parts[1]; + } + return result; + } + + public static PeInspection InspectPe(Stream stream) + { + if (!stream.CanSeek) throw new ArgumentException("PE inspection requires a bounded seekable stream.", nameof(stream)); + var original = stream.Position; + try + { + using var reader = new PEReader(stream, PEStreamOptions.LeaveOpen); + if (!reader.HasMetadata && reader.PEHeaders.PEHeader is null) throw new BadImageFormatException("Not a valid PE image."); + var coff = reader.PEHeaders.CoffHeader; + var pe = reader.PEHeaders.PEHeader; + var machine = coff.Machine switch + { + Machine.I386 => "x86", + Machine.Amd64 => "x64", + Machine.Arm => "arm", + Machine.Arm64 => "arm64", + Machine.IA64 => "ia64", + _ => $"unknown-0x{(ushort)coff.Machine:x4}" + }; + var isDll = (coff.Characteristics & Characteristics.Dll) != 0; + var evidence = new List<Evidence> + { + new("pe.machine", machine, "pe-reader", "1", Confidence.Deterministic, DateTimeOffset.UtcNow), + new("pe.kind", isDll ? "library" : "executable", "pe-reader", "1", Confidence.Deterministic, DateTimeOffset.UtcNow) + }; + if (pe is not null) evidence.Add(new("pe.subsystem", pe.Subsystem.ToString(), "pe-reader", "1", Confidence.Deterministic, DateTimeOffset.UtcNow)); + var versionInfo = ReadVersionInfo(reader); + foreach (var (key, value) in versionInfo) + evidence.Add(new($"pe.version.{key}", value, "pe-version-resource", "1", Confidence.Deterministic, DateTimeOffset.UtcNow)); + return new(machine, isDll ? "library" : "executable", isDll, reader.HasMetadata, + pe?.ResourceTableDirectory.Size > 0, pe?.Subsystem.ToString(), versionInfo, Confidence.Deterministic, evidence); + } + finally { stream.Position = original; } + } + + private static Dictionary<string, string> ReadVersionInfo(PEReader reader) + { + const int maxResourceBytes = 4 * 1024 * 1024; + var directory = reader.PEHeaders.PEHeader?.ResourceTableDirectory; + if (directory is null || directory.Value.RelativeVirtualAddress == 0 || directory.Value.Size < 16) return new Dictionary<string, string>(); + try + { + var rootRva = directory.Value.RelativeVirtualAddress; + var resource = reader.GetSectionData(rootRva).GetContent(0, Math.Min(directory.Value.Size, maxResourceBytes)); + var bytes = resource.AsSpan(); + var typeDirectory = FindDirectoryEntry(bytes, 0, 16); + if (typeDirectory < 0) return new Dictionary<string, string>(); + var nameDirectory = FirstDirectoryChild(bytes, typeDirectory); + var dataEntry = nameDirectory < 0 ? -1 : FirstDataChild(bytes, nameDirectory); + if (dataEntry < 0 || dataEntry + 16 > bytes.Length) return new Dictionary<string, string>(); + var dataRva = BinaryPrimitives.ReadInt32LittleEndian(bytes.Slice(dataEntry, 4)); + var size = BinaryPrimitives.ReadInt32LittleEndian(bytes.Slice(dataEntry + 4, 4)); + if (size <= 0 || size > maxResourceBytes) return new Dictionary<string, string>(); + var versionBytes = reader.GetSectionData(dataRva).GetContent(0, size).AsSpan(); + var result = new Dictionary<string, string>(StringComparer.Ordinal); + foreach (var key in new[] { "ProductName", "ProductVersion", "FileVersion", "CompanyName", "FileDescription", "InternalName", "OriginalFilename" }) + if (ReadVersionString(versionBytes, key) is { Length: > 0 } value) result[key] = value; + return result; + } + catch (BadImageFormatException) { return new Dictionary<string, string>(); } + catch (ArgumentOutOfRangeException) { return new Dictionary<string, string>(); } + } + + private static int FindDirectoryEntry(ReadOnlySpan<byte> bytes, int directoryOffset, int id) + { + if (directoryOffset < 0 || directoryOffset + 16 > bytes.Length) return -1; + var count = BinaryPrimitives.ReadUInt16LittleEndian(bytes.Slice(directoryOffset + 12, 2)) + + BinaryPrimitives.ReadUInt16LittleEndian(bytes.Slice(directoryOffset + 14, 2)); + for (var index = 0; index < count; index++) + { + var entry = directoryOffset + 16 + index * 8; + if (entry + 8 > bytes.Length) return -1; + var name = BinaryPrimitives.ReadUInt32LittleEndian(bytes.Slice(entry, 4)); + if ((name & 0x80000000) == 0 && name == id) + return (int)(BinaryPrimitives.ReadUInt32LittleEndian(bytes.Slice(entry + 4, 4)) & 0x7fffffff); + } + return -1; + } + + private static int FirstDirectoryChild(ReadOnlySpan<byte> bytes, int directoryOffset) => FirstChild(bytes, directoryOffset, true); + private static int FirstDataChild(ReadOnlySpan<byte> bytes, int directoryOffset) => FirstChild(bytes, directoryOffset, false); + private static int FirstChild(ReadOnlySpan<byte> bytes, int directoryOffset, bool directory) + { + if (directoryOffset < 0 || directoryOffset + 24 > bytes.Length) return -1; + var count = BinaryPrimitives.ReadUInt16LittleEndian(bytes.Slice(directoryOffset + 12, 2)) + + BinaryPrimitives.ReadUInt16LittleEndian(bytes.Slice(directoryOffset + 14, 2)); + for (var index = 0; index < count; index++) + { + var entry = directoryOffset + 16 + index * 8; + if (entry + 8 > bytes.Length) return -1; + var offset = BinaryPrimitives.ReadUInt32LittleEndian(bytes.Slice(entry + 4, 4)); + if (((offset & 0x80000000) != 0) == directory) return (int)(offset & 0x7fffffff); + } + return -1; + } + + private static string? ReadVersionString(ReadOnlySpan<byte> bytes, string key) + { + var keyBytes = Encoding.Unicode.GetBytes(key + '\0'); + var keyOffset = bytes.IndexOf(keyBytes); + if (keyOffset < 6) return null; + var valueCharacters = BinaryPrimitives.ReadUInt16LittleEndian(bytes.Slice(keyOffset - 4, 2)); + var valueOffset = (keyOffset + keyBytes.Length + 3) & ~3; + var valueBytes = Math.Min(valueCharacters * 2, bytes.Length - valueOffset); + if (valueOffset < 0 || valueBytes <= 0) return null; + return Encoding.Unicode.GetString(bytes.Slice(valueOffset, valueBytes)).TrimEnd('\0').Trim(); + } + + public static IReadOnlyList<int> MissingNumberedPayloads(IEnumerable<string> fileNames) + { + var numbers = fileNames.Select(Path.GetFileName).Select(x => NumberedPayloadRegex().Match(x ?? string.Empty)) + .Where(x => x.Success).Select(x => int.Parse(x.Groups[1].Value, CultureInfo.InvariantCulture)).Distinct().Order().ToArray(); + if (numbers.Length < 2) return []; + return Enumerable.Range(numbers[0], numbers[^1] - numbers[0] + 1).Except(numbers).ToArray(); + } + + [GeneratedRegex("(?:^|[-_.])(\\d{1,4})\\.(?:bin|cab|dat)$", RegexOptions.IgnoreCase)] + private static partial Regex NumberedPayloadRegex(); +} diff --git a/src/Ludarium.Application/ArchiveAnalysis.cs b/src/Ludarium.Application/ArchiveAnalysis.cs new file mode 100644 index 0000000..99b7a24 --- /dev/null +++ b/src/Ludarium.Application/ArchiveAnalysis.cs @@ -0,0 +1,105 @@ +using System.IO.Compression; +using System.Security.Cryptography; +using System.Text; +using Ludarium.Domain; +using SharpCompress.Archives; +using SharpCompress.Archives.SevenZip; +using SharpCompress.Common; +using SharpCompress.Readers; + +namespace Ludarium.Application; + +public sealed record ArchiveLimits(int MaxMembers = 10_000, long MaxMetadataBytes = 4 * 1024 * 1024, + long MaxExpandedBytes = 8L * 1024 * 1024 * 1024, decimal MaxExpansionRatio = 1_000, + int MaxPathLength = 1_024); + +public sealed record ArchiveInspection(IReadOnlyList<ArchiveMember> Members, + IReadOnlyList<string> Findings, bool Complete); + +public static class ArchiveAnalysis +{ + public static ArchiveInspection InspectSevenZip(Guid artifactId, Stream stream, ArchiveLimits? limits = null, + CancellationToken cancellationToken = default) + { + limits ??= new ArchiveLimits(); + if (!stream.CanRead || !stream.CanSeek) throw new ArgumentException("7z inspection requires a readable, seekable stream.", nameof(stream)); + stream.Position = 0; + using var archive = SevenZipArchive.OpenArchive(stream, new ReaderOptions { LeaveStreamOpen = true }); + return InspectEntries(artifactId, archive.Entries.Select(entry => new MemberHeader( + entry.Key ?? string.Empty, entry.CompressedSize, entry.Size, entry.IsDirectory)), limits, cancellationToken); + } + + public static ArchiveInspection InspectZip(Guid artifactId, Stream stream, ArchiveLimits? limits = null, + CancellationToken cancellationToken = default) + { + limits ??= new ArchiveLimits(); + if (!stream.CanRead || !stream.CanSeek) throw new ArgumentException("ZIP inspection requires a readable, seekable stream.", nameof(stream)); + stream.Position = 0; + using var archive = new ZipArchive(stream, ZipArchiveMode.Read, leaveOpen: true); + return InspectEntries(artifactId, archive.Entries.Select(entry => new MemberHeader( + entry.FullName, entry.CompressedLength, entry.Length, entry.FullName.EndsWith('/'))), limits, cancellationToken); + } + + private static ArchiveInspection InspectEntries(Guid artifactId, IEnumerable<MemberHeader> entries, + ArchiveLimits limits, CancellationToken cancellationToken) + { + var members = new List<ArchiveMember>(); + var findings = new List<string>(); + long metadataBytes = 0; + long expandedBytes = 0; + long compressedBytes = 0; + + foreach (var entry in entries) + { + cancellationToken.ThrowIfCancellationRequested(); + if (members.Count >= limits.MaxMembers) + { + findings.Add($"Archive member limit of {limits.MaxMembers} was reached."); + break; + } + + metadataBytes = checked(metadataBytes + Encoding.UTF8.GetByteCount(entry.Path)); + if (metadataBytes > limits.MaxMetadataBytes) + { + findings.Add("Archive metadata exceeds the configured byte limit."); + break; + } + if (entry.Path.Length > limits.MaxPathLength) + { + findings.Add($"Archive member path exceeds {limits.MaxPathLength} characters."); + continue; + } + + expandedBytes = checked(expandedBytes + entry.UncompressedSize); + compressedBytes = checked(compressedBytes + entry.CompressedSize); + if (expandedBytes > limits.MaxExpandedBytes) + { + findings.Add("Declared expanded archive size exceeds the configured limit."); + break; + } + // Solid formats such as 7z may not expose a meaningful compressed size per member. + // Apply the ratio gate only when the archive supplied that evidence; the absolute + // expanded-size and member-count limits still bound header-only inspection. + if (expandedBytes > 0 && compressedBytes > 0 && (decimal)expandedBytes / compressedBytes > limits.MaxExpansionRatio) + { + findings.Add("Declared archive expansion ratio exceeds the configured limit."); + break; + } + + var normalized = entry.Path.Replace('\\', '/'); + var unsafePath = IsUnsafeMemberPath(normalized); + if (unsafePath) findings.Add("Archive contains a member path that would escape an extraction root; it was recorded but never extracted."); + var sequence = members.Count; + var memberId = new Guid(SHA256.HashData(Encoding.UTF8.GetBytes($"{artifactId:N}|{sequence}|{normalized}"))[..16]); + members.Add(new(memberId, artifactId, sequence, normalized, entry.CompressedSize, entry.UncompressedSize, + entry.IsDirectory, unsafePath, null)); + } + + return new(members, findings.Distinct(StringComparer.Ordinal).ToArray(), findings.Count == 0); + } + + private static bool IsUnsafeMemberPath(string path) => + Path.IsPathFullyQualified(path) || path.Split('/', StringSplitOptions.RemoveEmptyEntries).Any(x => x == ".."); + + private sealed record MemberHeader(string Path, long CompressedSize, long UncompressedSize, bool IsDirectory); +} diff --git a/src/Ludarium.Application/ArtworkQuality.cs b/src/Ludarium.Application/ArtworkQuality.cs new file mode 100644 index 0000000..c10d4b3 --- /dev/null +++ b/src/Ludarium.Application/ArtworkQuality.cs @@ -0,0 +1,184 @@ +using System.Collections.ObjectModel; +using Ludarium.Domain; + +namespace Ludarium.Application; + +public static class ArtworkQuality +{ + public const decimal VerifiedThreshold = 80m; + + // Width / height of the platform's canonical retail front. Keep this list in canonical-id order + // with PostgresStore.BuiltInPlatforms: the web stylesheet has a deterministic parity test that + // compares both surfaces. Platforms without a stable physical format use an explicit 2:3 + // archival-poster profile instead of silently falling through to an unrelated case family. + private static readonly Dictionary<string, decimal> RetailRatios = + new Dictionary<string, decimal>(StringComparer.OrdinalIgnoreCase) + { + ["windows"] = 5m / 7m, + ["dos"] = 4m / 5m, + ["linux"] = 2m / 3m, + ["macintosh"] = 4m / 5m, + ["amiga"] = 4m / 5m, + ["amiga-cd32"] = 1m / 1m, + ["c64"] = 4m / 5m, + ["zx-spectrum"] = 4m / 5m, + ["atari-st"] = 4m / 5m, + ["pc-98"] = 5m / 7m, + ["flash"] = 2m / 3m, + ["scummvm"] = 4m / 5m, + ["nes"] = 5m / 7m, + ["fds"] = 3m / 4m, + ["snes"] = 7m / 5m, + ["n64"] = 7m / 5m, + ["n64dd"] = 129m / 112m, + ["gamecube"] = 5m / 7m, + ["wii"] = 5m / 7m, + ["wii-u"] = 5m / 7m, + ["switch"] = 5m / 8m, + ["gb"] = 1m / 1m, + ["gbc"] = 1m / 1m, + ["gba"] = 1m / 1m, + ["nds"] = 135m / 122m, + ["3ds"] = 130m / 116m, + ["virtual-boy"] = 1m / 1m, + ["pokemon-mini"] = 1m / 1m, + ["psx"] = 1m / 1m, + ["ps2"] = 5m / 7m, + ["ps3"] = 27m / 34m, + ["ps4"] = 27m / 34m, + ["ps5"] = 27m / 34m, + ["psp"] = 3m / 5m, + ["psvita"] = 25m / 32m, + ["xbox"] = 5m / 7m, + ["xbox-360"] = 5m / 7m, + ["xbox-one"] = 27m / 34m, + ["xbox-series"] = 27m / 34m, + ["master-system"] = 5m / 7m, + ["genesis"] = 5m / 7m, + ["sega-cd"] = 5m / 7m, + ["sega-32x"] = 5m / 7m, + ["saturn"] = 5m / 7m, + ["dreamcast"] = 1m / 1m, + ["game-gear"] = 5m / 7m, + ["sg-1000"] = 5m / 7m, + ["atari-2600"] = 5m / 7m, + ["atari-5200"] = 5m / 7m, + ["atari-7800"] = 5m / 7m, + ["atari-jaguar"] = 5m / 7m, + ["atari-lynx"] = 13m / 16m, + ["neo-geo"] = 4m / 5m, + ["neo-geo-cd"] = 1m / 1m, + ["neo-geo-pocket"] = 7m / 8m, + ["neo-geo-pocket-color"] = 1m / 1m, + ["pc-engine"] = 1m / 1m, + ["pc-engine-cd"] = 1m / 1m, + ["pc-fx"] = 1m / 1m, + ["3do"] = 1m / 1m, + ["colecovision"] = 5m / 7m, + ["intellivision"] = 5m / 7m, + ["odyssey2"] = 5m / 7m, + ["vectrex"] = 5m / 7m, + ["wonderswan"] = 4m / 5m, + ["wonderswan-color"] = 2m / 3m, + ["arcade"] = 2m / 3m, + ["pico-8"] = 2m / 3m + }; + + // Historical scan output predates the hyphenated ids in the built-in catalog. It remains + // readable, but aliases do not inflate the canonical profile matrix or its coverage count. + private static readonly Dictionary<string, string> RetailRatioAliases = + new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase) + { + ["wiiu"] = "wii-u", + ["xbox360"] = "xbox-360", + ["xboxone"] = "xbox-one", + ["xboxseries"] = "xbox-series", + ["mastersystem"] = "master-system", + ["gamegear"] = "game-gear", + ["sega32x"] = "sega-32x", + ["segacd"] = "sega-cd" + }; + + public static IReadOnlyDictionary<string, decimal> RetailProfileRatios { get; } = + new ReadOnlyDictionary<string, decimal>(RetailRatios); + + public static decimal Score(int? width, int? height, decimal? ratio, string source, Confidence confidence, + string? platform = null) + { + if (width is null || height is null || width <= 0 || height <= 0) return 0; + // Steam library art is digital key art, not a photographed/scanned retail box front. + // It remains useful provenance, but must never satisfy Ludarium's retail-cover contract. + if (source.Equals("Steam", StringComparison.OrdinalIgnoreCase)) return 0; + ratio ??= (decimal)width.Value / height.Value; + var expected = source.Equals("SteamDigital", StringComparison.OrdinalIgnoreCase) + ? 2m / 3m + : ExpectedRatio(platform); + var deviation = Math.Abs(ratio.Value - expected) / expected; + decimal score = 0; + if (deviation <= 0.06m) score += 50; + else if (deviation <= 0.12m) score += 20; + else return source.Equals("Uploaded", StringComparison.OrdinalIgnoreCase) ? 35 : 0; + if (width >= 500) score += 20; + else if (width >= 300) score += 10; + if (height >= 700) score += 20; + else if (height >= 450) score += 10; + score += confidence switch { Confidence.Deterministic => 15, Confidence.High => 12, Confidence.Medium => 6, _ => 0 }; + if (source.Equals("Generated", StringComparison.OrdinalIgnoreCase)) score = Math.Min(score, 25); + if (source.Equals("Library", StringComparison.OrdinalIgnoreCase) && deviation > 0.06m) score = Math.Min(score, 45); + return Math.Min(score, 100); + } + + public static decimal ExpectedRatio(string? platform) + { + if (string.IsNullOrWhiteSpace(platform)) return 2m / 3m; + var id = platform.Trim(); + if (RetailRatioAliases.TryGetValue(id, out var canonical)) id = canonical; + return RetailRatios.TryGetValue(id, out var ratio) ? ratio : 2m / 3m; + } + + public static bool MatchesRetailProfile(GameArtwork artwork, string? platform) => + !artwork.Source.Equals("Steam", StringComparison.OrdinalIgnoreCase) && + (artwork.Source.Equals("Uploaded", StringComparison.OrdinalIgnoreCase) || + artwork.AspectRatio is { } ratio && Math.Abs(ratio - ExpectedRatio(platform)) / ExpectedRatio(platform) <= 0.12m); + + public static GameArtwork EvaluateForPlatform(GameArtwork artwork, string? platform) + { + if (artwork.Source.Equals("Uploaded", StringComparison.OrdinalIgnoreCase)) return artwork; + var score = Score(artwork.Width, artwork.Height, artwork.AspectRatio, artwork.Source, + artwork.Confidence, platform); + var verified = score >= VerifiedThreshold; + return artwork with + { + Platform = platform, + QualityScore = score, + Role = artwork.Source.Equals("SteamDigital", StringComparison.OrdinalIgnoreCase) + ? ArtworkRole.Poster + : verified ? ArtworkRole.VerifiedBoxFront : ArtworkRole.BoxFront, + VerificationStatus = verified + ? ArtworkVerificationStatus.AutomaticallyVerified + : ArtworkVerificationStatus.NeedsReview + }; + } + + public static GameArtwork? SelectBest(IEnumerable<GameArtwork?> candidates) => candidates + .Where(candidate => candidate is not null && candidate.VerificationStatus != ArtworkVerificationStatus.Rejected && + (candidate.QualityScore > 0 || candidate.Source.Equals("Uploaded", StringComparison.OrdinalIgnoreCase))) + .Cast<GameArtwork>() + .OrderByDescending(candidate => candidate.QualityScore) + .ThenByDescending(candidate => candidate.Confidence) + .ThenByDescending(candidate => SourcePriority(candidate.Source)) + .ThenByDescending(candidate => candidate.RetrievedAt) + .FirstOrDefault(); + + private static int SourcePriority(string source) => source.ToLowerInvariant() switch + { + "uploaded" => 5, + "worldofgames" => 4, + "launchbox" => 3, + "libretro" => 2, + "psxdatacenter" => 2, + "steamdigital" => 1, + "nintendo" => 1, + _ => 0 + }; +} diff --git a/src/Ludarium.Application/BrowserGameDataPolicy.cs b/src/Ludarium.Application/BrowserGameDataPolicy.cs new file mode 100644 index 0000000..1190b1d --- /dev/null +++ b/src/Ludarium.Application/BrowserGameDataPolicy.cs @@ -0,0 +1,75 @@ +using System.Text; +using System.Text.Json; +using Ludarium.Domain; + +namespace Ludarium.Application; + +public sealed record BrowserGameDataBinding(Guid ArtifactId, long ArtifactVersion, string Core, + string? ArtifactSha256); + +public static class BrowserGameDataPolicy +{ + public const string SourceType = "BrowserPlayerV1"; + public static IReadOnlyList<string> AutomaticRestoreCores { get; } = Array.AsReadOnly([ + "fceumm", + "gambatte", + "melonds", + "mgba", + "snes9x", + "ppsspp", + "pcsx_rearmed", + "n64wasm", + "genesis_plus_gx" + ]); + + public static bool SupportsAutomaticRestore(string? core) => + core is not null && AutomaticRestoreCores.Contains(core, StringComparer.OrdinalIgnoreCase); + + public static Guid StableEntryId(Guid gameId, string core, GameDataKind kind) + { + if (gameId == Guid.Empty || string.IsNullOrWhiteSpace(core)) + throw new ArgumentException("A game and emulator core are required for browser game data."); + var input = Encoding.UTF8.GetBytes($"ludarium-browser-data:{gameId:D}:{core.Trim().ToLowerInvariant()}:{kind}"); + var hash = System.Security.Cryptography.SHA256.HashData(input); + return new Guid(hash.AsSpan(0, 16)); + } + + public static string EncodeBinding(Guid artifactId, long artifactVersion, string core, string? artifactSha256) + { + if (!SupportsAutomaticRestore(core)) + throw new ArgumentException("Automatic restore is not fixture-verified for this core.", nameof(core)); + if (artifactId == Guid.Empty || artifactVersion < 1) + throw new ArgumentException("A versioned artifact identity is required for automatic restore."); + var binding = new BrowserGameDataBinding(artifactId, artifactVersion, core, + NormalizeSha256(artifactSha256)); + return Convert.ToBase64String(JsonSerializer.SerializeToUtf8Bytes(binding)); + } + + public static bool Matches(string? sourceType, string? sourceId, BrowserPlaySession session, + Artifact artifact) + { + if (!string.Equals(sourceType, SourceType, StringComparison.Ordinal) || string.IsNullOrWhiteSpace(sourceId) || + sourceId.Length > 2048 || !SupportsAutomaticRestore(session.Core)) return false; + try + { + var binding = JsonSerializer.Deserialize<BrowserGameDataBinding>(Convert.FromBase64String(sourceId)); + if (binding is null || binding.ArtifactId != session.ArtifactId || binding.ArtifactId != artifact.Id || + binding.ArtifactVersion != artifact.Version || + !string.Equals(binding.Core, session.Core, StringComparison.OrdinalIgnoreCase)) return false; + var expectedSha = NormalizeSha256(binding.ArtifactSha256); + if (binding.ArtifactSha256 is not null && expectedSha is null) return false; + return expectedSha is null || expectedSha.Equals(NormalizeSha256(artifact.Sha256), StringComparison.Ordinal); + } + catch (Exception exception) when (exception is FormatException or JsonException) + { + return false; + } + } + + private static string? NormalizeSha256(string? value) + { + if (string.IsNullOrWhiteSpace(value)) return null; + var normalized = value.Trim().ToLowerInvariant(); + return normalized.Length == 64 && normalized.All(Uri.IsHexDigit) ? normalized : null; + } +} diff --git a/src/Ludarium.Application/BrowserPlayPolicy.cs b/src/Ludarium.Application/BrowserPlayPolicy.cs new file mode 100644 index 0000000..0d7b3c7 --- /dev/null +++ b/src/Ludarium.Application/BrowserPlayPolicy.cs @@ -0,0 +1,168 @@ +using Ludarium.Domain; + +namespace Ludarium.Application; + +/// <summary> +/// One browser-playable platform. +/// </summary> +/// <param name="Extensions">Container formats a deterministic fixture has proven in the real core.</param> +/// <param name="CandidateExtensions"> +/// Formats whose delivery path is implemented but not yet fixture-proven. They are recognised so the +/// interface can explain them exactly, and they fail closed until their qualification pass has run. +/// </param> +public sealed record BrowserPlayPlatform(string Platform, string Core, bool DesktopRecommended, + IReadOnlySet<string> Extensions, bool ReleaseQualified, long MaximumBytes = 512L * 1024 * 1024, + IReadOnlySet<string>? CandidateExtensions = null) +{ + public bool Accepts(string? extension) => extension is not null && + (Extensions.Contains(extension) || CandidateExtensions?.Contains(extension) == true); + + public bool IsQualified(string? extension) => extension is not null && Extensions.Contains(extension); +} + +public static class BrowserPlayPolicy +{ + public const long MaximumRomBytes = 512L * 1024 * 1024; + + private static readonly Dictionary<string, BrowserPlayPlatform> Platforms = + new(StringComparer.OrdinalIgnoreCase) + { + // A platform may only become release-qualified after a deterministic ROM fixture has + // completed the real bundled core in the browser. Keep future mappings here so they + // remain explicit and fail closed until that evidence exists. + ["nes"] = new("nes", "fceumm", false, Set(".nes"), true), + ["snes"] = new("snes", "snes9x", false, Set(".sfc"), true, + CandidateExtensions: Set(".smc")), + ["gb"] = new("gb", "gambatte", false, Set(".gb"), true), + ["gbc"] = new("gbc", "gambatte", false, Set(".gbc"), true), + ["gba"] = new("gba", "mgba", false, Set(".gba"), true), + ["n64"] = new("n64", "n64wasm", true, Set(".n64", ".z64", ".v64", ".zip"), true), + ["nds"] = new("nds", "melonds", true, Set(".nds"), true), + // A CUE sheet stands for its whole disc set; Ludarium streams the sheet and its tracks + // together as one archive, so the size limit applies to the resolved set, not to the sheet. + // EmulatorJS mounts that archive, but the delivery has not completed a live fixture yet. + ["psx"] = new("psx", "pcsx_rearmed", true, Set(".chd"), true, + 4L * 1024 * 1024 * 1024, Set(".pbp", ".cue", ".m3u")), + ["psp"] = new("psp", "ppsspp", true, Set(".pbp"), true, + CandidateExtensions: Set(".iso", ".cso")), + // Play!.js range-reads the session content as a raw block device and boots it as an ISO, + // so it cannot mount an archive: no CUE set or playlist is offered here. Only the ISO + // fixture has ever completed the live gate, so every compressed container and the raw + // BIN track stay candidates until one proves the runtime decompresses them itself. + ["ps2"] = new("ps2", "playjs", true, Set(".iso"), true, + 8L * 1024 * 1024 * 1024, Set(".cso", ".chd", ".isz", ".bin")), + + // Sega hardware the bundled genesis_plus_gx core covers. Each completed the live core gate + // on its own pinned fixture: the core booted, ran, produced a savestate and restored it + // into a relaunched session. That is evidence for these fixtures, not a compatibility + // claim for every cartridge ever pressed. + ["genesis"] = new("genesis", "genesis_plus_gx", false, Set(".md"), true, + CandidateExtensions: Set(".gen", ".smd", ".bin")), + ["mastersystem"] = new("mastersystem", "genesis_plus_gx", false, Set(".sms"), true), + ["gamegear"] = new("gamegear", "genesis_plus_gx", false, Set(".gg"), true) + }; + + /// <summary> + /// EmulatorJS cores that must stay in the image because a mapped platform uses them. The image + /// build deletes every other core, so this list and that build step must never drift apart. + /// Ludarium's own players (Play!.js, N64Wasm) are not EmulatorJS cores and are excluded. + /// </summary> + public static IReadOnlyList<string> RequiredCores { get; } = + Platforms.Values.Select(platform => platform.Core) + .Where(core => core is not "playjs" and not "n64wasm") + .Distinct(StringComparer.Ordinal).Order(StringComparer.Ordinal).ToArray(); + + public static IReadOnlyList<BrowserPlayPlatform> DescribePlatforms() => + Platforms.Values.OrderBy(platform => platform.Platform, StringComparer.Ordinal).ToArray(); + + public static BrowserPlayPlatform? GetPlatform(string? platform) => + platform is not null && Platforms.TryGetValue(platform, out var value) ? value : null; + + public static BrowserPlayCapability Evaluate(BrowserPlayCandidate? candidate, bool configured, + bool emulatorHealthy, DateTimeOffset now) + { + if (candidate is null) + return new(Guid.Empty, false, BrowserPlayState.MissingRom, "No present ROM is linked to this game.", CheckedAt: now); + var platform = GetPlatform(candidate.Platform); + if (!configured) + return Unavailable(candidate, BrowserPlayState.Disabled, "Browser play is not configured.", now, platform); + if (!candidate.SourceReadOnly) + return Unavailable(candidate, BrowserPlayState.Disabled, "The source library is not verified read-only.", now, platform); + if (!emulatorHealthy) + return Unavailable(candidate, BrowserPlayState.EmulatorUnavailable, "The isolated emulator service is unavailable.", now, platform); + if (platform is null) + return Unavailable(candidate, BrowserPlayState.UnsupportedPlatform, "This platform is not allowlisted for browser play.", now); + if (!platform.ReleaseQualified) + return Unavailable(candidate, BrowserPlayState.UnsupportedPlatform, + "This browser-emulation core is bundled but is not release-qualified by a deterministic playback fixture.", now, platform); + if (!IsSafeRelativePath(candidate.RelativePath)) + return Unavailable(candidate, BrowserPlayState.UnsupportedFormat, "The linked ROM path is not a safe relative source path.", now, platform); + var extension = Path.GetExtension(candidate.RelativePath); + if (!platform.Accepts(extension) || candidate.Size <= 0 || candidate.Size > platform.MaximumBytes) + return Unavailable(candidate, BrowserPlayState.UnsupportedFormat, "This ROM format or size is not allowlisted for browser play.", now, platform); + if (!platform.IsQualified(extension)) + return Unavailable(candidate, BrowserPlayState.UnsupportedFormat, + "Ludarium can deliver this container format, but it is not release-qualified by a deterministic playback fixture.", + now, platform); + return new(candidate.GameId, true, BrowserPlayState.Available, "Ready to play in Ludarium's isolated browser player.", + platform.Platform, EmulatorName(platform.Core), platform.Core, platform.DesktopRecommended, now, + BrowserGameDataPolicy.SupportsAutomaticRestore(platform.Core)); + } + + /// <summary>Every container format any platform accepts, qualified or not.</summary> + public static IReadOnlySet<string> KnownExtensions { get; } = Platforms.Values + .SelectMany(platform => platform.Extensions.Concat(platform.CandidateExtensions ?? new HashSet<string>())) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + /// <summary>A CUE sheet stands for a whole disc rather than for a single playable file.</summary> + public static bool IsCueSheet(string? relativePath) => relativePath is not null && + Path.GetExtension(relativePath).Equals(".cue", StringComparison.OrdinalIgnoreCase); + + /// <summary>An M3U playlist stands for a whole multi-disc game.</summary> + public static bool IsPlaylist(string? relativePath) => relativePath is not null && + Path.GetExtension(relativePath).Equals(".m3u", StringComparison.OrdinalIgnoreCase); + + /// <summary> + /// Choose the one artifact a browser session may stream. + /// </summary> + /// <remarks> + /// Descriptors outrank what they describe, most general first: a playlist stands for the whole + /// game, a CUE sheet for one disc, and the track files for neither. Without that order a + /// multi-disc game looks ambiguous, because every disc and track is linked to the same game. + /// Two playlists, two sheets, or two unrelated playable files stay deliberately unresolvable — + /// Ludarium must never guess which disc to load. + /// </remarks> + public static BrowserPlayCandidate? SelectCandidate(IReadOnlyList<BrowserPlayCandidate> candidates) + { + var playlists = candidates.Where(candidate => IsPlaylist(candidate.RelativePath)).ToArray(); + if (playlists.Length > 0) return playlists.Length == 1 ? playlists[0] : null; + var sheets = candidates.Where(candidate => IsCueSheet(candidate.RelativePath)).ToArray(); + if (sheets.Length > 0) return sheets.Length == 1 ? sheets[0] : null; + return candidates.Count == 1 ? candidates[0] : null; + } + + public static string SafeFileName(string relativePath) => Path.GetFileName(relativePath.Replace('\\', '/')); + + private static bool IsSafeRelativePath(string path) + { + if (string.IsNullOrWhiteSpace(path) || path.StartsWith('/') || path.StartsWith('\\') + || Path.IsPathRooted(path) || path.Contains(':') || path.Contains('\0')) + return false; + var parts = path.Split(['/', '\\'], StringSplitOptions.RemoveEmptyEntries); + return parts.Length > 0 && parts.All(part => part is not "." and not ".."); + } + + private static BrowserPlayCapability Unavailable(BrowserPlayCandidate candidate, BrowserPlayState state, + string message, DateTimeOffset now, BrowserPlayPlatform? platform = null) => + new(candidate.GameId, false, state, message, candidate.Platform, EmulatorName(platform?.Core), platform?.Core, + platform?.DesktopRecommended ?? false, now); + + public static string EmulatorName(string? core) => core switch + { + "playjs" => "Ludarium Play!.js", + "n64wasm" => "Ludarium N64Wasm", + _ => "Ludarium EmulatorJS" + }; + + private static HashSet<string> Set(params string[] values) => new(values, StringComparer.OrdinalIgnoreCase); +} diff --git a/src/Ludarium.Application/CatalogImport.cs b/src/Ludarium.Application/CatalogImport.cs new file mode 100644 index 0000000..ed3edda --- /dev/null +++ b/src/Ludarium.Application/CatalogImport.cs @@ -0,0 +1,72 @@ +using System.Security.Cryptography; +using System.Text; +using System.Xml; +using Ludarium.Domain; + +namespace Ludarium.Application; + +public sealed class LogiqxCatalogImporter(ICatalogStore store) : ICatalogImporter +{ + private const int BatchSize = 500; + + public async Task<CatalogSource> ImportLogiqxAsync(string name, string version, Stream input, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(name)) throw new ArgumentException("Catalog name is required.", nameof(name)); + if (!input.CanRead) throw new ArgumentException("Catalog stream must be readable.", nameof(input)); + var sourceId = DeterministicGuid($"logiqx|{name.Trim()}|{version.Trim()}"); + var source = new CatalogSource(sourceId, name.Trim(), "LogiqxXml", version.Trim(), null, "Importing", DateTimeOffset.UtcNow, 0); + await store.UpsertCatalogSourceAsync(source, cancellationToken); + + using var sha256 = SHA256.Create(); + await using var hashingStream = new CryptoStream(input, sha256, CryptoStreamMode.Read, leaveOpen: true); + using var reader = XmlReader.Create(hashingStream, new XmlReaderSettings + { + Async = true, + DtdProcessing = DtdProcessing.Prohibit, + XmlResolver = null, + MaxCharactersInDocument = 512L * 1024 * 1024, + MaxCharactersFromEntities = 0, + IgnoreComments = true, + IgnoreProcessingInstructions = true + }); + var batch = new List<CatalogEntry>(BatchSize); + string? gameName = null; + var count = 0; + while (await reader.ReadAsync()) + { + cancellationToken.ThrowIfCancellationRequested(); + if (reader.NodeType == XmlNodeType.Element && (reader.Name.Equals("game", StringComparison.OrdinalIgnoreCase) || reader.Name.Equals("machine", StringComparison.OrdinalIgnoreCase))) + gameName = reader.GetAttribute("name"); + else if (reader.NodeType == XmlNodeType.EndElement && (reader.Name.Equals("game", StringComparison.OrdinalIgnoreCase) || reader.Name.Equals("machine", StringComparison.OrdinalIgnoreCase))) + gameName = null; + else if (reader.NodeType == XmlNodeType.Element && reader.Name.Equals("rom", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(gameName)) + { + var romName = reader.GetAttribute("name") ?? "unnamed"; + var size = long.TryParse(reader.GetAttribute("size"), out var parsedSize) ? (long?)parsedSize : null; + var sha = NormalizeHash(reader.GetAttribute("sha256"), 64); + var sha1 = NormalizeHash(reader.GetAttribute("sha1"), 40); + var crc = NormalizeHash(reader.GetAttribute("crc"), 8); + if (sha is null && sha1 is null && crc is null) continue; + var identity = $"{gameName}\n{romName}\n{size}\n{sha}\n{sha1}\n{crc}"; + batch.Add(new(DeterministicGuid($"{sourceId:N}|{identity}"), sourceId, gameName, romName, size, sha, sha1, crc, identity)); + count++; + if (batch.Count >= BatchSize) { await store.UpsertCatalogEntriesAsync(batch, cancellationToken); batch.Clear(); } + } + } + if (batch.Count > 0) await store.UpsertCatalogEntriesAsync(batch, cancellationToken); + var digest = Convert.ToHexString(sha256.Hash ?? throw new InvalidDataException("Catalog checksum was not finalized.")).ToLowerInvariant(); + source = source with { Sha256 = digest, State = "Ready", EntryCount = count, ImportedAt = DateTimeOffset.UtcNow }; + await store.UpsertCatalogSourceAsync(source, cancellationToken); + await store.MatchCatalogAsync(source.Id, cancellationToken); + return source; + } + + private static string? NormalizeHash(string? value, int length) + { + if (string.IsNullOrWhiteSpace(value)) return null; + var normalized = value.Trim().ToLowerInvariant(); + return normalized.Length == length && normalized.All(Uri.IsHexDigit) ? normalized : null; + } + + private static Guid DeterministicGuid(string value) => new(SHA256.HashData(Encoding.UTF8.GetBytes(value))[..16]); +} diff --git a/src/Ludarium.Application/CatalogVisibility.cs b/src/Ludarium.Application/CatalogVisibility.cs new file mode 100644 index 0000000..ca60472 --- /dev/null +++ b/src/Ludarium.Application/CatalogVisibility.cs @@ -0,0 +1,20 @@ +using System.Text.RegularExpressions; + +namespace Ludarium.Application; + +/// <summary> +/// The single catalog visibility specification used by in-memory policy checks and +/// PostgreSQL-backed catalog queries. Support content remains persisted as evidence, +/// but is not presented as a game. +/// </summary> +public static partial class CatalogVisibility +{ + public static bool IsVisibleTitle(string title) => + !string.IsNullOrWhiteSpace(title) && !ExcludedTitle().IsMatch(title.Trim()); + + public static string SqlPredicate(string alias = "g") => + $"NOT ({alias}.title ~* '^(steam|fmt-v[0-9].*|frosty[[:space:]]+mod[[:space:]]+tool.*|.*dlc[[:space:]]+unlocker.*)$')"; + + [GeneratedRegex(@"(?i)^(?:steam|fmt-v\d.*|frosty\s+mod\s+tool.*|.*dlc\s+unlocker.*)$")] + private static partial Regex ExcludedTitle(); +} diff --git a/src/Ludarium.Application/ClaimPolicy.cs b/src/Ludarium.Application/ClaimPolicy.cs new file mode 100644 index 0000000..0d8187c --- /dev/null +++ b/src/Ludarium.Application/ClaimPolicy.cs @@ -0,0 +1,17 @@ +using Ludarium.Domain; + +namespace Ludarium.Application; + +public static class ClaimPolicy +{ + public static MetadataClaim? SelectEffective(IEnumerable<MetadataClaim> claims) + { + var materialized = claims.ToArray(); + var superseded = materialized.Where(x => x.SupersedesId is not null).Select(x => x.SupersedesId!.Value).ToHashSet(); + return materialized.Where(x => !superseded.Contains(x.Id)) + .OrderByDescending(x => x.ManualLock) + .ThenByDescending(x => x.Confidence) + .ThenByDescending(x => x.CreatedAt) + .FirstOrDefault(); + } +} diff --git a/src/Ludarium.Application/Contracts.cs b/src/Ludarium.Application/Contracts.cs new file mode 100644 index 0000000..ca202f9 --- /dev/null +++ b/src/Ludarium.Application/Contracts.cs @@ -0,0 +1,380 @@ +using Ludarium.Domain; + +namespace Ludarium.Application; + +public sealed class ConcurrencyException(string message) : Exception(message); +public sealed class ResourceConflictException(string message) : Exception(message); +public sealed class SnapshotPreconditionException(string message) : Exception(message); + +public sealed record Page<T>(IReadOnlyList<T> Items, int PageNumber, int PageSize, long Total); +public sealed record WishlistSummary(long Total, long HighPriority, long Upcoming, long PriceDrops, long Acquired); +public sealed record GameDiscoveryResult(string Provider, string ExternalId, string Title, string? Platform, + DateTimeOffset? ReleaseDate, string? ArtworkUrl, string? StoreUrl); +public sealed record GameDiscoveryResponse(bool Configured, string Provider, IReadOnlyList<GameDiscoveryResult> Results, string? Message = null); +public sealed record GameCollectionInput(string Name, string? Description, CollectionKind Kind, + CollectionRule? Rule, bool Pinned); +public sealed record GameUserStateInput(bool Favorite, GamePlayStatus Status, int? Rating, + int? Difficulty, decimal? CompletionPercent, string? Notes, int PlayCount, + DateTimeOffset? LastPlayedAt); +public sealed record GameRelationInput(GameRelationKind Kind, string Title, Guid? RelatedGameId, + Guid? ArtifactId); +public sealed record GameMediaInput(GameMediaKind Kind, string Title, string? Location, + string? Provider = null, string? ExternalId = null); +public sealed record PlatformDefinitionInput(string Id, string Name, string Category, + string[] Aliases, bool Enabled); +public sealed record GameDataEntryInput(GameDataKind Kind, string Name, string? Emulator, + string? Device, string? Notes); +public sealed record GameDataMetadataInput(string Name, string? Emulator, string? Device, + string? Notes); +public sealed record MetadataFieldCandidate(string Field, string Value, string Provider, + string ExternalId, Confidence Confidence, string SourceVersion); +public sealed record ProviderArtworkCandidate(string Url, string Role, string Provider, + string ExternalId, Confidence Confidence); +public sealed record ProviderGameResult(string Provider, string ExternalId, string Title, + string? Platform, IReadOnlyList<MetadataFieldCandidate> Fields, + IReadOnlyList<ProviderArtworkCandidate> Artwork, + GameAchievementProgress? AchievementProgress = null); +public sealed record ProviderDescriptor(string Id, string Name, bool Configured, string State, + IReadOnlyList<string> Capabilities, bool RequiresInternet, string AttributionUrl, + string? Message = null, DateTimeOffset? LastSuccessfulSyncAt = null); +public sealed record ProviderHubResult(int Games, int ProvidersQueried, int Matched, + int ClaimsAdded, int ArtworkCandidates, int AchievementSnapshots, int ReviewItems, int Failed); +public sealed record ProviderValidationResult(string ProviderId, string Provider, string State, + bool Configured, bool Reachable, bool FixtureMatched, string Message, DateTimeOffset CheckedAt); +public sealed record BrowserPlayLaunch(BrowserPlaySession Session, string Token); +public sealed record BrowserPlayContent(Stream Content, string FileName, long Length, string? Sha256); +public sealed record FirmwareContent(Stream Content, long Length, string Sha256); +public sealed record FirmwareRequirement(string Platform, string Slot, FirmwareAssetKind Kind, + string Label, string RuntimeState, bool BrowserDelivery, long MaximumBytes, + IReadOnlySet<string> Extensions); +public sealed record FirmwareStatus(FirmwareRequirement Requirement, FirmwareAsset? Asset); +public sealed record AuthorizedBrowserPlaySource(BrowserPlaySession Session, ArtifactDetails Details); +public sealed record PlatformSummary(string Platform, long Artifacts, long Bytes, long Games); +public sealed record PlatformPlayabilitySummary(string Platform, long Supported, long Ready, long Blocked); +public sealed record PlayabilitySummary(long Supported, long Ready, long Blocked, long Unsupported, + IReadOnlyList<PlatformPlayabilitySummary> ByPlatform); +public sealed record DashboardSummary(long Libraries, long Artifacts, long PresentArtifacts, long Games, + long Releases, long Bundles, long TotalBytes, long HashedArtifacts, long RecognizedArtifacts, + long UnknownArtifacts, long MissingArtifacts, long DuplicateArtifacts, long DuplicateBytes, + long OpenReviews, long CriticalFindings, long ActiveScans, long RootsRequiringAttention, + long ExcludedSupportGames = 0, long UnresolvedGameCandidates = 0, + DateTimeOffset? LastFullScanAt = null, DateTimeOffset? LastIncrementalScanAt = null, + bool CatalogImportedWithoutScan = false, long OpenReviewRecords = 0, + long ArtworkNeedingReview = 0) +{ + public decimal? ConfidencePercent => PresentArtifacts == 0 ? null + : Math.Round((HashedArtifacts + RecognizedArtifacts) * 50m / PresentArtifacts, 1); +} +public sealed record ArtifactDetails(Artifact Artifact, LibraryRoot Library, IReadOnlyList<Evidence> Evidence, + IReadOnlyList<ArchiveMember> ArchiveMembers, IReadOnlyList<Bundle> Bundles, + IReadOnlyList<Finding> Findings, IReadOnlyList<Artifact> Duplicates); +public sealed record NativeLaunchCandidate(Guid ArtifactId, string RelativePath, long Size, bool SourceReadOnly); +public sealed record StorageSummary(long TotalBytes, long DuplicateBytes, IReadOnlyList<PlatformSummary> Platforms, + IReadOnlyList<LibraryStorageSummary> Libraries); +public sealed record LibraryStorageSummary(Guid LibraryId, string Name, long Artifacts, long Bytes, long Missing); +public sealed record HealthSummary(long Critical, long Warning, long Notice, long Unknown, long Missing, + long IncompleteBundles, IReadOnlyList<Finding> RecentFindings); +public sealed record HashResult(string Sha256, string Sha1, string Crc32, long BytesRead); +public sealed record Classification(MediaType MediaType, string? Platform, Confidence Confidence, + IReadOnlyList<Evidence> Evidence, bool Supported, string Capability); +public sealed record RootVerification(bool Exists, bool Readable, bool IsReadOnly, string Message, bool? HasEntries = null) +{ + public bool Available => Exists && Readable; +} +public sealed record OperationalSettings(int ReviewRetentionDays, int AuditRetentionDays, int ExportRetentionDays, + int DefaultPageSize, ScanMode DefaultScanMode, long Version, DateTimeOffset UpdatedAt) +{ + public static OperationalSettings Defaults => new(180, 365, 30, 50, ScanMode.Quick, 1, DateTimeOffset.UtcNow); +} +public sealed record GameDeletionResult(Game Game, IReadOnlyList<GameMedia> Media, + IReadOnlyList<GameDataRevision> DataRevisions); + +public interface ILudariumStore +{ + Task InitializeAsync(CancellationToken cancellationToken); + Task<int> GetSchemaVersionAsync(CancellationToken cancellationToken); + Task<IReadOnlyList<LibraryRoot>> ListLibrariesAsync(CancellationToken cancellationToken); + Task<LibraryRoot?> GetLibraryAsync(Guid id, CancellationToken cancellationToken); + Task<IReadOnlyList<LibraryHealthSummary>> ListLibraryHealthAsync(CancellationToken cancellationToken); + Task UpsertLibraryAsync(LibraryRoot root, CancellationToken cancellationToken); + Task<ScanRun> CreateScanAsync(Guid libraryId, ScanMode mode, string? idempotencyKey, CancellationToken cancellationToken); + Task<IReadOnlyList<ScanRun>> ListScansAsync(CancellationToken cancellationToken); + Task<ScanRun?> GetScanAsync(Guid id, CancellationToken cancellationToken); + Task UpdateScanAsync(ScanRun scan, CancellationToken cancellationToken); + Task UpsertArtifactAsync(Artifact artifact, ContentBlob? content, IReadOnlyList<Evidence> evidence, CancellationToken cancellationToken); + Task MarkArtifactIgnoredAsync(Guid artifactId, long nextVersion, Evidence evidence, CancellationToken cancellationToken); + Task<ArtifactOverride?> GetArtifactOverrideAsync(Guid artifactId, CancellationToken cancellationToken); + Task<Artifact?> FindByPathAsync(Guid libraryId, string relativePath, CancellationToken cancellationToken); + Task<Artifact?> FindByHashAsync(Guid libraryId, long size, string sha256, CancellationToken cancellationToken); + Task<long> MarkMissingExceptAsync(Guid libraryId, IReadOnlySet<string> observedPaths, DateTimeOffset at, CancellationToken cancellationToken); + Task<Page<Artifact>> SearchArtifactsAsync(string? query, int page, int pageSize, CancellationToken cancellationToken); + Task<ArtifactDetails?> GetArtifactDetailsAsync(Guid id, CancellationToken cancellationToken); + Task<DashboardSummary> GetDashboardSummaryAsync(CancellationToken cancellationToken); + Task<PlayabilitySummary> GetPlayabilitySummaryAsync(CancellationToken cancellationToken) => + Task.FromResult(new PlayabilitySummary(0, 0, 0, 0, [])); + Task<StorageSummary> GetStorageSummaryAsync(CancellationToken cancellationToken); + Task<HealthSummary> GetHealthSummaryAsync(CancellationToken cancellationToken); + Task<IReadOnlyList<Artifact>> GetDuplicatesAsync(CancellationToken cancellationToken); + Task ReplaceArchiveMembersAsync(Guid artifactId, IReadOnlyList<ArchiveMember> members, CancellationToken cancellationToken); + Task<IReadOnlyList<ArchiveMember>> ListArchiveMembersAsync(Guid artifactId, CancellationToken cancellationToken); + Task AddBundleAsync(Bundle bundle, CancellationToken cancellationToken); + Task<IReadOnlyList<Bundle>> ListBundlesAsync(CancellationToken cancellationToken); + Task AddReviewAsync(ReviewItem item, CancellationToken cancellationToken); + Task<IReadOnlyList<ReviewItem>> ListReviewsAsync(CancellationToken cancellationToken); + Task<Page<ReviewItem>> SearchReviewsAsync(ReviewState? state, string? reason, int page, int pageSize, CancellationToken cancellationToken); + Task<IReadOnlyList<ReviewGroup>> ListReviewGroupsAsync(ReviewState state, CancellationToken cancellationToken); + async Task<Page<ReviewGroup>> SearchReviewGroupsAsync(ReviewGroupQuery query, + CancellationToken cancellationToken) + { + var page = Math.Max(1, query.Page); + var pageSize = Math.Clamp(query.PageSize, 1, 100); + IEnumerable<ReviewGroup> groups = await ListReviewGroupsAsync(query.State, cancellationToken); + if (query.Category?.Equals("actionable", StringComparison.OrdinalIgnoreCase) == true) + groups = groups.Where(group => group.Category is not ("unknown" or "duplicates")); + else if (!string.IsNullOrWhiteSpace(query.Category)) + groups = groups.Where(group => group.Category.Equals(query.Category, StringComparison.OrdinalIgnoreCase)); + if (!string.IsNullOrWhiteSpace(query.Search)) + { + var search = query.Search.Trim(); + groups = groups.Where(group => $"{group.Reason} {group.PathPattern} {group.RecommendedAction} {group.Library} {group.Platform}" + .Contains(search, StringComparison.OrdinalIgnoreCase)); + } + if (!string.IsNullOrWhiteSpace(query.Library)) groups = groups.Where(group => group.Library == query.Library); + if (!string.IsNullOrWhiteSpace(query.Platform)) groups = groups.Where(group => group.Platform == query.Platform); + if (query.Severity is not null) groups = groups.Where(group => group.Severity == query.Severity); + if (query.Since is not null) groups = groups.Where(group => group.NewestAt >= query.Since); + groups = query.Order switch + { + ReviewGroupOrder.Impact => groups.OrderByDescending(group => group.EstimatedBytes).ThenByDescending(group => group.Count), + ReviewGroupOrder.Newest => groups.OrderByDescending(group => group.NewestAt), + _ => groups.OrderByDescending(group => group.Severity).ThenByDescending(group => group.EstimatedBytes).ThenByDescending(group => group.Count) + }; + var materialized = groups.ToArray(); + return new(materialized.Skip((page - 1) * pageSize).Take(pageSize).ToArray(), page, pageSize, materialized.LongLength); + } + Task<ReviewOperation> ResolveReviewGroupAsync(IReadOnlyList<Guid> ids, int expectedCount, string resolution, string actor, CancellationToken cancellationToken); + Task<ReviewOperation> DeferReviewGroupAsync(IReadOnlyList<Guid> ids, int expectedCount, string reason, string actor, CancellationToken cancellationToken); + Task<ReviewOperation> ReopenReviewGroupAsync(IReadOnlyList<Guid> ids, int expectedCount, ReviewState sourceState, string reason, string actor, CancellationToken cancellationToken); + Task<ReviewOperation> ApplyReviewGroupActionAsync(IReadOnlyList<Guid> ids, int expectedCount, ReviewBulkAction action, string? platform, string? pathPattern, string actor, CancellationToken cancellationToken); + Task UndoReviewOperationAsync(Guid id, string actor, CancellationToken cancellationToken); + Task<ReviewItem> SetReviewStateAsync(Guid id, ReviewState state, string actor, CancellationToken cancellationToken); + Task<ManualResolution> ResolveReviewAsync(Guid id, string resolution, string actor, CancellationToken cancellationToken); + Task UndoResolutionAsync(Guid id, string actor, CancellationToken cancellationToken); + Task AddClaimAsync(MetadataClaim claim, CancellationToken cancellationToken); + Task<IReadOnlyList<MetadataClaim>> ListClaimsAsync(string entityType, Guid entityId, CancellationToken cancellationToken); + Task<Game> CreateGameAsync(string title, string actor, CancellationToken cancellationToken); + Task<GameDeletionResult> DeleteGameAsync(Guid id, long expectedVersion, string actor, + CancellationToken cancellationToken); + Task<Game> UpdateGameAsync(Guid id, string title, long expectedVersion, string actor, CancellationToken cancellationToken); + Task<Game?> GetGameAsync(Guid id, CancellationToken cancellationToken); + Task<IReadOnlyList<Game>> ListGamesAsync(CancellationToken cancellationToken); + Task<Page<Game>> SearchGamesAsync(GameQuery query, CancellationToken cancellationToken); + + /// <summary>Convenience overload for callers that only page through the whole catalog.</summary> + Task<Page<Game>> SearchGamesAsync(string? query, string? platform, int page, int pageSize, + CancellationToken cancellationToken) => + SearchGamesAsync(new GameQuery { Search = query, Platform = platform, Page = page, PageSize = pageSize }, + cancellationToken); + Task<Page<WishlistItem>> SearchWishlistAsync(string? query, string? platform, WishlistPriority? priority, WishlistStatus? status, string? sort, int page, int pageSize, CancellationToken cancellationToken); + Task<WishlistSummary> GetWishlistSummaryAsync(CancellationToken cancellationToken); + Task<WishlistItem> CreateWishlistItemAsync(WishlistInput input, string actor, CancellationToken cancellationToken); + Task<WishlistItem> UpdateWishlistItemAsync(Guid id, WishlistInput input, long expectedVersion, string actor, CancellationToken cancellationToken); + Task DeleteWishlistItemAsync(Guid id, long expectedVersion, string actor, CancellationToken cancellationToken); + Task<int> ReconcileWishlistAsync(string actor, CancellationToken cancellationToken); + Task<int> SynchronizeDiscoveredGamesAsync(Guid libraryId, CancellationToken cancellationToken); + Task<IReadOnlyList<PlatformSummary>> ListPlatformsAsync(CancellationToken cancellationToken); + Task<Release> CreateReleaseAsync(Guid gameId, string title, string? platform, string? region, string? revision, string actor, CancellationToken cancellationToken); + Task<Release> UpdateReleaseAsync(Guid id, string title, string? platform, string? region, string? revision, long expectedVersion, string actor, CancellationToken cancellationToken); + Task DeleteReleaseAsync(Guid id, long expectedVersion, string actor, CancellationToken cancellationToken); + Task<IReadOnlyList<Release>> ListReleasesAsync(Guid? gameId, CancellationToken cancellationToken); + + /// <summary>Releases for exactly the games named, so a browser page never loads the whole catalog.</summary> + Task<IReadOnlyList<Release>> ListReleasesForGamesAsync(IReadOnlyList<Guid> gameIds, + CancellationToken cancellationToken); + Task<ArtifactDetails?> GetGameArtworkAsync(Guid gameId, CancellationToken cancellationToken); + Task UpsertGameArtworkAsync(GameArtwork artwork, CancellationToken cancellationToken); + Task<GameArtwork?> GetSelectedGameArtworkAsync(Guid gameId, CancellationToken cancellationToken, + string? platform = null); + Task<IReadOnlyList<GameArtwork>> ListGameArtworkAsync(Guid gameId, CancellationToken cancellationToken); + Task SelectGameArtworkAsync(Guid gameId, Guid artworkId, CancellationToken cancellationToken); + Task<IReadOnlyList<GameArtwork>> ListArtworkNeedingReviewAsync(CancellationToken cancellationToken); + Task<IReadOnlyList<ArtworkReviewItem>> ListArtworkReviewItemsAsync(CancellationToken cancellationToken); + Task<BackgroundJob> EnqueueJobAsync(string kind, string payloadJson, string? idempotencyKey, CancellationToken cancellationToken); + Task<BackgroundJob?> LeaseJobAsync(string worker, TimeSpan lease, CancellationToken cancellationToken); + Task UpdateJobAsync(BackgroundJob job, CancellationToken cancellationToken); + Task<IReadOnlyList<BackgroundJob>> ListJobsAsync(CancellationToken cancellationToken); + Task<BackgroundJob?> GetJobAsync(Guid id, CancellationToken cancellationToken); + Task RequestJobCancellationAsync(Guid id, CancellationToken cancellationToken); + Task<BackgroundJob> RetryJobAsync(Guid id, CancellationToken cancellationToken); + Task UpsertProviderSnapshotAsync(ProviderSnapshot snapshot, CancellationToken cancellationToken); + Task<IReadOnlyList<ProviderSnapshot>> ListProviderSnapshotsAsync(CancellationToken cancellationToken); + Task<IReadOnlyList<Finding>> ListFindingsAsync(CancellationToken cancellationToken); + Task AddFindingAsync(Finding finding, CancellationToken cancellationToken); + Task<IntegritySnapshot> CreateSnapshotAsync(string name, CancellationToken cancellationToken); + Task<IReadOnlyList<IntegritySnapshot>> ListSnapshotsAsync(CancellationToken cancellationToken); + Task<IntegritySnapshot?> GetSnapshotAsync(Guid id, CancellationToken cancellationToken); + Task<OperationalSettings> GetOperationalSettingsAsync(CancellationToken cancellationToken); + Task<OperationalSettings> SaveOperationalSettingsAsync(OperationalSettings settings, long expectedVersion, CancellationToken cancellationToken); +} + +public interface IBrowserPlayStore +{ + Task<BrowserPlayCandidate?> GetBrowserPlayCandidateAsync(Guid gameId, CancellationToken cancellationToken); + Task<IReadOnlyList<NativeLaunchCandidate>> ListSwitchLaunchCandidatesAsync(Guid gameId, CancellationToken cancellationToken) => + Task.FromResult<IReadOnlyList<NativeLaunchCandidate>>([]); + Task<IReadOnlyList<NativeLaunchCandidate>> ListDolphinLaunchCandidatesAsync(Guid gameId, string platform, + CancellationToken cancellationToken) => Task.FromResult<IReadOnlyList<NativeLaunchCandidate>>([]); + Task<int> CountActiveBrowserPlaySessionsAsync(DateTimeOffset now, CancellationToken cancellationToken); + Task SaveBrowserPlaySessionAsync(BrowserPlaySession session, string actor, CancellationToken cancellationToken); + Task<BrowserPlaySession?> GetBrowserPlaySessionAsync(Guid sessionId, CancellationToken cancellationToken); + Task EndBrowserPlaySessionAsync(Guid sessionId, PlaySessionState state, string actor, + string? errorCode, string? errorMessage, CancellationToken cancellationToken); +} + +public interface IFirmwareStore +{ + Task<IReadOnlyList<FirmwareAsset>> ListFirmwareAssetsAsync(CancellationToken cancellationToken); + Task<FirmwareAsset?> GetFirmwareAssetAsync(Guid id, CancellationToken cancellationToken); + Task<FirmwareAsset?> GetSelectedFirmwareAssetAsync(string platform, string slot, CancellationToken cancellationToken); + Task SaveFirmwareAssetAsync(FirmwareAsset asset, string actor, CancellationToken cancellationToken); + Task<IReadOnlyList<FirmwareAsset>> DeleteFirmwareAssetAsync(Guid id, long expectedVersion, string actor, + CancellationToken cancellationToken); +} + +public interface IBrowserPlayService +{ + Task<BrowserPlayCapability> GetCapabilityAsync(Guid gameId, CancellationToken cancellationToken); + Task<BrowserPlayLaunch> StartAsync(Guid gameId, string actor, CancellationToken cancellationToken); + Task<BrowserPlaySession?> GetSessionAsync(Guid sessionId, CancellationToken cancellationToken); + Task<BrowserPlaySession?> AuthorizeAsync(Guid sessionId, string token, CancellationToken cancellationToken); + Task<AuthorizedBrowserPlaySource?> AuthorizeSourceAsync(Guid sessionId, string token, CancellationToken cancellationToken); + Task<BrowserPlayContent?> OpenContentAsync(Guid sessionId, string token, CancellationToken cancellationToken); + Task<FirmwareContent?> OpenFirmwareAsync(Guid sessionId, string token, CancellationToken cancellationToken); + Task CancelAsync(Guid sessionId, string actor, CancellationToken cancellationToken); +} + +public interface IGameDiscoveryService +{ + bool Configured { get; } + Task<GameDiscoveryResponse> SearchAsync(string query, CancellationToken cancellationToken); +} + +public interface ILibraryExperienceStore +{ + Task<IReadOnlyList<GameGroup>> ListCollectionsAsync(CancellationToken cancellationToken); + Task<GameGroup?> GetCollectionAsync(Guid id, CancellationToken cancellationToken); + Task<GameGroup> CreateCollectionAsync(GameCollectionInput input, string actor, CancellationToken cancellationToken); + Task<GameGroup> UpdateCollectionAsync(Guid id, GameCollectionInput input, long expectedVersion, string actor, CancellationToken cancellationToken); + Task DeleteCollectionAsync(Guid id, long expectedVersion, string actor, CancellationToken cancellationToken); + Task AddCollectionGameAsync(Guid collectionId, Guid gameId, string actor, CancellationToken cancellationToken); + Task RemoveCollectionGameAsync(Guid collectionId, Guid gameId, string actor, CancellationToken cancellationToken); + Task<Page<Game>> SearchCollectionGamesAsync(Guid collectionId, int page, int pageSize, CancellationToken cancellationToken); + Task<IReadOnlyList<TagSummary>> ListTagsAsync(CancellationToken cancellationToken); + Task<IReadOnlyList<GameTag>> ListGameTagsAsync(Guid gameId, CancellationToken cancellationToken); + Task<GameTag> AddGameTagAsync(Guid gameId, string name, string actor, CancellationToken cancellationToken); + Task DeleteGameTagAsync(Guid gameId, Guid tagId, string actor, CancellationToken cancellationToken); + Task<GameUserState> GetGameUserStateAsync(Guid gameId, CancellationToken cancellationToken); + Task<GameUserState> SaveGameUserStateAsync(Guid gameId, GameUserStateInput input, long expectedVersion, string actor, CancellationToken cancellationToken); + Task<GameUserState> RecordGamePlayedAsync(Guid gameId, string actor, CancellationToken cancellationToken); + Task<IReadOnlyList<RecentlyPlayedGame>> ListRecentlyPlayedGamesAsync(int limit, CancellationToken cancellationToken); + Task<IReadOnlyList<GameRelation>> ListGameRelationsAsync(Guid gameId, CancellationToken cancellationToken); + Task<GameRelation> AddGameRelationAsync(Guid gameId, GameRelationInput input, string actor, CancellationToken cancellationToken); + Task DeleteGameRelationAsync(Guid gameId, Guid relationId, string actor, CancellationToken cancellationToken); + Task<IReadOnlyList<GameMedia>> ListGameMediaAsync(Guid gameId, CancellationToken cancellationToken); + Task<GameMedia> AddExternalGameMediaAsync(Guid gameId, GameMediaInput input, string actor, CancellationToken cancellationToken); + Task UpsertGameMediaAsync(GameMedia media, CancellationToken cancellationToken); + Task<GameMedia?> GetGameMediaAsync(Guid mediaId, CancellationToken cancellationToken); + Task DeleteGameMediaAsync(Guid gameId, Guid mediaId, string actor, CancellationToken cancellationToken); + Task<GameAchievementProgress?> GetAchievementProgressAsync(Guid gameId, CancellationToken cancellationToken); + Task UpsertAchievementProgressAsync(GameAchievementProgress progress, CancellationToken cancellationToken); + Task<IReadOnlyList<PlatformDefinition>> ListPlatformDefinitionsAsync(CancellationToken cancellationToken); + Task<PlatformDefinition> CreatePlatformDefinitionAsync(PlatformDefinitionInput input, string actor, CancellationToken cancellationToken); + Task<PlatformDefinition> UpdatePlatformDefinitionAsync(string id, PlatformDefinitionInput input, long expectedVersion, string actor, CancellationToken cancellationToken); +} + +public interface IGameDataVaultStore +{ + Task<IReadOnlyList<GameDataItem>> ListGameDataAsync(Guid gameId, CancellationToken cancellationToken); + Task<GameDataItem?> GetGameDataAsync(Guid entryId, CancellationToken cancellationToken); + Task<Page<GameDataRevision>> ListGameDataRevisionsAsync(Guid entryId, int page, int pageSize, + CancellationToken cancellationToken); + Task<GameDataRevision?> GetGameDataRevisionAsync(Guid revisionId, CancellationToken cancellationToken); + Task<GameDataItem> SaveGameDataRevisionAsync(Guid gameId, Guid entryId, GameDataEntryInput input, + long expectedVersion, GameDataRevision revision, string actor, CancellationToken cancellationToken); + Task<GameDataItem> UpdateGameDataAsync(Guid gameId, Guid entryId, GameDataMetadataInput input, + long expectedVersion, string actor, CancellationToken cancellationToken); + Task<IReadOnlyList<GameDataRevision>> DeleteGameDataAsync(Guid gameId, Guid entryId, + long expectedVersion, string actor, CancellationToken cancellationToken); + Task<GameDataSummary> GetGameDataSummaryAsync(Guid? gameId, CancellationToken cancellationToken); + Task<IReadOnlyList<string>> ListGameDataRevisionLocationsAsync(CancellationToken cancellationToken); +} + +public interface IGameMetadataProvider +{ + ProviderDescriptor Describe(); + Task<ProviderGameResult?> EnrichAsync(Game game, Release? release, CancellationToken cancellationToken); +} + +public interface IGameProviderHub +{ + IReadOnlyList<ProviderDescriptor> DescribeProviders(); + Task<ProviderValidationResult> ValidateAsync(string providerId, CancellationToken cancellationToken); + Task<ProviderHubResult> EnrichAsync(IReadOnlyList<Game> games, IReadOnlyList<Release> releases, + CancellationToken cancellationToken, Func<int, int, Task>? progress = null); +} + +public interface ICatalogStore +{ + Task UpsertCatalogSourceAsync(CatalogSource source, CancellationToken cancellationToken); + Task UpsertCatalogEntriesAsync(IReadOnlyList<CatalogEntry> entries, CancellationToken cancellationToken); + Task<IReadOnlyList<CatalogSource>> ListCatalogSourcesAsync(CancellationToken cancellationToken); + Task<int> MatchCatalogAsync(Guid sourceId, CancellationToken cancellationToken); + Task<IReadOnlyList<MatchCandidate>> ListMatchCandidatesAsync(CancellationToken cancellationToken); +} + +public interface IReadOnlyLibraryFileSystem +{ + RootVerification Verify(string path); + IAsyncEnumerable<FileObservation> EnumerateAsync(LibraryRoot root, CancellationToken cancellationToken); + ValueTask<Stream> OpenReadAsync(LibraryRoot root, string relativePath, CancellationToken cancellationToken); + string ResolveContainedPath(LibraryRoot root, string relativePath); +} + +public sealed record FileObservation(string RelativePath, long Size, DateTimeOffset ModifiedAt); + +public interface IScanCoordinator +{ + Task<ScanRun> RequestAsync(Guid libraryId, ScanMode mode, string? idempotencyKey, CancellationToken cancellationToken); + Task ExecuteAsync(Guid scanId, CancellationToken cancellationToken); + Task CancelAsync(Guid scanId, CancellationToken cancellationToken); +} + +public interface ICatalogImporter +{ + Task<CatalogSource> ImportLogiqxAsync(string name, string version, Stream input, CancellationToken cancellationToken); +} + +public interface ISnapshotComparer +{ + IReadOnlyList<SnapshotChange> Compare(IntegritySnapshot before, IntegritySnapshot after); +} + +public sealed record SupportBundleResult(string FileName, DateTimeOffset CreatedAt, int LibraryCount, int ScanCount); +public interface ISupportBundleService +{ + Task<SupportBundleResult> CreateAsync(CancellationToken cancellationToken); +} + +public sealed record InventoryExportResult(string FileName, string Format, long Count, DateTimeOffset CreatedAt); +public interface IInventoryExportService +{ + Task<InventoryExportResult> CreateAsync(string format, CancellationToken cancellationToken); +} + +public sealed record GameCatalogExportResult(string FileName, string Format, long Count, DateTimeOffset CreatedAt); +public sealed record GameCatalogExportFile(Stream Content, string ContentType, long Length, string FileName); +public interface IGameCatalogExportService +{ + Task<GameCatalogExportResult> CreateAsync(string format, CancellationToken cancellationToken); + Task<GameCatalogExportFile?> OpenAsync(string fileName, CancellationToken cancellationToken); +} diff --git a/src/Ludarium.Application/DiscImageAnalysis.cs b/src/Ludarium.Application/DiscImageAnalysis.cs new file mode 100644 index 0000000..85eb7da --- /dev/null +++ b/src/Ludarium.Application/DiscImageAnalysis.cs @@ -0,0 +1,147 @@ +using System.Buffers.Binary; +using System.Text; +using Ludarium.Domain; + +namespace Ludarium.Application; + +public sealed record DiscImageInspection(string? Platform, Confidence Confidence, + IReadOnlyList<Evidence> Evidence); + +public static class DiscImageAnalysis +{ + private const int SectorSize = 2048; + private const long PrimaryVolumeDescriptorOffset = 16L * SectorSize; + private const int MaximumDirectoryBytes = 64 * 1024; + private const int MaximumSystemConfigBytes = 4 * 1024; + + public static async Task<DiscImageInspection> InspectPbpAsync(Stream stream, CancellationToken ct) + { + const int headerLength = 40; + if (!stream.CanSeek || stream.Length < headerLength) return Empty(); + var header = await ReadAtAsync(stream, 0, headerLength, ct); + if (header.Length != headerLength || !header.AsSpan(0, 4).SequenceEqual("\0PBP"u8)) return Empty(); + + var offsets = new uint[8]; + for (var index = 0; index < offsets.Length; index++) + offsets[index] = BinaryPrimitives.ReadUInt32LittleEndian(header.AsSpan(8 + index * 4, 4)); + if (offsets[0] < headerLength || offsets[^1] > stream.Length) return Empty(); + for (var index = 1; index < offsets.Length; index++) + if (offsets[index] < offsets[index - 1]) return Empty(); + + var now = DateTimeOffset.UtcNow; + var evidence = new List<Evidence> + { + new("disc.signature", "PBP", "builtin-pbp", "1", Confidence.Deterministic, now) + }; + var dataPspLength = offsets[7] - offsets[6]; + var dataPsp = dataPspLength >= 4 ? await ReadAtAsync(stream, offsets[6], 4, ct) : []; + var dataPsarLength = stream.Length - offsets[7]; + var dataPsar = dataPsarLength >= 8 + ? await ReadAtAsync(stream, offsets[7], (int)Math.Min(16, dataPsarLength), ct) + : []; + if (dataPsar.AsSpan().StartsWith("PSISOIMG"u8)) + { + evidence.Add(new("disc.platform", "psx:PBP:PSISOIMG", "builtin-pbp", "1", Confidence.Deterministic, now)); + return new("psx", Confidence.Deterministic, evidence); + } + if (dataPsp.AsSpan().SequenceEqual(new byte[] { 0x7f, (byte)'E', (byte)'L', (byte)'F' }) && dataPsarLength == 0) + { + evidence.Add(new("disc.platform", "psp:PBP:ELF", "builtin-pbp", "1", Confidence.Deterministic, now)); + return new("psp", Confidence.Deterministic, evidence); + } + return new(null, Confidence.Deterministic, evidence); + } + + public static async Task<DiscImageInspection> InspectIsoAsync(Stream stream, CancellationToken ct) + { + if (!stream.CanSeek || stream.Length < PrimaryVolumeDescriptorOffset + SectorSize) + return Empty(); + var descriptor = await ReadAtAsync(stream, PrimaryVolumeDescriptorOffset, SectorSize, ct); + if (descriptor.Length != SectorSize || descriptor[0] != 1 || + !descriptor.AsSpan(1, 5).SequenceEqual("CD001"u8) || descriptor[6] != 1) + return Empty(); + + var now = DateTimeOffset.UtcNow; + var evidence = new List<Evidence> + { + new("disc.signature", "ISO9660-CD001", "builtin-iso9660", "1", Confidence.Deterministic, now) + }; + if (!TryDirectoryRecord(descriptor, 156, out var rootExtent, out var rootLength)) + return new(null, Confidence.Deterministic, evidence); + var directoryLength = (int)Math.Min(rootLength, MaximumDirectoryBytes); + var directory = await ReadAtAsync(stream, (long)rootExtent * SectorSize, directoryLength, ct); + if (directory.Length == 0) return new(null, Confidence.Deterministic, evidence); + + uint systemExtent = 0; + uint systemLength = 0; + var hasPspGame = false; + var hasUmdData = false; + for (var offset = 0; offset < directory.Length;) + { + var length = directory[offset]; + if (length == 0) { offset = ((offset / SectorSize) + 1) * SectorSize; continue; } + if (offset + length > directory.Length || length < 34) break; + var nameLength = directory[offset + 32]; + if (33 + nameLength <= length) + { + var name = Encoding.ASCII.GetString(directory, offset + 33, nameLength); + if (name.Equals("SYSTEM.CNF;1", StringComparison.OrdinalIgnoreCase)) + { + systemExtent = BinaryPrimitives.ReadUInt32LittleEndian(directory.AsSpan(offset + 2, 4)); + systemLength = BinaryPrimitives.ReadUInt32LittleEndian(directory.AsSpan(offset + 10, 4)); + } + else if (name.Equals("PSP_GAME", StringComparison.OrdinalIgnoreCase)) hasPspGame = true; + else if (name.Equals("UMD_DATA.BIN;1", StringComparison.OrdinalIgnoreCase)) hasUmdData = true; + } + offset += length; + } + if (hasPspGame && hasUmdData) + { + evidence.Add(new("disc.platform", "psp:PSP_GAME+UMD_DATA.BIN", "builtin-iso9660", "1", Confidence.Deterministic, now)); + return new("psp", Confidence.Deterministic, evidence); + } + if (systemExtent == 0 || systemLength == 0) return new(null, Confidence.Deterministic, evidence); + var config = await ReadAtAsync(stream, (long)systemExtent * SectorSize, + (int)Math.Min(systemLength, MaximumSystemConfigBytes), ct); + var text = Encoding.ASCII.GetString(config); + if (text.Contains("BOOT2", StringComparison.OrdinalIgnoreCase) && + text.Contains("cdrom0:", StringComparison.OrdinalIgnoreCase)) + { + evidence.Add(new("disc.platform", "ps2:SYSTEM.CNF:BOOT2", "builtin-iso9660", "1", Confidence.Deterministic, now)); + return new("ps2", Confidence.Deterministic, evidence); + } + if (text.Contains("BOOT", StringComparison.OrdinalIgnoreCase) && + text.Contains("cdrom:", StringComparison.OrdinalIgnoreCase)) + { + evidence.Add(new("disc.platform", "psx:SYSTEM.CNF:BOOT", "builtin-iso9660", "1", Confidence.Deterministic, now)); + return new("psx", Confidence.Deterministic, evidence); + } + return new(null, Confidence.Deterministic, evidence); + } + + private static bool TryDirectoryRecord(byte[] bytes, int offset, out uint extent, out uint length) + { + extent = 0; length = 0; + if (offset < 0 || offset + 34 > bytes.Length || bytes[offset] < 34) return false; + extent = BinaryPrimitives.ReadUInt32LittleEndian(bytes.AsSpan(offset + 2, 4)); + length = BinaryPrimitives.ReadUInt32LittleEndian(bytes.AsSpan(offset + 10, 4)); + return extent > 0 && length > 0; + } + + private static async Task<byte[]> ReadAtAsync(Stream stream, long offset, int length, CancellationToken ct) + { + if (offset < 0 || length <= 0 || offset > stream.Length || stream.Length - offset < length) return []; + stream.Position = offset; + var bytes = new byte[length]; + var total = 0; + while (total < bytes.Length) + { + var read = await stream.ReadAsync(bytes.AsMemory(total), ct); + if (read == 0) break; + total += read; + } + return total == bytes.Length ? bytes : bytes[..total]; + } + + private static DiscImageInspection Empty() => new(null, Confidence.None, []); +} diff --git a/src/Ludarium.Application/DolphinLaunchPolicy.cs b/src/Ludarium.Application/DolphinLaunchPolicy.cs new file mode 100644 index 0000000..ea5fcf1 --- /dev/null +++ b/src/Ludarium.Application/DolphinLaunchPolicy.cs @@ -0,0 +1,60 @@ +namespace Ludarium.Application; + +/// <summary> +/// Selects the one unique read-only GameCube or Wii image a Dolphin session may launch. +/// </summary> +/// <remarks> +/// Dolphin serves two platforms from one mount, so a runtime path always names its platform +/// directory. Everything else — path safety, prefix rebasing, capability vocabulary — is shared with +/// every other native player. +/// </remarks> +public static class DolphinLaunchPolicy +{ + public static NativeRemotePlayer Player => NativeRemotePlayerRegistry.Dolphin; + + public static IReadOnlyList<string> SupportedPlatforms => Player.Platforms; + + /// <summary>A disc image below this size is a stub, a companion file or a partial download.</summary> + private const long MinimumImageBytes = 1024 * 1024; + + public static NativeRemotePlayCapability EvaluateCapability(Guid gameId, string? platform, bool runtimeReady, + IReadOnlyList<NativeLaunchCandidate> candidates, DateTimeOffset? checkedAt = null, + bool vaultBacked = false) + { + var now = checkedAt ?? DateTimeOffset.UtcNow; + if (!Player.Owns(platform)) + return Unavailable(NativeRemotePlayState.UnsupportedPlatform, + "Only GameCube and Wii games can use the isolated Dolphin player."); + if (!runtimeReady) + return Unavailable(NativeRemotePlayState.RuntimeUnavailable, + "The isolated Dolphin runtime or exact-title controller is not available."); + if (candidates.Count == 0) + return Unavailable(NativeRemotePlayState.MissingGame, + "No linked Dolphin-compatible disc image was found for this game."); + if (!candidates.Any(candidate => candidate.SourceReadOnly)) + return Unavailable(NativeRemotePlayState.SourceNotReadOnly, + "The linked source is not mounted read-only, so playback is blocked."); + if (SelectGame(candidates) is null) + return Unavailable(NativeRemotePlayState.AmbiguousMapping, + "Ludarium could not identify one unique read-only Dolphin-compatible image for this game."); + return NativeRemotePlayPolicy.Available(Player, gameId, + "The exact linked game is ready in the isolated Dolphin player.", now, platform, vaultBacked); + + NativeRemotePlayCapability Unavailable(NativeRemotePlayState state, string message) => + NativeRemotePlayPolicy.Unavailable(Player, gameId, state, message, now, platform); + } + + public static NativeLaunchCandidate? SelectGame(IReadOnlyList<NativeLaunchCandidate> candidates) + { + var eligible = candidates.Where(candidate => candidate.SourceReadOnly && + candidate.Size > MinimumImageBytes && Player.AcceptsExtension(candidate.RelativePath) && + NativeRemotePlayPolicy.IsSafeRelativePath(candidate.RelativePath)).ToArray(); + return eligible.Length == 1 ? eligible[0] : null; + } + + public static string ToRuntimePath(string platform, string relativePath, string catalogPrefix) + { + if (!Player.Owns(platform)) throw new InvalidOperationException("Unsupported Dolphin platform."); + return NativeRemotePlayPolicy.ToRuntimePath(relativePath, catalogPrefix, Player.Extensions, platform); + } +} diff --git a/src/Ludarium.Application/FirmwarePolicy.cs b/src/Ludarium.Application/FirmwarePolicy.cs new file mode 100644 index 0000000..4689325 --- /dev/null +++ b/src/Ludarium.Application/FirmwarePolicy.cs @@ -0,0 +1,49 @@ +using Ludarium.Domain; + +namespace Ludarium.Application; + +public static class FirmwarePolicy +{ + private static readonly Dictionary<string, FirmwareRequirement> Requirements = new(StringComparer.OrdinalIgnoreCase) + { + ["psx/bios"] = Requirement("psx", "bios", FirmwareAssetKind.Bios, "PlayStation BIOS", + "Release-qualified browser runtime; personal BIOS optional", true, 4L * 1024 * 1024, ".bin", ".rom"), + ["ps2/bios"] = Requirement("ps2", "bios", FirmwareAssetKind.Bios, "PlayStation 2 BIOS", + "Play!.js browser runtime uses its built-in HLE BIOS; retained for future native compatibility", false, + 16L * 1024 * 1024, ".bin", ".rom"), + ["switch/prod-keys"] = Requirement("switch", "prod-keys", FirmwareAssetKind.KeySet, "Switch production keys", + "Provisioned to the isolated Eden runtime when its optional deployment profile is enabled", false, 4L * 1024 * 1024, ".keys", ".txt"), + ["switch/title-keys"] = Requirement("switch", "title-keys", FirmwareAssetKind.KeySet, "Switch title keys", + "Provisioned to the isolated Eden runtime when its optional deployment profile is enabled", false, 4L * 1024 * 1024, ".keys", ".txt") + }; + + public static IReadOnlyList<FirmwareRequirement> Describe() => Requirements.Values + .OrderBy(value => value.Platform, StringComparer.Ordinal).ThenBy(value => value.Slot, StringComparer.Ordinal).ToArray(); + + public static FirmwareRequirement Get(string platform, string slot) + { + var key = $"{Normalize(platform)}/{Normalize(slot)}"; + return Requirements.TryGetValue(key, out var requirement) + ? requirement + : throw new FirmwareValidationException("This firmware slot is not allowlisted by Ludarium."); + } + + public static string ValidateFileName(FirmwareRequirement requirement, string fileName) + { + var safe = Path.GetFileName(fileName?.Trim()); + if (string.IsNullOrWhiteSpace(safe) || safe.Length > 180 || safe.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0) + throw new FirmwareValidationException("A safe firmware file name is required."); + if (!requirement.Extensions.Contains(Path.GetExtension(safe))) + throw new FirmwareValidationException($"{requirement.Label} accepts only {string.Join(", ", requirement.Extensions.Order())} files."); + return safe; + } + + private static string Normalize(string value) => value.Trim().ToLowerInvariant(); + private static FirmwareRequirement Requirement(string platform, string slot, FirmwareAssetKind kind, + string label, string runtimeState, bool browserDelivery, long maximumBytes, params string[] extensions) => + new(platform, slot, kind, label, runtimeState, browserDelivery, maximumBytes, + new HashSet<string>(extensions, StringComparer.OrdinalIgnoreCase)); +} + +public sealed class FirmwareValidationException(string message) : Exception(message); +public sealed class FirmwareIntegrityException(string message) : Exception(message); diff --git a/src/Ludarium.Application/GameDataVaultPolicy.cs b/src/Ludarium.Application/GameDataVaultPolicy.cs new file mode 100644 index 0000000..3d1ff8b --- /dev/null +++ b/src/Ludarium.Application/GameDataVaultPolicy.cs @@ -0,0 +1,49 @@ +using Ludarium.Domain; + +namespace Ludarium.Application; + +public static class GameDataVaultPolicy +{ + private static readonly HashSet<string> ExecutableExtensions = new(StringComparer.OrdinalIgnoreCase) + { + ".exe", ".dll", ".com", ".scr", ".msi", ".bat", ".cmd", ".ps1", ".psm1", + ".vbs", ".vbe", ".js", ".jse", ".wsf", ".wsh", ".sh", ".appimage" + }; + + public static GameDataEntryInput Normalize(GameDataEntryInput input) + { + if (!Enum.IsDefined(input.Kind)) throw new ArgumentException("Game-data kind is not supported.", nameof(input)); + return new(input.Kind, + CleanRequired(input.Name, "Vault entry name", 120), + Clean(input.Emulator, "Emulator", 80), + Clean(input.Device, "Device", 80), + Clean(input.Notes, "Notes", 1000)); + } + + public static GameDataMetadataInput Normalize(GameDataMetadataInput input) => new( + CleanRequired(input.Name, "Vault entry name", 120), + Clean(input.Emulator, "Emulator", 80), + Clean(input.Device, "Device", 80), + Clean(input.Notes, "Notes", 1000)); + + public static string ValidateFileName(string fileName) + { + var cleaned = CleanRequired(fileName, "Filename", 160); + if (cleaned.IndexOfAny(['/', '\\']) >= 0 || cleaned.Any(char.IsControl)) + throw new ArgumentException("Filename must be a single safe file name.", nameof(fileName)); + if (cleaned is "." or "..") throw new ArgumentException("Filename is not valid.", nameof(fileName)); + if (ExecutableExtensions.Contains(Path.GetExtension(cleaned))) + throw new ArgumentException("Executable and script files are not accepted by the game-data vault.", nameof(fileName)); + return cleaned; + } + + private static string CleanRequired(string? value, string label, int maximum) => + Clean(value, label, maximum) ?? throw new ArgumentException($"{label} is required."); + + private static string? Clean(string? value, string label, int maximum) + { + var cleaned = string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + if (cleaned?.Length > maximum) throw new ArgumentException($"{label} cannot exceed {maximum} characters."); + return cleaned; + } +} diff --git a/src/Ludarium.Application/GameQuery.cs b/src/Ludarium.Application/GameQuery.cs new file mode 100644 index 0000000..38ec931 --- /dev/null +++ b/src/Ludarium.Application/GameQuery.cs @@ -0,0 +1,76 @@ +using Ludarium.Domain; + +namespace Ludarium.Application; + +/// <summary> +/// Everything the library browser can ask of the catalog. +/// </summary> +/// <remarks> +/// Filtering and ordering belong to the query, not to the page the browser happens to hold. The +/// library previously sorted the forty-eight games already on screen, which reordered a page rather +/// than the collection and quietly disagreed with what the operator asked for. +/// </remarks> +public sealed record GameQuery +{ + public string? Search { get; init; } + public string? Platform { get; init; } + + /// <summary>Ordering key. Unknown values fall back to title.</summary> + public string? Sort { get; init; } + + public bool? Favorite { get; init; } + public GamePlayStatus? Status { get; init; } + public Guid? CollectionId { get; init; } + + /// <summary>Normalized tag name, as stored by <see cref="LibraryExperiencePolicy.NormalizeTag"/>.</summary> + public string? Tag { get; init; } + + /// <summary>Keep only games rated at least this highly.</summary> + public int? MinimumRating { get; init; } + + /// <summary> + /// Keep only games with a linked, present copy in a format some player accepts. + /// </summary> + /// <remarks> + /// This is a property of the collection, not of the runtime: a sidecar that is switched off does + /// not make a game disappear from the library. Whether a specific game can start right now stays + /// the capability's answer, shown on the game itself. + /// </remarks> + public bool PlayableOnly { get; init; } + + public int Page { get; init; } = 1; + public int PageSize { get; init; } = 50; + + public static readonly IReadOnlyList<string> SortKeys = + ["title", "recent", "updated", "played", "rating"]; + + public bool HasFilters => !string.IsNullOrWhiteSpace(Search) || !string.IsNullOrWhiteSpace(Platform) || + Favorite is not null || Status is not null || CollectionId is not null || + !string.IsNullOrWhiteSpace(Tag) || MinimumRating is not null || PlayableOnly; +} + +/// <summary> +/// The exact platform-and-container pairs some Ludarium player accepts. +/// </summary> +/// <remarks> +/// Derived from the browser matrix and the native registry so the library's "playable copy" filter +/// can never claim a format no player takes, and can never drift from the policies that decide it. +/// </remarks> +public static class PlayableFormats +{ + /// <summary>Each entry is <c>platform|.extension</c>, lower-cased.</summary> + public static IReadOnlyList<string> Pairs { get; } = BuildPairs(); + + private static string[] BuildPairs() + { + var pairs = new SortedSet<string>(StringComparer.Ordinal); + foreach (var platform in BrowserPlayPolicy.DescribePlatforms()) + foreach (var extension in platform.Extensions.Concat(platform.CandidateExtensions ?? new HashSet<string>())) + pairs.Add($"{platform.Platform.ToLowerInvariant()}|{extension.ToLowerInvariant()}"); + foreach (var player in NativeRemotePlayerRegistry.All) + foreach (var platform in player.Platforms) + foreach (var extension in player.Extensions) + pairs.Add($"{platform.ToLowerInvariant()}|{extension.ToLowerInvariant()}"); + return [.. pairs]; + } +} diff --git a/src/Ludarium.Application/HealthRules.cs b/src/Ludarium.Application/HealthRules.cs new file mode 100644 index 0000000..a51b7ec --- /dev/null +++ b/src/Ludarium.Application/HealthRules.cs @@ -0,0 +1,26 @@ +using Ludarium.Domain; + +namespace Ludarium.Application; + +public sealed record HealthRuleResult(string Category, Severity Severity, string Message); + +public static class HealthRules +{ + public static HealthRuleResult? EvaluateRoot(LibraryRoot root) => + !root.IsAvailable ? new("Availability", Severity.Critical, "The library root could not be read; existing artifacts were preserved.") : + root.IsReadOnly != true ? new("Availability", Severity.Warning, "The library root is readable but a read-only mount could not be confirmed.") : null; + + public static HealthRuleResult? EvaluateArtifact(Artifact artifact) => artifact.MediaType switch + { + MediaType.Unknown => new("Identity", Severity.Notice, "Content type is unknown and remains available for review."), + _ when artifact.State == ArtifactState.Missing => new("Availability", Severity.Warning, "The artifact was not observed during a successful root traversal."), + _ => null + }; + + public static HealthRuleResult? EvaluateBundle(Bundle bundle) => bundle.State switch + { + BundleState.Incomplete => new("BundleCompleteness", Severity.Warning, "One or more required bundle members are missing."), + BundleState.Ambiguous => new("BundleCompleteness", Severity.Notice, "Bundle membership requires an operator decision."), + _ => null + }; +} diff --git a/src/Ludarium.Application/LibraryContentPolicy.cs b/src/Ludarium.Application/LibraryContentPolicy.cs new file mode 100644 index 0000000..095e1bd --- /dev/null +++ b/src/Ludarium.Application/LibraryContentPolicy.cs @@ -0,0 +1,102 @@ +using System.Text.RegularExpressions; + +namespace Ludarium.Application; + +public sealed record NonGamePath(string Category, string Evidence); + +public static partial class LibraryContentPolicy +{ + private static readonly HashSet<string> NonGameSegments = new(StringComparer.OrdinalIgnoreCase) + { + "tools", "tool", "emulators", "emulator", "retroarch", "bios", "firmware", + ".gameyfin", ".cache", "cache", "$recycle.bin", "system volume information", + "runtime", "_original_files", "sce_sys", "sce_module", "fakelib", "md5", + "saves", "savegames", "screenshots", "manuals", "manual", "documentation", "docs", + "patches", "patch", "updates", "update", "trainers", "trainer", "cheats", "keys", + "shaders", "plugins", "logs", "covers", "cover", "artwork", "images", "imgs", + "steam", "steamapps", "depotcache", "controller_base", "crack", "redist", "redistributables", + "directx", "dotnet", "vcredist", "support", "extras", "bonus", "soundtrack" + }; + private static readonly HashSet<string> GameExtensions = new(StringComparer.OrdinalIgnoreCase) + { + ".nes", ".sfc", ".smc", ".n64", ".z64", ".v64", ".gb", ".gbc", ".gba", ".nds", ".3ds", + ".cia", ".iso", ".cue", ".m3u", ".chd", ".cso", ".isz", ".pbp", ".xci", ".nsp", ".wbfs", ".rvz", + ".gcm", ".gcz", ".wia", ".gdi", ".cdi", ".vpk", + ".md", ".gen", ".smd", ".32x", ".sms", ".gg", ".sg", + ".wad", ".wux", ".wud", ".wua", ".exe", ".msi", ".zip", ".7z" + }; + + public static NonGamePath? IdentifyNonGamePath(string relativePath) + { + var segments = relativePath.Replace('\\', '/').Split('/', StringSplitOptions.RemoveEmptyEntries); + var segment = segments.FirstOrDefault(NonGameSegments.Contains); + if (segment is not null) return new("NonGameSupport", $"directory:{segment}"); + var fileName = segments.LastOrDefault() ?? string.Empty; + if (SupportFileName().IsMatch(fileName)) return new("NonGameSupport", $"support-file:{fileName}"); + if (Path.GetExtension(fileName) is ".html" or ".htm") + return new("NonGameSupport", $"web-page:{fileName}"); + var contentRoot = segments.Length > 1 && segments[0].Equals("PC", StringComparison.OrdinalIgnoreCase) ? segments[1] : null; + return contentRoot is not null && NonGamePcRoot().IsMatch(contentRoot) + ? new("NonGameSupport", $"pc-root:{contentRoot}") + : null; + } + + public static string CanonicalGameTitle(string rawTitle) + { + var extension = Path.GetExtension(rawTitle); + var title = (GameExtensions.Contains(extension) ? rawTitle[..^extension.Length] : rawTitle).Replace('_', ' '); + if (title.Contains('.') && !title.Contains(". ", StringComparison.Ordinal)) title = title.Replace('.', ' '); + title = SceneSuffix().Replace(title, string.Empty); + title = ReleaseTag().Replace(title, string.Empty); + title = BracketMetadata().Replace(title, string.Empty); + title = ParenthesizedMetadata().Replace(title, string.Empty); + title = DiscSuffix().Replace(title, string.Empty); + title = PlatformSuffix().Replace(title, string.Empty); + title = ProductCodeSuffix().Replace(title, string.Empty); + title = DanglingMetadata().Replace(title, string.Empty); + title = ParenthesizedMetadata().Replace(title, string.Empty); + title = DuplicatePossessive().Replace(title, "$1's"); + title = CatalogSequence().Replace(title, string.Empty); + title = Whitespace().Replace(title, " ").Trim(' ', '-', '.'); + return string.IsNullOrWhiteSpace(title) ? rawTitle.Trim() : title; + } + + [GeneratedRegex(@"(?i)\s*(?:[-–]\s*)?(?:duplex|codex|flt|reloaded|skidrow|gog|razor1911|venom|psypsp|internal-ps5b)\s*$")] + private static partial Regex SceneSuffix(); + + [GeneratedRegex(@"(?i)\s*\[(?:fitgirl(?: monkey)? repack|dodi repack|gog|portable|repack|update[^\]]*|[a-z0-9]{6})\]\s*$")] + private static partial Regex BracketMetadata(); + + [GeneratedRegex(@"(?i)\s+(?:(?:usa|eur|pal|ntsc)\s+)?(?:multi\d+\s+)?(?:clean\s+)?(?:nds|3ds|wii|gba|psp|ps2|ps3|ps4|ps5|nsw)-[a-z0-9]+$")] + private static partial Regex ReleaseTag(); + + [GeneratedRegex(@"(?i)\s*\((?:usa|europe|japan|world|australia|korea|asia)(?:[^)]*)\)\s*(?:\([^)]*\)\s*)*$")] + private static partial Regex ParenthesizedMetadata(); + + [GeneratedRegex(@"(?i)\s*(?:\(|\[)?(?:disc|disk|cd)\s*\d+(?:\)|\])?\s*$")] + private static partial Regex DiscSuffix(); + + [GeneratedRegex(@"(?i)(?:\s+|-\s*)(?:ps[1-5]|playstation\s*[1-5]?|psp|ps\s*vita|xbox(?:\s*(?:one|360|series\s*[xs]))?|switch|wiiu?|gamecube|n64|snes|nes|gba|gbc|game\s*boy(?:\s*(?:advance|color))?|nds|3ds)\s*$")] + private static partial Regex PlatformSuffix(); + + [GeneratedRegex(@"(?i)\s*(?:\[(?:u|e|j|ntsc(?:-[uj])?|pal|scus-\d+|sles-\d+|slus-\d+|ul[eus]-\d+)\]\s*)+$")] + private static partial Regex ProductCodeSuffix(); + + [GeneratedRegex(@"(?i)\s*[\[(](?:usa|eur(?:ope)?|japan|world|australia|korea|asia|v(?:er(?:sion)?)?\s*\d+)[^\])]*$")] + private static partial Regex DanglingMetadata(); + + [GeneratedRegex(@"(?i)\b([a-z]+)s's\b")] + private static partial Regex DuplicatePossessive(); + + [GeneratedRegex(@"^\d{3,5}\s*-\s*")] + private static partial Regex CatalogSequence(); + + [GeneratedRegex(@"\s+")] + private static partial Regex Whitespace(); + + [GeneratedRegex(@"(?i)^(?:steam|fmt-v\d.*|frosty\s*mod\s*tool.*|.*(?:dlc\s*unlocker|emulator|trainer|crack|keygen|mod\s*manager|save\s*editor).*)$")] + private static partial Regex NonGamePcRoot(); + + [GeneratedRegex(@"(?i)^(?:readme|changelog|changes|license|licence|credits|manual|compatibility)(?:[._ -].*)?\.(?:txt|md|pdf|nfo|html?)$|^(?:prod|title|console)\.keys$")] + private static partial Regex SupportFileName(); +} diff --git a/src/Ludarium.Application/LibraryExperiencePolicy.cs b/src/Ludarium.Application/LibraryExperiencePolicy.cs new file mode 100644 index 0000000..41949bc --- /dev/null +++ b/src/Ludarium.Application/LibraryExperiencePolicy.cs @@ -0,0 +1,118 @@ +using System.Text.RegularExpressions; +using Ludarium.Domain; + +namespace Ludarium.Application; + +public static partial class LibraryExperiencePolicy +{ + /// <summary> + /// The regular-expression alternation a derived title strips from its end, longest first. + /// </summary> + /// <remarks> + /// One vocabulary, derived from what the identifier recognises, so a format added there cannot + /// leave its extension sitting in a title. The extras are container formats Ludarium accepts by + /// name without classifying them by extension. + /// </remarks> + public static string TitleExtensionPattern { get; } = string.Join('|', ArtifactAnalysis.KnownExtensions + .Concat(["wad", "wua"]) + .Select(Regex.Escape) + .OrderByDescending(extension => extension.Length) + .ThenBy(extension => extension, StringComparer.Ordinal)); + + public static string CleanTitle(string value, string label = "Title") => Required(value, 200, label); + + public static GameCollectionInput Normalize(GameCollectionInput input) + { + var name = Required(input.Name, 120, "Collection name"); + var description = Optional(input.Description, 1000); + var rule = input.Kind == CollectionKind.Static ? null : Normalize(input.Rule ?? new CollectionRule()); + return input with { Name = name, Description = description, Rule = rule }; + } + + public static CollectionRule Normalize(CollectionRule rule) + { + var tags = (rule.Tags ?? []) + .Select(NormalizeTag) + .Distinct(StringComparer.Ordinal) + .Take(20) + .ToArray(); + return rule with + { + Query = Optional(rule.Query, 200), + Platform = Optional(rule.Platform, 80)?.ToLowerInvariant(), + Tags = tags, + MetadataField = Optional(rule.MetadataField, 80), + MetadataValue = Optional(rule.MetadataValue, 200) + }; + } + + public static string NormalizeTag(string value) + { + var tag = Required(value, 60, "Tag"); + tag = Whitespace().Replace(tag, " "); + if (tag.Any(char.IsControl)) throw new ArgumentException("A tag cannot contain control characters."); + return tag.ToLowerInvariant(); + } + + public static GameUserStateInput Normalize(GameUserStateInput input) + { + if (input.Rating is < 1 or > 10) throw new ArgumentException("Rating must be between 1 and 10."); + if (input.Difficulty is < 1 or > 10) throw new ArgumentException("Difficulty must be between 1 and 10."); + if (input.CompletionPercent is < 0 or > 100) throw new ArgumentException("Completion must be between 0 and 100 percent."); + if (input.PlayCount < 0) throw new ArgumentException("Play count cannot be negative."); + return input with { Notes = Optional(input.Notes, 4000), PlayCount = Math.Min(input.PlayCount, 1_000_000) }; + } + + public static GameRelationInput Normalize(GameRelationInput input) + { + if (input.RelatedGameId is not null && input.ArtifactId is not null) + throw new ArgumentException("A relationship can target either a game or an artifact, not both."); + return input with { Title = Required(input.Title, 200, "Relationship title") }; + } + + public static GameMediaInput Normalize(GameMediaInput input) + { + var location = Optional(input.Location, 2000); + if (location is not null && (!Uri.TryCreate(location, UriKind.Absolute, out var uri) || uri.Scheme != Uri.UriSchemeHttps)) + throw new ArgumentException("External media locations must use HTTPS."); + return input with + { + Title = Required(input.Title, 200, "Media title"), + Location = location, + Provider = Optional(input.Provider, 80), + ExternalId = Optional(input.ExternalId, 200) + }; + } + + public static PlatformDefinitionInput Normalize(PlatformDefinitionInput input) + { + var id = Required(input.Id, 80, "Platform id").ToLowerInvariant(); + if (!PlatformId().IsMatch(id)) throw new ArgumentException("Platform id may contain lowercase letters, digits and hyphens only."); + var aliases = input.Aliases.Select(value => Required(value, 80, "Platform alias").ToLowerInvariant()) + .Distinct(StringComparer.Ordinal).Take(50).ToArray(); + return input with + { + Id = id, + Name = Required(input.Name, 120, "Platform name"), + Category = Required(input.Category, 80, "Platform category"), + Aliases = aliases + }; + } + + private static string Required(string? value, int maximum, string label) => + Optional(value, maximum) ?? throw new ArgumentException($"{label} is required."); + + private static string? Optional(string? value, int maximum) + { + if (string.IsNullOrWhiteSpace(value)) return null; + var result = value.Trim(); + if (result.Length > maximum) throw new ArgumentException($"Value cannot exceed {maximum} characters."); + return result; + } + + [GeneratedRegex(@"\s+", RegexOptions.CultureInvariant)] + private static partial Regex Whitespace(); + + [GeneratedRegex("^[a-z0-9]+(?:-[a-z0-9]+)*$", RegexOptions.CultureInvariant)] + private static partial Regex PlatformId(); +} diff --git a/src/Ludarium.Application/Ludarium.Application.csproj b/src/Ludarium.Application/Ludarium.Application.csproj new file mode 100644 index 0000000..1b2f982 --- /dev/null +++ b/src/Ludarium.Application/Ludarium.Application.csproj @@ -0,0 +1,19 @@ +<Project Sdk="Microsoft.NET.Sdk"> + + <ItemGroup> + <ProjectReference Include="..\Ludarium.Domain\Ludarium.Domain.csproj" /> + </ItemGroup> + + <PropertyGroup> + <TargetFramework>net10.0</TargetFramework> + <ImplicitUsings>enable</ImplicitUsings> + <Nullable>enable</Nullable> +</PropertyGroup> + + <ItemGroup> + <PackageReference Include="OpenMcdf" Version="3.2.0" /> + <PackageReference Include="SharpCompress" Version="0.50.4" /> + <PackageReference Include="System.IO.Hashing" Version="10.0.11" /> + </ItemGroup> + +</Project> diff --git a/src/Ludarium.Application/MsiDatabaseAnalysis.cs b/src/Ludarium.Application/MsiDatabaseAnalysis.cs new file mode 100644 index 0000000..05e28cf --- /dev/null +++ b/src/Ludarium.Application/MsiDatabaseAnalysis.cs @@ -0,0 +1,226 @@ +using System.Buffers.Binary; +using System.Text; +using OpenMcdf; + +namespace Ludarium.Application; + +public sealed record MsiCabinetRelationship(int DiskId, int LastSequence, string Cabinet, bool Embedded, + bool StreamPresent, string? VolumeLabel, string? Source); + +public sealed record MsiFileRelationship(string Key, string FileName, long Size, int Sequence, + string? Version, string? Language, int Attributes); + +public sealed record MsiDatabaseInspection(IReadOnlyDictionary<string, string> Properties, + IReadOnlyList<MsiCabinetRelationship> Cabinets, IReadOnlyList<MsiFileRelationship> Files, + int CodePage, IReadOnlyList<string> Findings) +{ + public bool IsComplete => Findings.Count == 0; +} + +public static class MsiDatabaseAnalysis +{ + private const int MaxStreamBytes = 8 * 1024 * 1024; + private const int MaxTotalBytes = 24 * 1024 * 1024; + private const int MaxStrings = 100_000; + private const int MaxRows = 100_000; + private const string Alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz._!"; + + public static MsiDatabaseInspection Inspect(string path, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(path) || !Path.IsPathFullyQualified(path)) + throw new ArgumentException("MSI path must be absolute.", nameof(path)); + + cancellationToken.ThrowIfCancellationRequested(); + using var root = RootStorage.OpenRead(path); + var budget = new ReadBudget(MaxTotalBytes); + var pool = ReadRequired(root, "_StringPool", budget, cancellationToken); + var data = ReadRequired(root, "_StringData", budget, cancellationToken); + var strings = ParseStrings(pool, data); + var columns = ParseColumns(ReadRequired(root, "_Columns", budget, cancellationToken), strings); + var findings = new List<string>(); + + var properties = ReadTable(root, "Property", columns, strings, budget, findings, cancellationToken) + .Where(row => row.TryGetValue("Property", out _) && row.TryGetValue("Value", out _)) + .GroupBy(row => row["Property"], StringComparer.OrdinalIgnoreCase) + .ToDictionary(group => group.Key, group => group.Last()["Value"], StringComparer.OrdinalIgnoreCase); + + var media = ReadTable(root, "Media", columns, strings, budget, findings, cancellationToken); + var cabinets = new List<MsiCabinetRelationship>(media.Count); + foreach (var row in media) + { + var cabinet = Value(row, "Cabinet"); + if (cabinet.Length == 0) continue; + var embedded = cabinet[0] == '#'; + var cabinetName = embedded ? cabinet[1..] : cabinet; + var present = embedded && StreamExists(root, cabinetName); + if (embedded && !present) findings.Add($"Embedded cabinet stream '{cabinetName}' is missing."); + cabinets.Add(new(ParseInt(row, "DiskId"), ParseInt(row, "LastSequence"), cabinetName, + embedded, present, NullIfEmpty(Value(row, "VolumeLabel")), NullIfEmpty(Value(row, "Source")))); + } + + var fileRows = ReadTable(root, "File", columns, strings, budget, findings, cancellationToken); + var files = fileRows.Select(row => new MsiFileRelationship(Value(row, "File"), Value(row, "FileName"), + ParseLong(row, "FileSize"), ParseInt(row, "Sequence"), NullIfEmpty(Value(row, "Version")), + NullIfEmpty(Value(row, "Language")), ParseInt(row, "Attributes"))).ToArray(); + + foreach (var file in files) + if (file.Sequence <= 0) findings.Add($"File '{file.Key}' has an invalid media sequence."); + if (cabinets.Count > 0 && files.Any(file => file.Sequence > cabinets.Max(cabinet => cabinet.LastSequence))) + findings.Add("One or more files are not covered by a Media table sequence range."); + + return new(properties, cabinets, files, strings.CodePage, findings.Distinct(StringComparer.Ordinal).ToArray()); + } + + internal static string EncodeStreamName(string name) + { + var result = new StringBuilder((name.Length + 1) / 2); + for (var index = 0; index < name.Length; index += 2) + { + var first = Alphabet.IndexOf(name[index]); + var second = index + 1 < name.Length ? Alphabet.IndexOf(name[index + 1]) : -1; + if (first >= 0 && second >= 0) result.Append((char)(0x3800 + first + (second << 6))); + else if (first >= 0 && second < 0) result.Append((char)(0x4800 + first)); + else + { + result.Append(name[index]); + if (index + 1 < name.Length) { result.Append(name[index + 1]); index++; } + } + } + return result.ToString(); + } + + private static MsiStrings ParseStrings(byte[] poolBytes, byte[] dataBytes) + { + if (poolBytes.Length < 4) throw new InvalidDataException("MSI string pool header is truncated."); + var codePage = BinaryPrimitives.ReadUInt16LittleEndian(poolBytes); + var offset = 4; + var dataOffset = 0; + var values = new List<string>(); + Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); + Encoding encoding; + try { encoding = codePage == 0 ? Encoding.Latin1 : Encoding.GetEncoding(codePage); } + catch (ArgumentException) { encoding = Encoding.Latin1; } + while (offset < poolBytes.Length) + { + if (offset + 4 > poolBytes.Length) throw new InvalidDataException("MSI string pool entry is truncated."); + var length = BinaryPrimitives.ReadUInt16LittleEndian(poolBytes.AsSpan(offset, 2)); + var references = BinaryPrimitives.ReadUInt16LittleEndian(poolBytes.AsSpan(offset + 2, 2)); + offset += 4; + long actualLength = length; + if (length == 0 && references != 0) + { + if (offset + 4 > poolBytes.Length) throw new InvalidDataException("MSI long string length is truncated."); + actualLength = BinaryPrimitives.ReadUInt32LittleEndian(poolBytes.AsSpan(offset, 4)); + offset += 4; + } + if (actualLength < 0 || actualLength > MaxStreamBytes || dataOffset + actualLength > dataBytes.Length) + throw new InvalidDataException("MSI string data exceeds its declared bounds."); + values.Add(encoding.GetString(dataBytes, dataOffset, (int)actualLength)); + dataOffset += (int)actualLength; + if (values.Count > MaxStrings) throw new InvalidDataException("MSI string pool exceeds the configured entry limit."); + } + return new(codePage, values); + } + + private static Dictionary<string, List<MsiColumn>> ParseColumns(byte[] bytes, MsiStrings strings) + { + const int rowSize = 8; + if (bytes.Length % rowSize != 0) throw new InvalidDataException("MSI _Columns table is truncated."); + var rows = bytes.Length / rowSize; + if (rows > MaxRows) throw new InvalidDataException("MSI _Columns table exceeds the row limit."); + var result = new Dictionary<string, List<MsiColumn>>(StringComparer.OrdinalIgnoreCase); + for (var row = 0; row < rows; row++) + { + var tableId = ReadColumnU16(bytes, rows, 0, row); + var number = ReadColumnU16(bytes, rows, 1, row); + var nameId = ReadColumnU16(bytes, rows, 2, row); + var attributes = ReadColumnU16(bytes, rows, 3, row); + var table = strings.Get(tableId); + if (!result.TryGetValue(table, out var list)) result[table] = list = []; + list.Add(new(strings.Get(nameId), number, attributes)); + } + foreach (var list in result.Values) list.Sort((left, right) => left.Number.CompareTo(right.Number)); + return result; + } + + private static List<Dictionary<string, string>> ReadTable(RootStorage root, string tableName, + IReadOnlyDictionary<string, List<MsiColumn>> allColumns, MsiStrings strings, ReadBudget budget, + List<string> findings, CancellationToken cancellationToken) + { + if (!allColumns.TryGetValue(tableName, out var columns)) return []; + byte[] bytes; + try { bytes = ReadRequired(root, tableName, budget, cancellationToken); } + catch (Exception exception) when (exception is KeyNotFoundException or FileNotFoundException) + { findings.Add($"Declared MSI table '{tableName}' has no stream."); return []; } + var rowWidth = columns.Sum(column => column.Width); + if (rowWidth <= 0 || bytes.Length % rowWidth != 0) + throw new InvalidDataException($"MSI table '{tableName}' has an invalid row layout."); + var rowCount = bytes.Length / rowWidth; + if (rowCount > MaxRows) throw new InvalidDataException($"MSI table '{tableName}' exceeds the row limit."); + var columnOffset = 0; + var output = Enumerable.Range(0, rowCount).Select(_ => new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)).ToList(); + foreach (var column in columns) + { + for (var row = 0; row < rowCount; row++) + { + cancellationToken.ThrowIfCancellationRequested(); + var offset = columnOffset + row * column.Width; + long raw = column.Width == 4 + ? BinaryPrimitives.ReadUInt32LittleEndian(bytes.AsSpan(offset, 4)) + : BinaryPrimitives.ReadUInt16LittleEndian(bytes.AsSpan(offset, 2)); + output[row][column.Name] = column.IsInteger + ? raw == 0 ? string.Empty : (raw - (column.Width == 4 ? 0x80000000L : 0x8000L)).ToString(System.Globalization.CultureInfo.InvariantCulture) + : raw == 0 ? string.Empty : strings.Get(checked((int)raw)); + } + columnOffset += rowCount * column.Width; + } + return output; + } + + private static byte[] ReadRequired(RootStorage root, string logicalName, ReadBudget budget, CancellationToken token) + { + token.ThrowIfCancellationRequested(); + using var stream = root.OpenStream(EncodeStreamName(logicalName)); + if (stream.Length > MaxStreamBytes) throw new InvalidDataException($"MSI stream '{logicalName}' exceeds the configured limit."); + budget.Add(stream.Length); + var bytes = new byte[checked((int)stream.Length)]; + stream.ReadExactly(bytes); + return bytes; + } + + private static bool StreamExists(RootStorage root, string logicalName) + { + try { using var _ = root.OpenStream(EncodeStreamName(logicalName)); return true; } + catch (Exception exception) when (exception is KeyNotFoundException or FileNotFoundException) { return false; } + } + + private static ushort ReadColumnU16(byte[] bytes, int rows, int column, int row) => + BinaryPrimitives.ReadUInt16LittleEndian(bytes.AsSpan((column * rows + row) * 2, 2)); + private static string Value(IReadOnlyDictionary<string, string> row, string key) => row.TryGetValue(key, out var value) ? value : string.Empty; + private static int ParseInt(IReadOnlyDictionary<string, string> row, string key) => int.TryParse(Value(row, key), out var value) ? value : 0; + private static long ParseLong(IReadOnlyDictionary<string, string> row, string key) => long.TryParse(Value(row, key), out var value) ? value : 0; + private static string? NullIfEmpty(string value) => value.Length == 0 ? null : value; + + private sealed record MsiColumn(string Name, int Number, int Attributes) + { + public bool IsInteger => (Attributes & 0x0f00) < 0x0800; + public int Width => IsInteger && (Attributes & 0x0fff) == 0x0104 ? 4 : 2; + } + + private sealed record MsiStrings(int CodePage, IReadOnlyList<string> Values) + { + public string Get(int reference) => reference > 0 && reference <= Values.Count + ? Values[reference - 1] + : throw new InvalidDataException($"MSI string reference {reference} is out of bounds."); + } + + private sealed class ReadBudget(long limit) + { + private long _used; + public void Add(long bytes) + { + _used = checked(_used + bytes); + if (_used > limit) throw new InvalidDataException("MSI metadata exceeds the aggregate read limit."); + } + } +} diff --git a/src/Ludarium.Application/NativeRemotePlayPolicy.cs b/src/Ludarium.Application/NativeRemotePlayPolicy.cs new file mode 100644 index 0000000..8271c0b --- /dev/null +++ b/src/Ludarium.Application/NativeRemotePlayPolicy.cs @@ -0,0 +1,69 @@ +namespace Ludarium.Application; + +/// <summary>Why a native remote player can or cannot start an exact linked title.</summary> +/// <remarks> +/// One shared vocabulary across every native player. A player that cannot reach a state simply never +/// returns it, which keeps the UI, the playability workspace and the API contract identical no matter +/// which sidecar answers. +/// </remarks> +public enum NativeRemotePlayState +{ + Available, + UnsupportedPlatform, + RuntimeUnavailable, + MissingKeys, + MissingGame, + MissingBaseGame, + SourceNotReadOnly, + AmbiguousMapping +} + +/// <summary>The evaluated readiness of one game in one native remote player.</summary> +public sealed record NativeRemotePlayCapability( + Guid GameId, + bool Available, + NativeRemotePlayState State, + string Message, + string Emulator, + bool DesktopRecommended, + DateTimeOffset CheckedAt, + bool AutomaticRestore = false, + string? Platform = null, + string? Player = null, + bool SupportsSavestates = false); + +public static class NativeRemotePlayPolicy +{ + /// <summary>A path Ludarium is willing to hand to a sidecar: relative, bounded and non-escaping.</summary> + public static bool IsSafeRelativePath(string? path) => !string.IsNullOrWhiteSpace(path) && + !Path.IsPathRooted(path) && !path.Contains(':') && !path.Contains('\0') && + path.Split(['/', '\\'], StringSplitOptions.RemoveEmptyEntries).All(part => part is not "." and not ".."); + + /// <summary> + /// Rebase a scan-derived source path onto the sidecar's own read-only mount. + /// The prefix is operator-configured and must match exactly, so a game outside the configured + /// catalog can never be addressed through the runtime. + /// </summary> + public static string ToRuntimePath(string relativePath, string catalogPrefix, IReadOnlySet<string> extensions, + string? platformDirectory = null) + { + var normalized = relativePath.Replace('\\', '/').TrimStart('/'); + var prefix = catalogPrefix.Replace('\\', '/').Trim('/'); + if (string.IsNullOrWhiteSpace(prefix) || !normalized.StartsWith(prefix + "/", StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException("The selected game is outside the configured catalog prefix."); + var suffix = normalized[(prefix.Length + 1)..]; + if (!IsSafeRelativePath(suffix) || !extensions.Contains(Path.GetExtension(suffix))) + throw new InvalidOperationException("The selected game path is unsafe or unsupported."); + return platformDirectory is null ? suffix : $"{platformDirectory.ToLowerInvariant()}/{suffix}"; + } + + public static NativeRemotePlayCapability Unavailable(NativeRemotePlayer player, Guid gameId, + NativeRemotePlayState state, string message, DateTimeOffset checkedAt, string? platform = null) => + new(gameId, false, state, message, player.Emulator, true, checkedAt, false, platform, player.Key, + player.SupportsSavestates); + + public static NativeRemotePlayCapability Available(NativeRemotePlayer player, Guid gameId, string message, + DateTimeOffset checkedAt, string? platform, bool automaticRestore) => + new(gameId, true, NativeRemotePlayState.Available, message, player.Emulator, true, checkedAt, + automaticRestore, platform, player.Key, player.SupportsSavestates); +} diff --git a/src/Ludarium.Application/NativeRemotePlayerRegistry.cs b/src/Ludarium.Application/NativeRemotePlayerRegistry.cs new file mode 100644 index 0000000..a6ab0cf --- /dev/null +++ b/src/Ludarium.Application/NativeRemotePlayerRegistry.cs @@ -0,0 +1,105 @@ +namespace Ludarium.Application; + +/// <summary> +/// The static identity of one isolated native remote player. +/// </summary> +/// <remarks> +/// Every native player is the same shape: an isolated sidecar that runs exactly one linked title +/// from a read-only mount, reached through an authenticated certificate-pinned proxy and an +/// exact-title control endpoint. Only the emulator, the platforms it owns, the container formats it +/// accepts and the controls it exposes differ. Keeping that here — instead of spread across route +/// literals, capability endpoints and UI conditionals — is what lets a new player (Azahar, xemu, +/// Cemu, Vita3K) be added by describing it rather than by copying a vertical slice. +/// </remarks> +public sealed record NativeRemotePlayer( + string Key, + string Emulator, + string ProxyHost, + string ControlHost, + int DefaultPublicPort, + IReadOnlyList<string> Platforms, + IReadOnlySet<string> Extensions, + IReadOnlySet<string> Actions, + string SaveMode, + int MaximumSessions, + bool RequiresFixtureDigest) +{ + /// <summary>Route prefix of the embedded proxy, also the scope of its capability cookie.</summary> + public string ProxyPath => $"/{Key}-player"; + + public string CookieName => $"ludarium-{Key}-session"; + + /// <summary>Prefix of every environment variable that configures this player.</summary> + public string EnvironmentPrefix => $"LUDARIUM_{Key.ToUpperInvariant()}"; + + /// <summary>Route segment of this player's session endpoints.</summary> + public string SessionRoute => $"{Key}-player-sessions"; + + public bool SupportsSavestates => Actions.Contains("save-state"); + + public bool Owns(string? platform) => platform is not null && + Platforms.Contains(platform, StringComparer.OrdinalIgnoreCase); + + public bool AcceptsExtension(string? relativePath) => relativePath is not null && + Extensions.Contains(Path.GetExtension(relativePath)); +} + +public static class NativeRemotePlayerRegistry +{ + /// <summary>Nintendo Switch through the isolated Eden runtime.</summary> + public static readonly NativeRemotePlayer Switch = new( + Key: "switch", + Emulator: "Eden", + ProxyHost: "eden", + ControlHost: "eden", + DefaultPublicPort: 1231, + Platforms: ["switch"], + Extensions: Set(".xci", ".nsp"), + Actions: Set("pause-resume", "fullscreen", "stop"), + SaveMode: "native-persistent", + MaximumSessions: 2, + RequiresFixtureDigest: false); + + /// <summary>GameCube and Wii through the isolated Dolphin runtime.</summary> + public static readonly NativeRemotePlayer Dolphin = new( + Key: "dolphin", + Emulator: "Dolphin", + ProxyHost: "dolphin", + ControlHost: "dolphin", + DefaultPublicPort: 1232, + Platforms: ["gamecube", "wii"], + Extensions: Set(".iso", ".gcm", ".rvz", ".gcz", ".wbfs", ".wia"), + Actions: Set("pause-resume", "fullscreen", "save-state", "load-state", "stop"), + SaveMode: "native-and-savestate-persistent", + MaximumSessions: 1, + RequiresFixtureDigest: true); + + public static IReadOnlyList<NativeRemotePlayer> All { get; } = [Switch, Dolphin]; + + /// <summary>Every platform served by a native remote player rather than by browser play.</summary> + public static IReadOnlySet<string> Platforms { get; } = + All.SelectMany(player => player.Platforms).ToHashSet(StringComparer.OrdinalIgnoreCase); + + public static NativeRemotePlayer? ForPlatform(string? platform) => + platform is null ? null : All.FirstOrDefault(player => player.Owns(platform)); + + public static NativeRemotePlayer? ForKey(string? key) => key is null ? null : + All.FirstOrDefault(player => player.Key.Equals(key, StringComparison.OrdinalIgnoreCase)); + + /// <summary> + /// Resolve the single native platform a game belongs to. A game whose releases name two native + /// platforms is deliberately unresolvable: Ludarium must never guess which disc to launch. + /// </summary> + public static string? ResolvePlatform(IEnumerable<string?> releasePlatforms) + { + var matches = releasePlatforms + .Select(platform => platform?.Trim().ToLowerInvariant()) + .Where(platform => platform is not null && Platforms.Contains(platform)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Take(2) + .ToArray(); + return matches.Length == 1 ? matches[0] : null; + } + + private static HashSet<string> Set(params string[] values) => new(values, StringComparer.OrdinalIgnoreCase); +} diff --git a/src/Ludarium.Application/ReleaseIdentity.cs b/src/Ludarium.Application/ReleaseIdentity.cs new file mode 100644 index 0000000..a48c21c --- /dev/null +++ b/src/Ludarium.Application/ReleaseIdentity.cs @@ -0,0 +1,23 @@ +using System.Reflection; + +namespace Ludarium.Application; + +/// <summary> +/// Runtime release identity derived from the repository VERSION file through MSBuild. +/// </summary> +public static class ReleaseIdentity +{ + private static readonly string ResolvedVersion = ResolveVersion(); + + public static string Version => ResolvedVersion; + public static string UserAgent => $"Ludarium/{ResolvedVersion}"; + + private static string ResolveVersion() + { + var informational = typeof(ReleaseIdentity).Assembly + .GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion; + if (string.IsNullOrWhiteSpace(informational)) + throw new InvalidOperationException("The Ludarium informational version is missing."); + return informational.Split('+', 2, StringSplitOptions.TrimEntries)[0]; + } +} diff --git a/src/Ludarium.Application/ReviewGroupQuery.cs b/src/Ludarium.Application/ReviewGroupQuery.cs new file mode 100644 index 0000000..2d4ca69 --- /dev/null +++ b/src/Ludarium.Application/ReviewGroupQuery.cs @@ -0,0 +1,19 @@ +using Ludarium.Domain; + +namespace Ludarium.Application; + +public enum ReviewGroupOrder { Recommended, Impact, Newest } + +public sealed record ReviewGroupQuery +{ + public ReviewState State { get; init; } = ReviewState.Open; + public string? Category { get; init; } + public string? Search { get; init; } + public string? Library { get; init; } + public string? Platform { get; init; } + public Severity? Severity { get; init; } + public DateTimeOffset? Since { get; init; } + public ReviewGroupOrder Order { get; init; } = ReviewGroupOrder.Recommended; + public int Page { get; init; } = 1; + public int PageSize { get; init; } = 50; +} diff --git a/src/Ludarium.Application/ScanCoordinator.cs b/src/Ludarium.Application/ScanCoordinator.cs new file mode 100644 index 0000000..53ac3d7 --- /dev/null +++ b/src/Ludarium.Application/ScanCoordinator.cs @@ -0,0 +1,371 @@ +using System.Text; +using System.Security.Cryptography; +using Ludarium.Domain; + +namespace Ludarium.Application; + +public sealed class ScanCoordinator(ILudariumStore store, IReadOnlyLibraryFileSystem files) : IScanCoordinator +{ + public Task<ScanRun> RequestAsync(Guid libraryId, ScanMode mode, string? idempotencyKey, CancellationToken cancellationToken) => + store.CreateScanAsync(libraryId, mode, idempotencyKey, cancellationToken); + + public async Task CancelAsync(Guid scanId, CancellationToken cancellationToken) + { + var scan = await store.GetScanAsync(scanId, cancellationToken) ?? throw new KeyNotFoundException("Scan not found."); + if (scan.State is ScanState.Completed or ScanState.Cancelled or ScanState.Failed) return; + await store.UpdateScanAsync(scan with { CancellationRequested = true, State = ScanState.Cancelling, Stage = "Cancelling" }, cancellationToken); + } + + public async Task ExecuteAsync(Guid scanId, CancellationToken cancellationToken) + { + var scan = await store.GetScanAsync(scanId, cancellationToken) ?? throw new KeyNotFoundException("Scan not found."); + if (scan.State is ScanState.Completed or ScanState.Cancelled) return; + var root = await store.GetLibraryAsync(scan.LibraryId, cancellationToken) ?? throw new InvalidOperationException("Library no longer exists."); + try + { + if (!root.Enabled) + throw new InvalidOperationException("Scan stopped: this library is dormant. Enable it before scanning."); + scan = scan with { State = ScanState.Verifying, Stage = "Verifying root", StartedAt = scan.StartedAt ?? DateTimeOffset.UtcNow }; + await store.UpdateScanAsync(scan, cancellationToken); + var verification = files.Verify(root.Path); + var verifiedRoot = root with { IsAvailable = verification.Available, IsReadOnly = verification.IsReadOnly, HasEntries = verification.HasEntries, LastVerifiedAt = DateTimeOffset.UtcNow }; + await store.UpsertLibraryAsync(verifiedRoot, cancellationToken); + if (HealthRules.EvaluateRoot(verifiedRoot) is { } rootHealth) + await store.AddFindingAsync(new(DeterministicGuid(root.Id, rootHealth.Category, "root-health"), rootHealth.Category, rootHealth.Severity, rootHealth.Message, null, null, DateTimeOffset.UtcNow), cancellationToken); + if (!verification.Available) throw new IOException(verification.Message); + if (verification.HasEntries == false) + { + var stored = (await store.GetStorageSummaryAsync(cancellationToken)).Libraries + .FirstOrDefault(item => item.LibraryId == root.Id); + var presentArtifacts = stored is null ? 0 : stored.Artifacts - stored.Missing; + if (presentArtifacts > 0) + throw new IOException($"Scan stopped: the source is empty while {presentArtifacts} catalog artifacts remain present. Restore or populate the mount, verify it, and retry."); + } + + scan = scan with { State = ScanState.Discovering, Stage = "Discovering files" }; + await store.UpdateScanAsync(scan, cancellationToken); + var observed = new HashSet<string>(StringComparer.OrdinalIgnoreCase); + await foreach (var file in files.EnumerateAsync(root, cancellationToken)) + { + var latest = await store.GetScanAsync(scanId, cancellationToken); + while (latest?.State == ScanState.Paused) + { + await Task.Delay(500, cancellationToken); + latest = await store.GetScanAsync(scanId, cancellationToken); + } + if (latest?.CancellationRequested == true) + { + await store.UpdateScanAsync(latest with { State = ScanState.Cancelled, Stage = "Cancelled", FinishedAt = DateTimeOffset.UtcNow }, cancellationToken); + return; + } + observed.Add(file.RelativePath); + var existing = await store.FindByPathAsync(root.Id, file.RelativePath, cancellationToken); + if (LibraryContentPolicy.IdentifyNonGamePath(file.RelativePath) is { } nonGame) + { + if (existing?.State != ArtifactState.Ignored || existing.MediaType != MediaType.SupportFile) + { + var evidence = new Evidence("content.role", nonGame.Evidence, "library-content-policy", "1", + Confidence.Deterministic, DateTimeOffset.UtcNow); + if (existing is not null) + await store.MarkArtifactIgnoredAsync(existing.Id, existing.Version + 1, evidence, cancellationToken); + else + { + var ignored = new Artifact(Guid.NewGuid(), root.Id, file.RelativePath, file.Size, + file.ModifiedAt, ArtifactState.Ignored, MediaType.SupportFile, Confidence.Deterministic, + null, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, null, null, 1); + await store.UpsertArtifactAsync(ignored, null, [evidence], cancellationToken); + scan = scan with { NewItems = scan.NewItems + 1 }; + } + } + scan = scan with { ItemsCompleted = scan.ItemsCompleted + 1, BytesCompleted = scan.BytesCompleted + file.Size }; + if (scan.ItemsCompleted % 250 == 0) await store.UpdateScanAsync(scan, cancellationToken); + continue; + } + var unchanged = existing is not null && existing.Size == file.Size && existing.ModifiedAt == file.ModifiedAt && existing.ContentBlobId is not null; + if (!unchanged) + { + await using var stream = await files.OpenReadAsync(root, file.RelativePath, cancellationToken); + const int maxHeaderBytes = 66 * 1024; + var header = new byte[Math.Min(maxHeaderBytes, (int)Math.Min(file.Size, maxHeaderBytes))]; + var read = await stream.ReadAsync(header, cancellationToken); + stream.Position = 0; + var platformHint = ArtifactAnalysis.InferPlatformHint(root.Name, root.Path, file.RelativePath); + var classification = ArtifactAnalysis.Classify(file.RelativePath, header.AsSpan(0, read), platformHint); + var inspectionEvidence = classification.Evidence.ToList(); + if (classification.MediaType == MediaType.DiscImage && + Path.GetExtension(file.RelativePath).Equals(".iso", StringComparison.OrdinalIgnoreCase)) + { + var disc = await DiscImageAnalysis.InspectIsoAsync(stream, cancellationToken); + inspectionEvidence.AddRange(disc.Evidence); + if (disc.Platform is not null) + { + for (var index = 0; index < inspectionEvidence.Count; index++) + if (inspectionEvidence[index].Kind == "directory.platform") + inspectionEvidence[index] = inspectionEvidence[index] with { Confidence = Confidence.Medium }; + if (platformHint is not null && !platformHint.Equals(disc.Platform, StringComparison.OrdinalIgnoreCase)) + inspectionEvidence.Add(new("platform.conflict", $"directory:{platformHint};disc:{disc.Platform}", + "builtin-iso9660", "1", Confidence.High, DateTimeOffset.UtcNow)); + classification = classification with + { + Platform = disc.Platform, + Confidence = disc.Confidence, + Supported = true, + Capability = "classify:iso9660-platform" + }; + } + stream.Position = 0; + } + if (classification.MediaType == MediaType.DiscImage && + Path.GetExtension(file.RelativePath).Equals(".pbp", StringComparison.OrdinalIgnoreCase)) + { + var disc = await DiscImageAnalysis.InspectPbpAsync(stream, cancellationToken); + inspectionEvidence.AddRange(disc.Evidence); + if (disc.Platform is not null) + { + for (var index = 0; index < inspectionEvidence.Count; index++) + if (inspectionEvidence[index].Kind == "directory.platform") + inspectionEvidence[index] = inspectionEvidence[index] with { Confidence = Confidence.Medium }; + if (platformHint is not null && !platformHint.Equals(disc.Platform, StringComparison.OrdinalIgnoreCase)) + inspectionEvidence.Add(new("platform.conflict", $"directory:{platformHint};disc:{disc.Platform}", + "builtin-pbp", "1", Confidence.High, DateTimeOffset.UtcNow)); + classification = classification with + { + Platform = disc.Platform, + Confidence = disc.Confidence, + Supported = true, + Capability = "classify:pbp-platform" + }; + } + stream.Position = 0; + } + if (classification.MediaType == MediaType.WindowsPackage && Path.GetExtension(file.RelativePath).Equals(".exe", StringComparison.OrdinalIgnoreCase)) + { + try { inspectionEvidence.AddRange(WindowsPackageAnalysis.InspectPe(stream).Evidence); } + catch (BadImageFormatException) { inspectionEvidence.Add(new("pe.inspection", "invalid-or-truncated", "pe-reader", "1", Confidence.Deterministic, DateTimeOffset.UtcNow)); } + stream.Position = 0; + } + if (classification.MediaType == MediaType.WindowsPackage && Path.GetExtension(file.RelativePath).Equals(".msi", StringComparison.OrdinalIgnoreCase)) + { + var msiPath = Path.Combine(root.Path, file.RelativePath); + try + { + var summary = WindowsPackageAnalysis.InspectMsiSummary(msiPath); + inspectionEvidence.AddRange(summary.Select(item => new Evidence($"msi.summary.{item.Key}", item.Value, + "ole-property-set", "1", Confidence.Deterministic, DateTimeOffset.UtcNow))); + } + catch (Exception exception) when (exception is IOException or InvalidDataException or ArgumentException or OpenMcdf.FileFormatException) + { + inspectionEvidence.Add(new("msi.inspection", "invalid-or-truncated", "ole-property-set", "1", + Confidence.Deterministic, DateTimeOffset.UtcNow)); + } + try + { + var database = MsiDatabaseAnalysis.Inspect(msiPath, cancellationToken); + inspectionEvidence.AddRange(database.Properties.Select(item => new Evidence($"msi.property.{item.Key}", item.Value, + "msi-database", "1", Confidence.Deterministic, DateTimeOffset.UtcNow))); + inspectionEvidence.Add(new("msi.files", database.Files.Count.ToString(System.Globalization.CultureInfo.InvariantCulture), + "msi-database", "1", Confidence.Deterministic, DateTimeOffset.UtcNow)); + foreach (var cabinet in database.Cabinets) + inspectionEvidence.Add(new("msi.cabinet", $"{(cabinet.Embedded ? "embedded" : "external")}:{cabinet.Cabinet}:{cabinet.LastSequence}", + "msi-database", "1", Confidence.Deterministic, DateTimeOffset.UtcNow)); + foreach (var finding in database.Findings) + inspectionEvidence.Add(new("msi.finding", finding, "msi-database", "1", Confidence.Deterministic, DateTimeOffset.UtcNow)); + } + catch (Exception exception) when (exception is IOException or InvalidDataException or ArgumentException or OpenMcdf.FileFormatException) + { + inspectionEvidence.Add(new("msi.database.inspection", "invalid-truncated-or-unsupported", "msi-database", "1", + Confidence.Deterministic, DateTimeOffset.UtcNow)); + } + } + HashResult? hashes = null; + // An integrity scan is an explicit operator request for cryptographic evidence. + // OnDemand is deliberately cheap for normal deep scans, but must never turn an + // integrity run into a no-op. + if (scan.Mode == ScanMode.Integrity || (scan.Mode != ScanMode.Quick && root.HashPolicy != HashPolicy.OnDemand)) + hashes = await ArtifactAnalysis.HashAsync(stream, root.HashPolicy == HashPolicy.CatalogCompatible, cancellationToken); + + var moved = hashes is null ? null : await store.FindByHashAsync(root.Id, file.Size, hashes.Sha256, cancellationToken); + var id = existing?.Id ?? moved?.Id ?? Guid.NewGuid(); + if (existing is null) + scan = moved is null + ? scan with { NewItems = scan.NewItems + 1 } + : scan with { MovedItems = scan.MovedItems + 1 }; + ArchiveInspection? archiveInspection = null; + var archiveExtension = Path.GetExtension(file.RelativePath); + if (root.InspectArchives && scan.Mode != ScanMode.Quick && classification.MediaType == MediaType.Archive && + (archiveExtension.Equals(".zip", StringComparison.OrdinalIgnoreCase) || archiveExtension.Equals(".7z", StringComparison.OrdinalIgnoreCase))) + { + try + { + stream.Position = 0; + archiveInspection = archiveExtension.Equals(".7z", StringComparison.OrdinalIgnoreCase) + ? ArchiveAnalysis.InspectSevenZip(id, stream, cancellationToken: cancellationToken) + : ArchiveAnalysis.InspectZip(id, stream, cancellationToken: cancellationToken); + inspectionEvidence.Add(new("archive.members", archiveInspection.Members.Count.ToString(System.Globalization.CultureInfo.InvariantCulture), + archiveExtension.Equals(".7z", StringComparison.OrdinalIgnoreCase) ? "7z-header" : "zip-central-directory", "1", Confidence.Deterministic, DateTimeOffset.UtcNow)); + } + catch (Exception exception) when (exception is InvalidDataException or FormatException) + { + inspectionEvidence.Add(new("archive.inspection", "invalid-or-truncated", archiveExtension.Equals(".7z", StringComparison.OrdinalIgnoreCase) ? "7z-header" : "zip-central-directory", "1", + Confidence.Deterministic, DateTimeOffset.UtcNow)); + } + } + var content = hashes is null ? null : new ContentBlob(Guid.NewGuid(), file.Size, hashes.Sha256, + NullIfEmpty(hashes.Sha1), NullIfEmpty(hashes.Crc32), DateTimeOffset.UtcNow); + var artifact = new Artifact(id, root.Id, file.RelativePath, file.Size, file.ModifiedAt, ArtifactState.Present, + classification.MediaType, classification.Confidence, content?.Id ?? existing?.ContentBlobId, + existing?.FirstSeenAt ?? moved?.FirstSeenAt ?? DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, + classification.Evidence.FirstOrDefault(x => x.Kind == "signature")?.Value, classification.Platform, + (existing?.Version ?? moved?.Version ?? 0) + 1, hashes?.Sha256 ?? existing?.Sha256); + if (await store.GetArtifactOverrideAsync(id, cancellationToken) is { } manual) + { + artifact = artifact with + { + State = manual.Ignore ? ArtifactState.Ignored : artifact.State, + MediaType = manual.MediaType ?? artifact.MediaType, + Platform = manual.Platform ?? artifact.Platform, + Confidence = Confidence.Deterministic + }; + inspectionEvidence.Add(new("classification.manual-override", manual.Ignore ? "ignored" : manual.Platform ?? manual.MediaType?.ToString() ?? "applied", + "operator", "1", Confidence.Deterministic, manual.CreatedAt)); + } + if (existing?.Sha256 is { } previousHash && hashes is not null && !previousHash.Equals(hashes.Sha256, StringComparison.OrdinalIgnoreCase)) + { + scan = scan with { ChangedItems = scan.ChangedItems + 1 }; + await store.AddFindingAsync(new(Guid.NewGuid(), "ChangedVerifiedContent", Severity.Critical, + "Verified content changed at an existing artifact path.", artifact.Id, null, DateTimeOffset.UtcNow), cancellationToken); + } + await store.UpsertArtifactAsync(artifact, content, inspectionEvidence, cancellationToken); + if (archiveInspection is not null) + { + await store.ReplaceArchiveMembersAsync(artifact.Id, archiveInspection.Members, cancellationToken); + foreach (var finding in archiveInspection.Findings) + await store.AddFindingAsync(new(DeterministicGuid(artifact.Id, finding, "archive-health"), "ArchiveSafety", Severity.Warning, + finding, artifact.Id, null, DateTimeOffset.UtcNow), cancellationToken); + } + if (HealthRules.EvaluateArtifact(artifact) is { } artifactHealth) + await store.AddFindingAsync(new(DeterministicGuid(artifact.Id, artifactHealth.Category, "artifact-health"), artifactHealth.Category, artifactHealth.Severity, artifactHealth.Message, artifact.Id, null, DateTimeOffset.UtcNow), cancellationToken); + if (classification.MediaType == MediaType.Unknown) + await store.AddReviewAsync(new(DeterministicGuid(artifact.Id, "unknown-review"), "Unknown content", Severity.Notice, ReviewState.Open, + System.Text.Json.JsonSerializer.Serialize(new { artifact.Id, artifact.RelativePath }), DateTimeOffset.UtcNow), cancellationToken); + } + scan = scan with + { + ItemsCompleted = scan.ItemsCompleted + 1, + BytesCompleted = scan.BytesCompleted + file.Size, + Checkpoint = file.RelativePath, + Stage = "Classifying and hashing" + }; + await store.UpdateScanAsync(scan, cancellationToken); + } + await DetectDescriptorBundlesAsync(root, observed, cancellationToken); + await DetectWindowsBundlesAsync(root, observed, cancellationToken); + // Missing reconciliation is only reached after positive root verification and complete traversal. + var deleted = await store.MarkMissingExceptAsync(root.Id, observed, DateTimeOffset.UtcNow, cancellationToken); + scan = scan with { DeletedItems = deleted }; + await store.SynchronizeDiscoveredGamesAsync(root.Id, cancellationToken); + await store.ReconcileWishlistAsync("scan-reconciliation", cancellationToken); + await store.UpdateScanAsync(scan with { State = ScanState.Completed, Stage = "Complete", FinishedAt = DateTimeOffset.UtcNow }, cancellationToken); + await store.EnqueueJobAsync("ArtworkEnrichment", "{}", $"artwork:scan:{scan.Id:N}", cancellationToken); + await store.EnqueueJobAsync("MetadataEnrichment", "{}", $"metadata:scan:{scan.Id:N}", cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + await store.UpdateScanAsync(scan with { State = ScanState.Cancelled, Stage = "Cancelled", FinishedAt = DateTimeOffset.UtcNow }, CancellationToken.None); + } + catch (Exception ex) + { + await store.UpdateScanAsync(scan with { State = ScanState.Failed, Stage = "Failed", Error = ex.Message, FinishedAt = DateTimeOffset.UtcNow }, CancellationToken.None); + } + } + + private async Task DetectDescriptorBundleAsync(LibraryRoot root, Artifact artifact, CancellationToken cancellationToken) + { + var ext = Path.GetExtension(artifact.RelativePath); + if (!ext.Equals(".cue", StringComparison.OrdinalIgnoreCase) && !ext.Equals(".m3u", StringComparison.OrdinalIgnoreCase)) return; + await using var stream = await files.OpenReadAsync(root, artifact.RelativePath, cancellationToken); + using var reader = new StreamReader(stream, Encoding.UTF8, true, 4096, leaveOpen: false); + var text = await reader.ReadToEndAsync(cancellationToken); + var refs = ext.Equals(".cue", StringComparison.OrdinalIgnoreCase) ? ArtifactAnalysis.ParseCueReferences(text) : ArtifactAnalysis.ParseM3uReferences(text); + var members = new List<BundleMember> { new(artifact.Id, "descriptor", 0, true, "explicit descriptor") }; + var findings = new List<string>(); + var directory = Path.GetDirectoryName(artifact.RelativePath) ?? string.Empty; + var sequence = 1; + foreach (var reference in refs) + { + if (!ArtifactAnalysis.IsSafeRelativeReference(reference)) { findings.Add($"Reference escapes the library boundary: {reference}"); continue; } + var relative = Path.GetRelativePath(root.Path, files.ResolveContainedPath(root, Path.Combine(directory, reference))).Replace('\\', '/'); + var target = await store.FindByPathAsync(root.Id, relative, cancellationToken); + if (target is null && ext.Equals(".cue", StringComparison.OrdinalIgnoreCase)) + { + target = await store.FindByPathAsync(root.Id, relative + ".ecm", cancellationToken); + if (target is not null) + { + members.Add(new(target.Id, "encoded track", sequence++, true, + $"exact ECM sidecar for {reference}")); + findings.Add($"Referenced track is present as legacy ECM content and needs derived-cache conversion for playback: {reference}.ecm"); + continue; + } + } + if (target is null) findings.Add($"Referenced file was not found: {reference}"); + else members.Add(new(target.Id, ext.Equals(".m3u", StringComparison.OrdinalIgnoreCase) ? "disc" : "track", sequence++, true, reference)); + } + var onlyConvertibleEcm = findings.Count > 0 && findings.All(finding => + finding.StartsWith("Referenced track is present as legacy ECM content", StringComparison.Ordinal)); + var state = findings.Count == 0 ? BundleState.Complete + : onlyConvertibleEcm ? BundleState.CompleteWithWarnings : BundleState.Incomplete; + var bundleId = DeterministicGuid(root.Id, artifact.RelativePath, "descriptor-bundle"); + var bundle = new Bundle(bundleId, ext.Equals(".cue", StringComparison.OrdinalIgnoreCase) ? BundleKind.CueBin : BundleKind.MultiDisc, + Path.GetFileNameWithoutExtension(artifact.RelativePath), state, members, findings); + await store.AddBundleAsync(bundle, cancellationToken); + if (HealthRules.EvaluateBundle(bundle) is { } bundleHealth) + await store.AddFindingAsync(new(DeterministicGuid(bundleId, bundleHealth.Category, "bundle-health"), bundleHealth.Category, bundleHealth.Severity, bundleHealth.Message, null, bundleId, DateTimeOffset.UtcNow), cancellationToken); + if (state == BundleState.Incomplete) + await store.AddReviewAsync(new(DeterministicGuid(bundleId, "incomplete-review"), "Incomplete bundle", Severity.Warning, ReviewState.Open, + System.Text.Json.JsonSerializer.Serialize(new { artifact.Id, findings }), DateTimeOffset.UtcNow), cancellationToken); + } + + private async Task DetectDescriptorBundlesAsync(LibraryRoot root, IReadOnlySet<string> observed, CancellationToken cancellationToken) + { + foreach (var path in observed.Where(x => Path.GetExtension(x).Equals(".cue", StringComparison.OrdinalIgnoreCase) || Path.GetExtension(x).Equals(".m3u", StringComparison.OrdinalIgnoreCase))) + if (await store.FindByPathAsync(root.Id, path, cancellationToken) is { } artifact) + await DetectDescriptorBundleAsync(root, artifact, cancellationToken); + } + + private async Task DetectWindowsBundlesAsync(LibraryRoot root, IReadOnlySet<string> observed, CancellationToken cancellationToken) + { + var groups = observed.GroupBy(x => (Path.GetDirectoryName(x) ?? string.Empty).Replace('\\', '/'), StringComparer.OrdinalIgnoreCase); + foreach (var directory in groups) + { + var paths = directory.Order(StringComparer.OrdinalIgnoreCase).ToArray(); + var primaries = paths.Where(x => (Path.GetExtension(x).Equals(".exe", StringComparison.OrdinalIgnoreCase) || Path.GetExtension(x).Equals(".msi", StringComparison.OrdinalIgnoreCase)) && + (Path.GetFileNameWithoutExtension(x).Contains("setup", StringComparison.OrdinalIgnoreCase) || Path.GetFileNameWithoutExtension(x).Contains("install", StringComparison.OrdinalIgnoreCase))).ToArray(); + var payloads = paths.Where(x => new[] { ".bin", ".cab", ".dat" }.Contains(Path.GetExtension(x), StringComparer.OrdinalIgnoreCase)).ToArray(); + if (primaries.Length == 0 || payloads.Length == 0) continue; + var bundleId = DeterministicGuid(root.Id, directory.Key, "windows-installer"); + var members = new List<BundleMember>(); + foreach (var primary in primaries) + if (await store.FindByPathAsync(root.Id, primary, cancellationToken) is { } artifact) + members.Add(new(artifact.Id, "bootstrapper", members.Count, true, "installer filename and neighboring payloads")); + foreach (var payload in payloads) + if (await store.FindByPathAsync(root.Id, payload, cancellationToken) is { } artifact) + members.Add(new(artifact.Id, "required payload", members.Count, true, "same-directory installer payload pattern")); + var missing = WindowsPackageAnalysis.MissingNumberedPayloads(payloads); + var findings = missing.Select(x => $"Numbered installer payload {x} is missing.").ToList(); + if (primaries.Length > 1) findings.Add("Multiple installer primaries were found; operator review is required."); + var state = primaries.Length > 1 ? BundleState.Ambiguous : findings.Count > 0 ? BundleState.Incomplete : BundleState.Complete; + var bundle = new Bundle(bundleId, BundleKind.WindowsInstaller, string.IsNullOrEmpty(directory.Key) ? Path.GetFileNameWithoutExtension(primaries[0]) : Path.GetFileName(directory.Key), state, members, findings); + await store.AddBundleAsync(bundle, cancellationToken); + if (HealthRules.EvaluateBundle(bundle) is { } bundleHealth) + await store.AddFindingAsync(new(DeterministicGuid(bundleId, bundleHealth.Category, "bundle-health"), bundleHealth.Category, bundleHealth.Severity, bundleHealth.Message, null, bundleId, DateTimeOffset.UtcNow), cancellationToken); + if (state is BundleState.Ambiguous or BundleState.Incomplete) + await store.AddReviewAsync(new(DeterministicGuid(bundleId, "bundle-review"), state == BundleState.Ambiguous ? "Windows package role ambiguity" : "Incomplete bundle", + Severity.Warning, ReviewState.Open, System.Text.Json.JsonSerializer.Serialize(new { bundleId, primaries, payloads, findings }), DateTimeOffset.UtcNow), cancellationToken); + } + } + + private static Guid DeterministicGuid(Guid scope, string value, string kind) => new(SHA256.HashData(Encoding.UTF8.GetBytes($"{scope:N}|{kind}|{value}"))[..16]); + private static Guid DeterministicGuid(Guid scope, string kind) => DeterministicGuid(scope, string.Empty, kind); + + private static string? NullIfEmpty(string value) => string.IsNullOrEmpty(value) ? null : value; +} diff --git a/src/Ludarium.Application/SnapshotComparer.cs b/src/Ludarium.Application/SnapshotComparer.cs new file mode 100644 index 0000000..ab6d92f --- /dev/null +++ b/src/Ludarium.Application/SnapshotComparer.cs @@ -0,0 +1,28 @@ +using Ludarium.Domain; + +namespace Ludarium.Application; + +public sealed class SnapshotComparer : ISnapshotComparer +{ + public IReadOnlyList<SnapshotChange> Compare(IntegritySnapshot before, IntegritySnapshot after) + { + var changes = new List<SnapshotChange>(); + var beforeByArtifact = before.Items.ToDictionary(x => x.ArtifactId); + var afterByArtifact = after.Items.ToDictionary(x => x.ArtifactId); + foreach (var oldItem in before.Items) + { + if (afterByArtifact.TryGetValue(oldItem.ArtifactId, out var current)) + { + if (!oldItem.Sha256.Equals(current.Sha256, StringComparison.OrdinalIgnoreCase)) + changes.Add(new(SnapshotChangeKind.ContentChanged, oldItem, current, "The artifact identity is retained but its verified SHA-256 changed.")); + else if (!oldItem.RelativePath.Equals(current.RelativePath, StringComparison.OrdinalIgnoreCase) || oldItem.LibraryId != current.LibraryId) + changes.Add(new(SnapshotChangeKind.Moved, oldItem, current, "Verified content retained its artifact identity at a new location.")); + else changes.Add(new(SnapshotChangeKind.Unchanged, oldItem, current, "Path and verified content are unchanged.")); + } + else changes.Add(new(SnapshotChangeKind.Removed, oldItem, null, "The artifact is absent from the later available-root snapshot.")); + } + foreach (var current in after.Items.Where(x => !beforeByArtifact.ContainsKey(x.ArtifactId))) + changes.Add(new(SnapshotChangeKind.Added, null, current, "A new artifact appears in the later snapshot.")); + return changes.OrderBy(x => x.Kind).ThenBy(x => x.After?.RelativePath ?? x.Before?.RelativePath, StringComparer.OrdinalIgnoreCase).ToArray(); + } +} diff --git a/src/Ludarium.Application/SwitchLaunchPolicy.cs b/src/Ludarium.Application/SwitchLaunchPolicy.cs new file mode 100644 index 0000000..e025b2c --- /dev/null +++ b/src/Ludarium.Application/SwitchLaunchPolicy.cs @@ -0,0 +1,83 @@ +using System.Text.RegularExpressions; + +namespace Ludarium.Application; + +/// <summary> +/// Selects the one unique read-only Switch base game an Eden session may launch. +/// </summary> +/// <remarks> +/// Switch is the only native player whose linked files are not interchangeable: updates and DLC +/// share a directory with the base game and cannot start on their own, so the base title identity is +/// read out of the dump name rather than inferred from the extension. +/// </remarks> +public static partial class SwitchLaunchPolicy +{ + public static NativeRemotePlayer Player => NativeRemotePlayerRegistry.Switch; + + /// <summary>Below this size a linked file is a companion artifact, not a base game.</summary> + private const long MinimumImageBytes = 1024 * 1024; + + [GeneratedRegex(@"\[(?<titleId>[0-9a-fA-F]{16})\]\[v(?<version>\d+)\]", RegexOptions.CultureInvariant)] + private static partial Regex TitleIdentity(); + + public static NativeLaunchCandidate? SelectBaseGame(IReadOnlyList<NativeLaunchCandidate> candidates) + { + var eligible = EligibleBaseGames(candidates); + + var xci = eligible.Where(item => Path.GetExtension(item.Candidate.RelativePath).Equals(".xci", StringComparison.OrdinalIgnoreCase)).ToArray(); + if (xci.Length == 1) return xci[0].Candidate; + if (xci.Length > 1) return null; + + var nsp = eligible.Where(item => Path.GetExtension(item.Candidate.RelativePath).Equals(".nsp", StringComparison.OrdinalIgnoreCase)).ToArray(); + return nsp.Length == 1 ? nsp[0].Candidate : null; + } + + public static NativeRemotePlayCapability EvaluateCapability(Guid gameId, bool isSwitchGame, + bool runtimeReady, bool productionKeysReady, IReadOnlyList<NativeLaunchCandidate> candidates, + DateTimeOffset? checkedAt = null, bool vaultBacked = false) + { + var now = checkedAt ?? DateTimeOffset.UtcNow; + if (!isSwitchGame) + return Unavailable(NativeRemotePlayState.UnsupportedPlatform, + "Only Nintendo Switch games can use the isolated Eden player."); + if (!runtimeReady) + return Unavailable(NativeRemotePlayState.RuntimeUnavailable, + "The isolated Eden runtime or exact-title controller is not available."); + if (!productionKeysReady) + return Unavailable(NativeRemotePlayState.MissingKeys, + "Select your own prod.keys in Settings before starting this game."); + if (candidates.Count == 0) + return Unavailable(NativeRemotePlayState.MissingGame, + "No linked XCI or NSP was found for this game."); + + var selected = SelectBaseGame(candidates); + if (selected is null) + { + if (!candidates.Any(candidate => candidate.SourceReadOnly)) + return Unavailable(NativeRemotePlayState.SourceNotReadOnly, + "The linked Switch source is not mounted read-only, so playback is blocked."); + if (EligibleBaseGames(candidates).Length == 0) + return Unavailable(NativeRemotePlayState.MissingBaseGame, + "Linked Switch files were found, but none is a qualified base game. Add the base XCI or NSP; updates and DLC cannot start alone."); + return Unavailable(NativeRemotePlayState.AmbiguousMapping, + "Ludarium could not identify one unique read-only base XCI or NSP for this game."); + } + + return NativeRemotePlayPolicy.Available(Player, gameId, + "The exact linked base game is ready in the isolated Eden player.", now, "switch", vaultBacked); + + NativeRemotePlayCapability Unavailable(NativeRemotePlayState state, string message) => + NativeRemotePlayPolicy.Unavailable(Player, gameId, state, message, now, + isSwitchGame ? "switch" : null); + } + + private static (NativeLaunchCandidate Candidate, Match Match)[] EligibleBaseGames( + IReadOnlyList<NativeLaunchCandidate> candidates) => candidates + .Where(candidate => candidate.SourceReadOnly && candidate.Size > MinimumImageBytes) + .Select(candidate => (Candidate: candidate, Match: TitleIdentity().Match(candidate.RelativePath))) + .Where(item => item.Match.Success && item.Match.Groups["titleId"].Value.EndsWith("000", StringComparison.OrdinalIgnoreCase)) + .ToArray(); + + public static string ToRuntimePath(string relativePath, string catalogPrefix) => + NativeRemotePlayPolicy.ToRuntimePath(relativePath, catalogPrefix, Player.Extensions); +} diff --git a/src/Ludarium.Application/packages.lock.json b/src/Ludarium.Application/packages.lock.json new file mode 100644 index 0000000..52cea0e --- /dev/null +++ b/src/Ludarium.Application/packages.lock.json @@ -0,0 +1,28 @@ +{ + "version": 1, + "dependencies": { + "net10.0": { + "OpenMcdf": { + "type": "Direct", + "requested": "[3.2.0, )", + "resolved": "3.2.0", + "contentHash": "n/iojS7V77YjM6IBbXaP0ZI8EELhJ2j6XRjx9NxMyUtjcM4a0yr0fWqO1y7lgd3CgYwnDugaKlO9c7Os8VxNtg==" + }, + "SharpCompress": { + "type": "Direct", + "requested": "[0.50.4, )", + "resolved": "0.50.4", + "contentHash": "/hxjUR7DEX6mky8/LQXyrnrKioOL6D6veAID1EZpro+q4s02x5dHEYBV3qjEN6lDYYilDoQQ76BcmQj+lRx51w==" + }, + "System.IO.Hashing": { + "type": "Direct", + "requested": "[10.0.11, )", + "resolved": "10.0.11", + "contentHash": "OzKDcIRkeNJeC8qAsbn8yJXnfTLP1dtkWILe+T56Gf/z+IkAASi7sMqLqJQat08j5z/mRN5xVtoAwbkMNMoBUQ==" + }, + "ludarium.domain": { + "type": "Project" + } + } + } +} \ No newline at end of file diff --git a/src/Ludarium.Domain/Ludarium.Domain.csproj b/src/Ludarium.Domain/Ludarium.Domain.csproj new file mode 100644 index 0000000..b760144 --- /dev/null +++ b/src/Ludarium.Domain/Ludarium.Domain.csproj @@ -0,0 +1,9 @@ +<Project Sdk="Microsoft.NET.Sdk"> + + <PropertyGroup> + <TargetFramework>net10.0</TargetFramework> + <ImplicitUsings>enable</ImplicitUsings> + <Nullable>enable</Nullable> + </PropertyGroup> + +</Project> diff --git a/src/Ludarium.Domain/Model.cs b/src/Ludarium.Domain/Model.cs new file mode 100644 index 0000000..9516c0e --- /dev/null +++ b/src/Ludarium.Domain/Model.cs @@ -0,0 +1,227 @@ +namespace Ludarium.Domain; + +public enum LibraryKind { Rom, WindowsPackage, DiscImage, Mixed, Unsorted } +public enum HashPolicy { Sha256, CatalogCompatible, OnDemand } +public enum ScanMode { Quick, Deep, Integrity } +public enum ScanState { Queued, Verifying, Discovering, Processing, Paused, Cancelling, Completed, Failed, Cancelled } +public enum ArtifactState { Present, Missing, LibraryUnavailable, Ignored, Superseded } +public enum MediaType { Rom, DiscDescriptor, DiscImage, Archive, WindowsPackage, Document, SupportFile, Unknown } +public enum Confidence { None, Low, Medium, High, Deterministic } +public enum BundleKind { SingleRom, CueBin, MultiDisc, WindowsInstaller, ArchiveGroup } +public enum BundleState { Complete, CompleteWithWarnings, Incomplete, Ambiguous, Unverified } +public enum ReviewState { Open, Deferred, Resolved } +public enum ReviewBulkAction { ClassifySupportContent, AcceptPlatform, ExcludeFolderPattern } +public enum Severity { Info, Notice, Warning, Critical } +public enum JobState { Queued, Leased, Paused, Completed, Failed, DeadLetter, Cancelled } +public enum ArtworkRole { VerifiedBoxFront, BoxFront, Poster, Banner, Screenshot, GeneratedFallback, UserUpload } +public enum ArtworkVerificationStatus { Unverified, AutomaticallyVerified, ManuallyVerified, NeedsReview, Rejected } +public enum WishlistPriority { Low, Normal, High } +public enum WishlistStatus { Interested, WaitingForRelease, WaitingForSale, Reserved, Acquired } +public enum CollectionKind { Static, Smart, Virtual } +public enum GamePlayStatus { None, Backlog, Playing, Paused, Completed, Abandoned } +public enum GameRelationKind { Dlc, Mod, Hack, Patch, Translation, Expansion, Manual, Extra, Sequel, Prequel } +public enum GameMediaKind { Screenshot, Manual, Background, Logo, Video, Soundtrack } +public enum GameDataKind { Save, SaveState } +public enum ReleaseOrigin { Scan, Manual } +public enum GameOrigin { Scan, Manual } +public enum BrowserPlayState { Available, Disabled, UnsupportedPlatform, UnsupportedFormat, MissingRom, MissingCore, EmulatorUnavailable, AmbiguousMapping, CapacityReached, Expired, Cancelled, Failed } +public enum PlaySessionState { Starting, Ready, Active, Completed, Cancelled, Expired, Failed } +public enum FirmwareAssetKind { Bios, Firmware, KeySet } + +public sealed record LibraryRoot( + Guid Id, string Name, string Path, LibraryKind Kind, bool Recursive, HashPolicy HashPolicy, + bool InspectArchives, int MaxConcurrency, bool Enabled, bool ReadOnlyRequired, + bool? IsReadOnly = null, bool IsAvailable = false, DateTimeOffset? LastVerifiedAt = null, + string[]? Exclusions = null, long Version = 1, bool? HasEntries = null) +{ + public static LibraryRoot Create(string name, string path, LibraryKind kind, bool recursive = true, + HashPolicy hashPolicy = HashPolicy.Sha256, bool inspectArchives = true, int maxConcurrency = 1) + { + if (string.IsNullOrWhiteSpace(name)) throw new ArgumentException("Library name is required.", nameof(name)); + if (string.IsNullOrWhiteSpace(path) || !System.IO.Path.IsPathFullyQualified(path)) + throw new ArgumentException("Library path must be absolute.", nameof(path)); + return new(Guid.NewGuid(), name.Trim(), System.IO.Path.GetFullPath(path), kind, recursive, hashPolicy, + inspectArchives, Math.Clamp(maxConcurrency, 1, 8), true, true); + } +} + +public sealed record LibraryHealthSummary(Guid LibraryId, long Artifacts, long Games, long Bytes, + IReadOnlyList<string> DetectedPlatforms, DateTimeOffset? LastFullScan, DateTimeOffset? LastIncrementalScan); + +public sealed record Artifact( + Guid Id, Guid LibraryId, string RelativePath, long Size, DateTimeOffset ModifiedAt, + ArtifactState State, MediaType MediaType, Confidence Confidence, Guid? ContentBlobId, + DateTimeOffset FirstSeenAt, DateTimeOffset LastSeenAt, string? Signature, + string? Platform, long Version = 1, string? Sha256 = null); + +public sealed record ArtifactOverride(Guid ArtifactId, MediaType? MediaType, string? Platform, bool Ignore, + string Actor, DateTimeOffset CreatedAt); + +public sealed record ContentBlob(Guid Id, long Size, string Sha256, string? Sha1, string? Crc32, + DateTimeOffset VerifiedAt); + +public sealed record ArchiveMember(Guid Id, Guid ParentArtifactId, int Sequence, string Path, long CompressedSize, + long UncompressedSize, bool IsDirectory, bool UnsafePath, Guid? ContentBlobId); + +public sealed record Evidence(string Kind, string Value, string Source, string SourceVersion, + Confidence Confidence, DateTimeOffset ObservedAt); + +public sealed record MetadataClaim(Guid Id, string EntityType, Guid EntityId, string Field, string Value, + string SourceType, string SourceId, Confidence Confidence, IReadOnlyList<Evidence> Evidence, + bool ManualLock, DateTimeOffset CreatedAt, Guid? SupersedesId = null); + +public sealed record BundleMember(Guid ArtifactId, string Role, int Sequence, bool Required, string Evidence); + +public sealed record Bundle(Guid Id, BundleKind Kind, string Name, BundleState State, + IReadOnlyList<BundleMember> Members, IReadOnlyList<string> Findings, long Version = 1); + +public sealed record ReviewItem(Guid Id, string Reason, Severity Severity, ReviewState State, + string PayloadJson, DateTimeOffset CreatedAt, DateTimeOffset? ResolvedAt = null, + string? Resolution = null); + +public sealed record ReviewGroup(string Key, string Category, string Reason, Severity Severity, + long Count, long EstimatedBytes, string? Library, string? Platform, string PathPattern, + DateTimeOffset OldestAt, DateTimeOffset NewestAt, string RecommendedAction, + IReadOnlyList<Guid> ReviewIds, string? Resolution = null, DateTimeOffset? ResolvedAt = null, + long? TotalCount = null); + +public sealed record ReviewOperation(Guid Id, IReadOnlyList<ReviewItem> Before, + string Resolution, string Actor, DateTimeOffset CreatedAt, DateTimeOffset? UndoneAt = null, + IReadOnlyList<Artifact>? BeforeArtifacts = null, ReviewBulkAction? BulkAction = null, + IReadOnlyList<LibraryRoot>? BeforeLibraries = null, string? ScopePattern = null, + IReadOnlyList<ArtifactOverride>? BeforeOverrides = null, + IReadOnlyList<ReviewItem>? After = null, IReadOnlyList<Artifact>? AfterArtifacts = null, + IReadOnlyList<LibraryRoot>? AfterLibraries = null, IReadOnlyList<ArtifactOverride>? AfterOverrides = null, + IReadOnlyList<long>? AddedEvidenceIds = null); + +public sealed record Finding(Guid Id, string Category, Severity Severity, string Message, + Guid? ArtifactId, Guid? BundleId, DateTimeOffset CreatedAt); + +public sealed record Game(Guid Id, string Title, DateTimeOffset CreatedAt, long Version = 1, + GameOrigin Origin = GameOrigin.Scan); +public sealed record WishlistItem(Guid Id, Guid? GameId, string Title, string? Platform, + WishlistPriority Priority, string? Notes, DateTimeOffset CreatedAt, DateTimeOffset UpdatedAt, + long Version = 1, WishlistStatus Status = WishlistStatus.Interested, decimal? DesiredPrice = null, + decimal? CurrentPrice = null, string? Currency = null, string? StoreName = null, string? StoreUrl = null, + string? Edition = null, string? Region = null, DateTimeOffset? ReleaseDate = null, + string? ExternalProvider = null, string? ExternalId = null, string? ArtworkUrl = null); +public sealed record WishlistInput(Guid? GameId, string Title, string? Platform, WishlistPriority Priority, + string? Notes, WishlistStatus Status = WishlistStatus.Interested, decimal? DesiredPrice = null, + decimal? CurrentPrice = null, string? Currency = null, string? StoreName = null, string? StoreUrl = null, + string? Edition = null, string? Region = null, DateTimeOffset? ReleaseDate = null, + string? ExternalProvider = null, string? ExternalId = null, string? ArtworkUrl = null); +public sealed record GameArtwork(Guid Id, Guid GameId, ArtworkRole Role, ArtworkVerificationStatus VerificationStatus, + string Source, string? Provider, string? ExternalId, string MatchMethod, Confidence Confidence, + int? Width, int? Height, decimal? AspectRatio, long Length, string Sha256, decimal QualityScore, + DateTimeOffset RetrievedAt, bool Selected = true, string? Platform = null); +public sealed record ArtworkReviewItem(Guid GameId, string Title, GameArtwork Artwork); +public sealed record BackgroundJob(Guid Id, string Kind, JobState State, string PayloadJson, + long ItemsCompleted, long? ItemsTotal, int Attempts, int MaxAttempts, DateTimeOffset ScheduledAt, + DateTimeOffset CreatedAt, DateTimeOffset UpdatedAt, string? LeaseOwner = null, + DateTimeOffset? LeaseUntil = null, bool CancellationRequested = false, string? Error = null); +public sealed record ProviderSnapshot(Guid Id, string Provider, string Version, string State, string? ETag, + string Sha256, DateTimeOffset RetrievedAt, DateTimeOffset ExpiresAt, DateTimeOffset? LastSuccessfulSyncAt, + string? Error = null); +public sealed record Release(Guid Id, Guid GameId, string Title, string? Platform, string? Region, + string? Revision, DateTimeOffset CreatedAt, long Version = 1, ReleaseOrigin Origin = ReleaseOrigin.Scan); +public sealed record CollectionRule(string? Query = null, string? Platform = null, string[]? Tags = null, + GamePlayStatus? Status = null, bool? Favorite = null, string? MetadataField = null, + string? MetadataValue = null); +public sealed record GameGroup(Guid Id, string Name, string? Description, CollectionKind Kind, + CollectionRule? Rule, bool Pinned, DateTimeOffset CreatedAt, DateTimeOffset UpdatedAt, + long Version = 1, long GameCount = 0); +public sealed record GameTag(Guid Id, Guid GameId, string Name, string NormalizedName, string SourceType, + string SourceId, Confidence Confidence, bool ManualLock, DateTimeOffset CreatedAt); +public sealed record TagSummary(string Name, string NormalizedName, long GameCount); +public sealed record GameUserState(Guid GameId, bool Favorite, GamePlayStatus Status, int? Rating, + int? Difficulty, decimal? CompletionPercent, string? Notes, int PlayCount, + DateTimeOffset? LastPlayedAt, DateTimeOffset UpdatedAt, long Version = 1) +{ + public static GameUserState Empty(Guid gameId) => new(gameId, false, GamePlayStatus.None, null, + null, null, null, 0, null, DateTimeOffset.UtcNow, 0); +} +public sealed record RecentlyPlayedGame(Game Game, GameUserState State); +public sealed record GameRelation(Guid Id, Guid GameId, GameRelationKind Kind, string Title, + Guid? RelatedGameId, Guid? ArtifactId, string SourceType, string SourceId, Confidence Confidence, + bool ManualLock, DateTimeOffset CreatedAt); +public sealed record GameMedia(Guid Id, Guid GameId, GameMediaKind Kind, string Title, string Source, + string? Provider, string? ExternalId, string? Location, string? ContentType, long? Length, + string? Sha256, bool AppOwned, bool Selected, DateTimeOffset CreatedAt); +public sealed record Achievement(Guid Id, string ExternalId, string Title, string? Description, + int Points, bool Unlocked, DateTimeOffset? UnlockedAt, string? BadgeUrl); +public sealed record GameAchievementProgress(Guid GameId, string Provider, string ExternalGameId, + int Earned, int Total, DateTimeOffset UpdatedAt, IReadOnlyList<Achievement> Achievements); +public sealed record PlatformDefinition(string Id, string Name, string Category, string[] Aliases, + bool MetadataOnly, bool Custom, bool Enabled, DateTimeOffset UpdatedAt, long Version = 1); +public sealed record GameDataEntry(Guid Id, Guid GameId, GameDataKind Kind, string Name, + string? Emulator, string? Device, string? Notes, Guid CurrentRevisionId, int RevisionCount, + DateTimeOffset CreatedAt, DateTimeOffset UpdatedAt, long Version = 1); +public sealed record GameDataRevision(Guid Id, Guid EntryId, int Sequence, string FileName, + string ContentType, long Length, string Sha256, string Location, string SourceType, + string SourceId, DateTimeOffset CreatedAt); +public sealed record GameDataItem(GameDataEntry Entry, GameDataRevision CurrentRevision); +public sealed record GameDataSummary(long Saves, long SaveStates, long Revisions, long Bytes); +public sealed record BrowserPlayCandidate(Guid GameId, Guid ArtifactId, string GameTitle, string Platform, + string RelativePath, long Size, string? Sha256, bool SourceReadOnly); +public sealed record BrowserPlayCapability(Guid GameId, bool Available, BrowserPlayState State, + string Message, string? Platform = null, string? Emulator = null, string? Core = null, + bool DesktopRecommended = false, DateTimeOffset? CheckedAt = null, bool AutomaticRestore = false); +public sealed record BrowserPlaySession(Guid Id, Guid GameId, Guid ArtifactId, PlaySessionState State, + string Platform, string Emulator, string Core, string LaunchUrl, string TokenHash, + DateTimeOffset CreatedAt, DateTimeOffset ExpiresAt, DateTimeOffset? EndedAt = null, + string? ErrorCode = null, string? ErrorMessage = null); +public sealed record FirmwareAsset(Guid Id, string Platform, string Slot, FirmwareAssetKind Kind, + string FileName, long Length, string Sha256, string Location, bool Selected, + DateTimeOffset CreatedAt, DateTimeOffset UpdatedAt, long Version = 1); +public sealed record CatalogSource(Guid Id, string Name, string Format, string Version, string? Sha256, + string State, DateTimeOffset ImportedAt, int EntryCount); +public sealed record CatalogEntry(Guid Id, Guid SourceId, string GameName, string RomName, long? Size, + string? Sha256, string? Sha1, string? Crc32, string Identity); +public sealed record MatchScoreComponent(string Kind, decimal Weight, string Evidence); +public sealed record MatchCandidate(Guid Id, Guid ArtifactId, Guid CatalogEntryId, decimal Score, + IReadOnlyList<MatchScoreComponent> Components, string State, DateTimeOffset CreatedAt); + +public sealed record ScanRun(Guid Id, Guid LibraryId, ScanMode Mode, ScanState State, string Stage, + long ItemsCompleted, long? ItemsTotal, long BytesCompleted, long? BytesTotal, + string? Checkpoint, bool CancellationRequested, DateTimeOffset CreatedAt, + DateTimeOffset? StartedAt = null, DateTimeOffset? FinishedAt = null, string? Error = null, + long NewItems = 0, long ChangedItems = 0, long MovedItems = 0, long DeletedItems = 0) +{ + public ScanRun Transition(ScanState next, string stage) + { + var allowed = State switch + { + ScanState.Queued => next is ScanState.Verifying or ScanState.Cancelled, + ScanState.Verifying => next is ScanState.Discovering or ScanState.Failed or ScanState.Cancelling, + ScanState.Discovering => next is ScanState.Processing or ScanState.Paused or ScanState.Cancelling or ScanState.Failed, + ScanState.Processing => next is ScanState.Completed or ScanState.Paused or ScanState.Cancelling or ScanState.Failed, + ScanState.Paused => next is ScanState.Discovering or ScanState.Processing or ScanState.Cancelling, + ScanState.Cancelling => next is ScanState.Cancelled, + _ => false + }; + if (!allowed) throw new InvalidOperationException($"Invalid scan transition {State} -> {next}."); + var now = DateTimeOffset.UtcNow; + return this with + { + State = next, + Stage = stage, + StartedAt = StartedAt ?? now, + FinishedAt = next is ScanState.Completed or ScanState.Failed or ScanState.Cancelled ? now : null + }; + } +} + +public sealed record IntegritySnapshot(Guid Id, string Name, string SchemaVersion, string ApplicationVersion, + DateTimeOffset CreatedAt, IReadOnlyList<SnapshotItem> Items); + +public sealed record SnapshotItem(Guid LibraryId, string RelativePath, long Size, string Sha256, + Guid ArtifactId, Guid? BundleId); + +public enum SnapshotChangeKind { Added, Removed, Moved, ContentChanged, Unchanged } +public sealed record SnapshotChange(SnapshotChangeKind Kind, SnapshotItem? Before, SnapshotItem? After, string Explanation); + +public sealed record AuditEvent(Guid Id, string Actor, string Action, string TargetType, Guid TargetId, + string? BeforeJson, string? AfterJson, Guid CorrelationId, DateTimeOffset CreatedAt); + +public sealed record ManualResolution(Guid Id, Guid ReviewItemId, string Action, string Actor, + string BeforeJson, string AfterJson, DateTimeOffset CreatedAt, DateTimeOffset? UndoneAt = null); diff --git a/src/Ludarium.Domain/packages.lock.json b/src/Ludarium.Domain/packages.lock.json new file mode 100644 index 0000000..4a91a8c --- /dev/null +++ b/src/Ludarium.Domain/packages.lock.json @@ -0,0 +1,6 @@ +{ + "version": 1, + "dependencies": { + "net10.0": {} + } +} \ No newline at end of file diff --git a/src/Ludarium.HealthCheck/Ludarium.HealthCheck.csproj b/src/Ludarium.HealthCheck/Ludarium.HealthCheck.csproj new file mode 100644 index 0000000..36a2962 --- /dev/null +++ b/src/Ludarium.HealthCheck/Ludarium.HealthCheck.csproj @@ -0,0 +1,6 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <PropertyGroup> + <OutputType>Exe</OutputType> + <TargetFramework>net10.0</TargetFramework> + </PropertyGroup> +</Project> diff --git a/src/Ludarium.HealthCheck/Program.cs b/src/Ludarium.HealthCheck/Program.cs new file mode 100644 index 0000000..b1e79c9 --- /dev/null +++ b/src/Ludarium.HealthCheck/Program.cs @@ -0,0 +1,15 @@ +using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(4) }; + +try +{ + using var response = await client.GetAsync("http://127.0.0.1:8734/health/ready"); + return response.IsSuccessStatusCode ? 0 : 1; +} +catch (HttpRequestException) +{ + return 1; +} +catch (TaskCanceledException) +{ + return 1; +} diff --git a/src/Ludarium.HealthCheck/packages.lock.json b/src/Ludarium.HealthCheck/packages.lock.json new file mode 100644 index 0000000..4a91a8c --- /dev/null +++ b/src/Ludarium.HealthCheck/packages.lock.json @@ -0,0 +1,6 @@ +{ + "version": 1, + "dependencies": { + "net10.0": {} + } +} \ No newline at end of file diff --git a/src/Ludarium.Infrastructure/AssemblyInfo.cs b/src/Ludarium.Infrastructure/AssemblyInfo.cs new file mode 100644 index 0000000..bd7f3be --- /dev/null +++ b/src/Ludarium.Infrastructure/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Ludarium.IntegrationTests")] diff --git a/src/Ludarium.Infrastructure/DependencyInjection.cs b/src/Ludarium.Infrastructure/DependencyInjection.cs new file mode 100644 index 0000000..29512ac --- /dev/null +++ b/src/Ludarium.Infrastructure/DependencyInjection.cs @@ -0,0 +1,70 @@ +using Ludarium.Application; +using Microsoft.Extensions.DependencyInjection; +using Npgsql; + +namespace Ludarium.Infrastructure; + +public static class DependencyInjection +{ + public static IServiceCollection AddLudariumInfrastructure(this IServiceCollection services, string connectionString, string? exportRoot = null) + { + var database = new NpgsqlConnectionStringBuilder(connectionString) + { + MaxPoolSize = 50, + Timeout = 15, + CommandTimeout = 120 + }; + services.AddSingleton(NpgsqlDataSource.Create(database.ConnectionString)); + services.AddSingleton<PostgresStore>(); + services.AddSingleton<ILudariumStore>(provider => provider.GetRequiredService<PostgresStore>()); + services.AddSingleton<ICatalogStore>(provider => provider.GetRequiredService<PostgresStore>()); + services.AddSingleton<ILibraryExperienceStore>(provider => provider.GetRequiredService<PostgresStore>()); + services.AddSingleton<IGameDataVaultStore>(provider => provider.GetRequiredService<PostgresStore>()); + services.AddSingleton<IBrowserPlayStore>(provider => provider.GetRequiredService<PostgresStore>()); + services.AddSingleton<IFirmwareStore>(provider => provider.GetRequiredService<PostgresStore>()); + services.AddSingleton<IReadOnlyLibraryFileSystem, ReadOnlyLibraryFileSystem>(); + services.AddSingleton<IScanCoordinator, ScanCoordinator>(); + services.AddSingleton<ICatalogImporter, LogiqxCatalogImporter>(); + services.AddSingleton<ISnapshotComparer, SnapshotComparer>(); + services.AddSingleton<ISupportBundleService>(provider => new SupportBundleService(provider.GetRequiredService<ILudariumStore>(), exportRoot ?? Path.Combine(AppContext.BaseDirectory, "exports"))); + services.AddSingleton<IInventoryExportService>(provider => new InventoryExportService(provider.GetRequiredService<ILudariumStore>(), exportRoot ?? Path.Combine(AppContext.BaseDirectory, "exports"))); + services.AddSingleton<IGameCatalogExportService>(provider => new GameCatalogExportService( + provider.GetRequiredService<ILudariumStore>(), provider.GetRequiredService<ILibraryExperienceStore>(), + exportRoot ?? Path.Combine(AppContext.BaseDirectory, "exports"))); + services.AddSingleton(new GameArtworkStore(Path.Combine(AppContext.BaseDirectory, "cache", "artwork"))); + services.AddSingleton(new GameMediaStore(Path.Combine(AppContext.BaseDirectory, "cache", "media"))); + services.AddSingleton(new GameDataVaultFileStore(Path.Combine(AppContext.BaseDirectory, "cache", "game-data"))); + services.AddSingleton(new FirmwareVaultFileStore(Environment.GetEnvironmentVariable("LUDARIUM_FIRMWARE_ROOT") + ?? Path.Combine(AppContext.BaseDirectory, "data", "firmware"))); + services.AddSingleton(new GameCheatCatalog(Path.Combine(AppContext.BaseDirectory, "cache", "cheats"))); + services.AddSingleton(new SwitchRuntimeProvisioner(Environment.GetEnvironmentVariable("LUDARIUM_SWITCH_KEYS_ROOT"))); + services.AddSingleton<GameArtworkEnricher>(); + services.AddSingleton<IGameMetadataProvider>(new IgdbMetadataProvider(CreateProviderClient(), + Environment.GetEnvironmentVariable("IGDB_CLIENT_ID"), Environment.GetEnvironmentVariable("IGDB_CLIENT_SECRET"))); + services.AddSingleton<IGameMetadataProvider>(new MobyGamesMetadataProvider(CreateProviderClient(), + Environment.GetEnvironmentVariable("MOBYGAMES_API_KEY"))); + services.AddSingleton<IGameMetadataProvider>(new ScreenScraperMetadataProvider(CreateProviderClient(), + Environment.GetEnvironmentVariable("SCREENSCRAPER_DEVID"), Environment.GetEnvironmentVariable("SCREENSCRAPER_DEVPASSWORD"), + Environment.GetEnvironmentVariable("SCREENSCRAPER_SOFTNAME"), Environment.GetEnvironmentVariable("SCREENSCRAPER_USER"), + Environment.GetEnvironmentVariable("SCREENSCRAPER_PASSWORD"))); + services.AddSingleton<IGameMetadataProvider>(new RetroAchievementsMetadataProvider(CreateProviderClient(), + Environment.GetEnvironmentVariable("RETROACHIEVEMENTS_API_KEY"), Environment.GetEnvironmentVariable("RETROACHIEVEMENTS_USERNAME"))); + services.AddSingleton<IGameProviderHub, GameProviderHub>(); + services.AddSingleton<IBrowserPlayService>(provider => new NativeBrowserPlayService( + provider.GetRequiredService<IBrowserPlayStore>(), provider.GetRequiredService<ILudariumStore>(), + provider.GetRequiredService<IReadOnlyLibraryFileSystem>(), + File.Exists(Path.Combine(AppContext.BaseDirectory, "wwwroot", "emulatorjs", "data", "loader.js")), + int.TryParse(Environment.GetEnvironmentVariable("BROWSERPLAY_MAX_SESSIONS"), out var maxSessions) ? maxSessions : 2, + provider.GetRequiredService<IFirmwareStore>(), provider.GetRequiredService<FirmwareVaultFileStore>())); + services.AddSingleton<GameMetadataEnricher>(); + services.AddSingleton<IGameDiscoveryService>(new RawgGameDiscoveryService(new HttpClient { Timeout = TimeSpan.FromSeconds(12) }, Environment.GetEnvironmentVariable("RAWG_API_KEY"))); + return services; + } + + private static HttpClient CreateProviderClient() + { + var client = new HttpClient(new HttpClientHandler { AllowAutoRedirect = false }) { Timeout = TimeSpan.FromSeconds(20) }; + client.DefaultRequestHeaders.UserAgent.ParseAdd(ReleaseIdentity.UserAgent); + return client; + } +} diff --git a/src/Ludarium.Infrastructure/DiscSetArchive.cs b/src/Ludarium.Infrastructure/DiscSetArchive.cs new file mode 100644 index 0000000..e26fab3 --- /dev/null +++ b/src/Ludarium.Infrastructure/DiscSetArchive.cs @@ -0,0 +1,250 @@ +using System.IO.Compression; +using System.IO.Pipelines; +using System.Text; +using Ludarium.Application; +using Ludarium.Domain; + +namespace Ludarium.Infrastructure; + +/// <summary>One descriptor and the exact read-only files it names, in the order they are packed.</summary> +public sealed record DiscSet(string EntryPath, IReadOnlyList<string> MemberPaths, long TotalBytes) +{ + public int DiscCount => MemberPaths.Count(BrowserPlayPolicy.IsCueSheet); +} + +/// <summary> +/// Serves a descriptor-based disc dump to the browser player as a single streamed ZIP. +/// </summary> +/// <remarks> +/// CUE/BIN is the dominant PlayStation dump layout and M3U is how a multi-disc game names its discs. +/// Both were shapes browser play could not open: a session streams exactly one file, and a descriptor +/// on its own is a text file. Resolving the descriptor and building the archive as it is read keeps +/// every existing invariant — the source library is only ever read, nothing is written beside it, and +/// a set that cannot be fully resolved fails closed instead of playing half a game. +/// </remarks> +public static class DiscSetArchive +{ + /// <summary>A descriptor is a small text file; anything larger is not one.</summary> + public const long MaximumDescriptorBytes = 256 * 1024; + + /// <summary>Redump multi-track discs stay well below this; a larger set is not a single disc.</summary> + public const int MaximumTrackFiles = 32; + + /// <summary>No retail game ships more discs than this, and each one costs a resolve.</summary> + public const int MaximumDiscs = 12; + + private static readonly HashSet<string> TrackExtensions = new(StringComparer.OrdinalIgnoreCase) + { + ".bin", ".img", ".iso", ".raw", ".wav", ".mp3", ".ogg", ".flac", ".ape" + }; + + /// <summary>Per-disc entries a playlist may name. A playlist of playlists is refused.</summary> + private static readonly HashSet<string> DiscExtensions = new(StringComparer.OrdinalIgnoreCase) + { + ".cue", ".chd", ".iso", ".img", ".pbp", ".bin" + }; + + public static bool IsCueSheet(string? relativePath) => BrowserPlayPolicy.IsCueSheet(relativePath); + + public static bool IsPlaylist(string? relativePath) => relativePath is not null && + Path.GetExtension(relativePath).Equals(".m3u", StringComparison.OrdinalIgnoreCase); + + public static bool IsDescriptor(string? relativePath) => IsCueSheet(relativePath) || IsPlaylist(relativePath); + + /// <summary> + /// Read the track file names out of a CUE sheet. + /// </summary> + /// <remarks> + /// A CUE sheet is operator content from a read-only library, so it is parsed defensively: only + /// FILE lines are honoured, and a name that is anything other than a plain file beside the sheet + /// is rejected rather than resolved. + /// </remarks> + public static IReadOnlyList<string> ParseTrackFiles(string cueText) + { + var files = new List<string>(); + foreach (var raw in cueText.Split('\n')) + { + var line = raw.Trim().TrimEnd('\r'); + if (!line.StartsWith("FILE", StringComparison.OrdinalIgnoreCase) || + (line.Length > 4 && !char.IsWhiteSpace(line[4]))) continue; + var name = ExtractFileName(line[4..]); + if (name is null) throw new InvalidDataException("A FILE entry in the CUE sheet has no readable name."); + if (!IsSafeMemberName(name, TrackExtensions)) + throw new InvalidDataException($"The CUE sheet references an unsupported track file '{name}'."); + if (!files.Contains(name, StringComparer.OrdinalIgnoreCase)) files.Add(name); + if (files.Count > MaximumTrackFiles) + throw new InvalidDataException("The CUE sheet references more track files than one disc can hold."); + } + if (files.Count == 0) throw new InvalidDataException("The CUE sheet references no track files."); + return files; + } + + /// <summary> + /// Read the disc entries out of an M3U playlist. Comments and blank lines are skipped; every + /// remaining line must name one plain disc file beside the playlist. + /// </summary> + public static IReadOnlyList<string> ParsePlaylist(string playlistText) + { + var discs = new List<string>(); + foreach (var raw in playlistText.Split('\n')) + { + var line = raw.Trim().TrimEnd('\r'); + if (line.Length == 0 || line.StartsWith('#')) continue; + if (!IsSafeMemberName(line, DiscExtensions)) + throw new InvalidDataException($"The playlist references an unsupported disc entry '{line}'."); + if (!discs.Contains(line, StringComparer.OrdinalIgnoreCase)) discs.Add(line); + if (discs.Count > MaximumDiscs) + throw new InvalidDataException("The playlist references more discs than one game may hold."); + } + if (discs.Count == 0) throw new InvalidDataException("The playlist references no discs."); + return discs; + } + + /// <summary> + /// Resolve a descriptor to its complete set, or return null when the set is incomplete, oversized + /// or unreadable. A partially resolvable game must never start. + /// </summary> + public static async Task<DiscSet?> ResolveAsync(IReadOnlyLibraryFileSystem files, LibraryRoot library, + string descriptorPath, long descriptorSize, long maximumBytes, CancellationToken ct) + { + if (!IsDescriptor(descriptorPath) || descriptorSize is <= 0 or > MaximumDescriptorBytes) return null; + try + { + var directory = DirectoryOf(descriptorPath); + var members = new List<string> { descriptorPath }; + var total = descriptorSize; + + if (IsCueSheet(descriptorPath)) + { + if (await AddCueTracksAsync(files, library, descriptorPath, members, total, maximumBytes, ct) + is not { } cueTotal) return null; + return new(descriptorPath, members, cueTotal); + } + + foreach (var disc in ParsePlaylist(await ReadTextAsync(files, library, descriptorPath, ct))) + { + var discPath = Combine(directory, disc); + var size = SizeOf(files, library, discPath); + if (size <= 0) return null; + members.Add(discPath); + total += size; + if (total > maximumBytes) return null; + // A playlist entry that is itself a CUE brings its own tracks along. + if (!IsCueSheet(discPath) || size > MaximumDescriptorBytes) continue; + if (await AddCueTracksAsync(files, library, discPath, members, total, maximumBytes, ct) + is not { } discTotal) return null; + total = discTotal; + } + return new(descriptorPath, members, total); + } + catch (Exception exception) when (exception is InvalidDataException or IOException or + UnauthorizedAccessException or InvalidOperationException or DecoderFallbackException) + { + return null; + } + } + + /// <summary> + /// Stream the resolved set as a ZIP the browser core can mount. Entries are stored uncompressed: + /// disc tracks do not compress usefully and the player only pays for the copy. + /// </summary> + public static BrowserPlayContent Open(DiscSet set, IReadOnlyLibraryFileSystem files, LibraryRoot library, + CancellationToken ct) + { + var pipe = new Pipe(); + _ = WriteAsync(pipe.Writer, set, files, library, ct); + var name = Path.ChangeExtension(BrowserPlayPolicy.SafeFileName(set.EntryPath), ".zip"); + // The archive is produced as it is read, so its exact encoded length is not known up front. + return new(pipe.Reader.AsStream(), name, 0, null); + } + + /// <summary>Add a CUE sheet's tracks to the set, or return null when the disc is not complete.</summary> + private static async Task<long?> AddCueTracksAsync(IReadOnlyLibraryFileSystem files, LibraryRoot library, + string cuePath, List<string> members, long total, long maximumBytes, CancellationToken ct) + { + var directory = DirectoryOf(cuePath); + foreach (var name in ParseTrackFiles(await ReadTextAsync(files, library, cuePath, ct))) + { + var trackPath = Combine(directory, name); + if (members.Contains(trackPath, StringComparer.OrdinalIgnoreCase)) continue; + var size = SizeOf(files, library, trackPath); + if (size <= 0) return null; + members.Add(trackPath); + total += size; + if (total > maximumBytes) return null; + } + return total; + } + + private static async Task WriteAsync(PipeWriter writer, DiscSet set, IReadOnlyLibraryFileSystem files, + LibraryRoot library, CancellationToken ct) + { + Exception? failure = null; + try + { + await using var output = writer.AsStream(leaveOpen: true); + using var archive = new ZipArchive(output, ZipArchiveMode.Create, leaveOpen: true); + foreach (var member in set.MemberPaths) + { + var entry = archive.CreateEntry(BrowserPlayPolicy.SafeFileName(member), CompressionLevel.NoCompression); + await using var destination = entry.Open(); + await using var source = await files.OpenReadAsync(library, member, ct); + await source.CopyToAsync(destination, 1024 * 1024, ct); + } + } + catch (Exception exception) + { + failure = exception; + } + // Completing with the failure surfaces a truncated game to the reader as an error rather than + // as a silently short archive. + await writer.CompleteAsync(failure); + } + + private static async Task<string> ReadTextAsync(IReadOnlyLibraryFileSystem files, LibraryRoot library, + string relativePath, CancellationToken ct) + { + await using var content = await files.OpenReadAsync(library, relativePath, ct); + using var reader = new StreamReader(content, Encoding.UTF8, detectEncodingFromByteOrderMarks: true); + return await reader.ReadToEndAsync(ct); + } + + private static long SizeOf(IReadOnlyLibraryFileSystem files, LibraryRoot library, string relativePath) + { + var info = new FileInfo(files.ResolveContainedPath(library, relativePath)); + return info.Exists ? info.Length : 0; + } + + private static string Combine(string directory, string name) => + directory.Length == 0 ? name : $"{directory}/{name}"; + + private static string DirectoryOf(string relativePath) + { + var normalized = relativePath.Replace('\\', '/'); + var index = normalized.LastIndexOf('/'); + return index < 0 ? string.Empty : normalized[..index]; + } + + private static string? ExtractFileName(string remainder) + { + var trimmed = remainder.Trim(); + if (trimmed.Length == 0) return null; + if (trimmed[0] == '"') + { + var closing = trimmed.IndexOf('"', 1); + return closing > 1 ? trimmed[1..closing] : null; + } + // An unquoted name ends at the track mode keyword that always follows it. + var end = trimmed.LastIndexOf(' '); + var name = end > 0 ? trimmed[..end].Trim() : trimmed; + return name.Length == 0 ? null : name; + } + + /// <summary>A member must be one plain file beside its descriptor, with an expected extension.</summary> + private static bool IsSafeMemberName(string name, HashSet<string> extensions) => + name.Length is > 0 and <= 200 && + name.IndexOfAny(['/', '\\', ':', '\0']) < 0 && + !name.Any(char.IsControl) && + name is not "." and not ".." && + extensions.Contains(Path.GetExtension(name)); +} diff --git a/src/Ludarium.Infrastructure/FirmwareVaultFileStore.cs b/src/Ludarium.Infrastructure/FirmwareVaultFileStore.cs new file mode 100644 index 0000000..a649a66 --- /dev/null +++ b/src/Ludarium.Infrastructure/FirmwareVaultFileStore.cs @@ -0,0 +1,133 @@ +using System.Security.Cryptography; +using Ludarium.Application; +using Ludarium.Domain; + +namespace Ludarium.Infrastructure; + +public sealed record FirmwareReconciliationResult(int RemovedUploads, int RemovedOrphans, long ReclaimedBytes); + +public sealed class FirmwareVaultFileStore +{ + private readonly string root; + + public FirmwareVaultFileStore(string root) + { + this.root = Path.GetFullPath(root); + Directory.CreateDirectory(this.root); + } + + public async Task<FirmwareAsset> SaveAsync(FirmwareRequirement requirement, Guid id, string fileName, + Stream input, long? declaredLength, CancellationToken ct) + { + fileName = FirmwarePolicy.ValidateFileName(requirement, fileName); + if (declaredLength is > 0 && declaredLength > requirement.MaximumBytes) + throw new FirmwareValidationException($"{requirement.Label} exceeds its upload limit."); + var directory = ContainedPath($"{requirement.Platform}/{requirement.Slot}"); + Directory.CreateDirectory(directory); + var location = $"{requirement.Platform}/{requirement.Slot}/{id:N}.bin"; + var destination = ContainedPath(location); + var temporary = ContainedPath($"{requirement.Platform}/{requirement.Slot}/{id:N}.{Guid.NewGuid():N}.uploading"); + try + { + using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); + var header = new byte[16]; + var headerLength = 0; + long length = 0; + await using (var output = new FileStream(temporary, FileMode.CreateNew, FileAccess.Write, + FileShare.None, 64 * 1024, FileOptions.Asynchronous | FileOptions.SequentialScan)) + { + var buffer = new byte[64 * 1024]; + while (true) + { + var read = await input.ReadAsync(buffer, ct); + if (read == 0) break; + length += read; + if (length > requirement.MaximumBytes) + throw new FirmwareValidationException($"{requirement.Label} exceeds its upload limit."); + var copy = Math.Min(header.Length - headerLength, read); + if (copy > 0) { buffer.AsSpan(0, copy).CopyTo(header.AsSpan(headerLength)); headerLength += copy; } + hash.AppendData(buffer, 0, read); + await output.WriteAsync(buffer.AsMemory(0, read), ct); + } + await output.FlushAsync(ct); + } + if (length == 0) throw new FirmwareValidationException("Firmware and key files cannot be empty."); + RejectHostExecutableOrArchive(header.AsSpan(0, headerLength)); + var sha256 = Convert.ToHexString(hash.GetHashAndReset()).ToLowerInvariant(); + File.Move(temporary, destination, false); + var now = DateTimeOffset.UtcNow; + return new(id, requirement.Platform, requirement.Slot, requirement.Kind, fileName, length, + sha256, location, true, now, now); + } + finally + { + if (File.Exists(temporary)) File.Delete(temporary); + } + } + + public async Task<FirmwareContent?> OpenAsync(FirmwareAsset asset, CancellationToken ct) + { + var path = ContainedPath(asset.Location); + if (!File.Exists(path)) return null; + var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 64 * 1024, + FileOptions.Asynchronous | FileOptions.SequentialScan); + try + { + if (stream.Length != asset.Length) + throw new FirmwareIntegrityException("The app-owned firmware length no longer matches its evidence."); + var actual = Convert.ToHexString(await SHA256.HashDataAsync(stream, ct)).ToLowerInvariant(); + if (!actual.Equals(asset.Sha256, StringComparison.Ordinal)) + throw new FirmwareIntegrityException("The app-owned firmware checksum no longer matches its evidence."); + stream.Position = 0; + return new(stream, stream.Length, asset.Sha256); + } + catch { await stream.DisposeAsync(); throw; } + } + + public bool Delete(FirmwareAsset asset) + { + var path = ContainedPath(asset.Location); + if (!File.Exists(path)) return false; + File.Delete(path); + return true; + } + + public FirmwareReconciliationResult Reconcile(IReadOnlySet<string> knownLocations) + { + var comparer = OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal; + var known = new HashSet<string>(knownLocations.Select(NormalizeRelative), comparer); + var uploads = 0; + var orphans = 0; + long bytes = 0; + var options = new EnumerationOptions { RecurseSubdirectories = true, AttributesToSkip = FileAttributes.ReparsePoint }; + foreach (var path in Directory.EnumerateFiles(root, "*", options).ToArray()) + { + var relative = NormalizeRelative(Path.GetRelativePath(root, path)); + var upload = relative.EndsWith(".uploading", StringComparison.OrdinalIgnoreCase); + var orphan = relative.EndsWith(".bin", StringComparison.OrdinalIgnoreCase) && !known.Contains(relative); + if (!upload && !orphan) continue; + bytes += new FileInfo(path).Length; + File.Delete(path); + if (upload) uploads++; else orphans++; + } + return new(uploads, orphans, bytes); + } + + private string ContainedPath(string relative) + { + if (Path.IsPathRooted(relative)) throw new FirmwareValidationException("Firmware locations must be app-relative."); + var path = Path.GetFullPath(Path.Combine(root, relative.Replace('/', Path.DirectorySeparatorChar))); + if (!path.StartsWith(root + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) + throw new FirmwareValidationException("The firmware location escapes the app-owned vault."); + return path; + } + + private static string NormalizeRelative(string value) => value.Replace('\\', '/').TrimStart('/'); + + private static void RejectHostExecutableOrArchive(ReadOnlySpan<byte> header) + { + if (header.StartsWith("MZ"u8) || header.StartsWith("#!"u8) || header.StartsWith("PK\x03\x04"u8) || + (header.Length >= 4 && header[..4].SequenceEqual(new byte[] { 0x7f, 0x45, 0x4c, 0x46 }))) + throw new FirmwareValidationException("Host executables, scripts and archives are not accepted as firmware."); + } +} diff --git a/src/Ludarium.Infrastructure/GameArtworkEnricher.cs b/src/Ludarium.Infrastructure/GameArtworkEnricher.cs new file mode 100644 index 0000000..e1ed038 --- /dev/null +++ b/src/Ludarium.Infrastructure/GameArtworkEnricher.cs @@ -0,0 +1,511 @@ +using System.Collections.Concurrent; +using System.IO.Compression; +using System.Net; +using System.Text.Json; +using System.Text.RegularExpressions; +using System.Xml; +using Ludarium.Application; +using Ludarium.Domain; + +namespace Ludarium.Infrastructure; + +public sealed record ArtworkEnrichmentResult(int Games, int AlreadyAvailable, int Downloaded, int Unmatched, int Failed); +internal sealed record RetailArtworkSource(string Url, string Provider, string MatchMethod, string ExternalId); + +public sealed partial class GameArtworkEnricher(GameArtworkStore artworkStore, ILudariumStore store) +{ + private static readonly HttpClient Client = new(new SocketsHttpHandler + { + PooledConnectionLifetime = TimeSpan.FromMinutes(10), + AutomaticDecompression = DecompressionMethods.All + }) + { + Timeout = TimeSpan.FromSeconds(20) + }; + private static readonly SemaphoreSlim NetworkSlots = new(4, 4); + private static readonly SemaphoreSlim EnrichmentGate = new(1, 1); + private readonly ConcurrentDictionary<string, Task<IReadOnlyDictionary<string, string>>> indexes = new(StringComparer.OrdinalIgnoreCase); + private Task<IReadOnlyDictionary<string, string>>? launchBoxIndex; + + private static readonly Dictionary<string, string> LibretroPlatforms = new(StringComparer.OrdinalIgnoreCase) + { + ["nes"] = "Nintendo - Nintendo Entertainment System", + ["snes"] = "Nintendo - Super Nintendo Entertainment System", + ["n64"] = "Nintendo - Nintendo 64", + ["gb"] = "Nintendo - Game Boy", + ["gbc"] = "Nintendo - Game Boy Color", + ["gba"] = "Nintendo - Game Boy Advance", + ["nds"] = "Nintendo - Nintendo DS", + ["3ds"] = "Nintendo - Nintendo 3DS", + ["gamecube"] = "Nintendo - GameCube", + ["wii"] = "Nintendo - Wii", + ["wiiu"] = "Nintendo - Wii U", + ["wii-u"] = "Nintendo - Wii U", + ["psx"] = "Sony - PlayStation", + ["ps2"] = "Sony - PlayStation 2", + ["psp"] = "Sony - PlayStation Portable", + ["psvita"] = "Sony - PlayStation Vita", + ["ps4"] = "Sony - PlayStation 4", + ["xbox"] = "Microsoft - Xbox", + ["xbox360"] = "Microsoft - Xbox 360", + ["xbox-360"] = "Microsoft - Xbox 360" + }; + private static readonly Dictionary<string, string> LaunchBoxPlatforms = new(StringComparer.OrdinalIgnoreCase) + { + ["windows"] = "Windows", + ["ps5"] = "Sony Playstation 5", + ["ps4"] = "Sony Playstation 4", + ["psvita"] = "Sony Playstation Vita", + ["psp"] = "Sony PSP", + ["ps2"] = "Sony Playstation 2", + ["psx"] = "Sony Playstation", + ["switch"] = "Nintendo Switch", + ["wiiu"] = "Nintendo Wii U", + ["wii-u"] = "Nintendo Wii U", + ["wii"] = "Nintendo Wii", + ["gamecube"] = "Nintendo GameCube", + ["3ds"] = "Nintendo 3DS", + ["nds"] = "Nintendo DS", + ["n64"] = "Nintendo 64", + ["gba"] = "Nintendo Game Boy Advance", + ["gbc"] = "Nintendo Game Boy Color", + ["gb"] = "Nintendo Game Boy", + ["snes"] = "Super Nintendo Entertainment System", + ["nes"] = "Nintendo Entertainment System", + ["xbox"] = "Microsoft Xbox", + ["xbox360"] = "Microsoft Xbox 360", + ["xbox-360"] = "Microsoft Xbox 360" + }; + private static readonly Dictionary<string, string> ArtworkLookupAliases = new(StringComparer.Ordinal) + { + ["callofdutymw2cr"] = "Call of Duty Modern Warfare 2 Campaign Remastered", + ["callofdutyblackops6ps5internalps5b"] = "Call of Duty Black Ops 6", + ["dishonoredcollection"] = "Dishonored", + ["disneysabugslife"] = "A Bug's Life", + ["destroyallhumans2"] = "Destroy All Humans 2 Reprobed", + ["fable3"] = "Fable III", + ["legolotr"] = "LEGO The Lord of the Rings", + ["legopirates"] = "LEGO Pirates of the Caribbean The Video Game", + ["lifeisstrangereunionps5unlimited"] = "Life is Strange Reunion", + ["marioluigibowsersinsidestorybowsersjr"] = "Mario & Luigi Bowser's Inside Story", + ["pokemonheartgold"] = "Pokemon HeartGold Version", + ["pokemonred"] = "Pokemon Red Version", + ["pokemonsoulsilver"] = "Pokemon SoulSilver Version", + ["pokemonyellowversion"] = "Pokemon Yellow Version Special Pikachu Edition", + ["spidermanmilesmorales"] = "Marvel's Spider-Man Miles Morales", + ["spidermanremastered"] = "Marvel's Spider-Man Remastered", + ["thejakanddaxtertrilogy"] = "The Jak and Daxter Collection", + ["thewalkingdeadseries"] = "The Walking Dead The Telltale Definitive Series", + ["tetrispsn"] = "Tetris", + ["tropico6tropicanshores"] = "Tropico 6", + ["watchdogs2"] = "Watch Dogs 2" + }; + private static readonly Dictionary<string, (string Image, string Product, string Provider, string MatchMethod)> CuratedArtwork = new(StringComparer.Ordinal) + { + ["ps5\0asterixandobelixslapthemall"] = ("https://www.wog.ch/nas/cover_xl/p5/p5_asterixobelixslapthemalle.jpg", "https://www.wog.ch/en/index.cfm/details/product/163417-Asterix-Obelix-Slap-them-All", "WorldOfGames", "curated-retailer-platform-box-front"), + ["ps5\0callofdutyblackops6"] = ("https://www.wog.ch/nas/cover_xl/p5/p5_callofdutyblackops6e.jpg", "https://www.wog.ch/en/index.cfm/details/product/205943-Call-of-Duty-Black-Ops-6-EN", "WorldOfGames", "curated-retailer-platform-box-front"), + ["ps5\0destroyallhumans2reprobed"] = ("https://www.wog.ch/nas/cover_xl/p5/p5_destroyallhumans2reprobede.jpg", "https://www.wog.ch/en/index.cfm/details/product/213841-Destroy-All-Humans-2-Reprobed", "WorldOfGames", "curated-retailer-platform-box-front"), + ["ps5\0evilgenius2worlddomination"] = ("https://www.wog.ch/nas/cover_xl/p5/p5_evilgenius2worlddominatione.jpg", "https://www.wog.ch/en/index.cfm/details/product/179758-Evil-Genius-2-World-Domination", "WorldOfGames", "curated-retailer-platform-box-front"), + ["ps5\0lifeisstrangereunion"] = ("https://www.wog.ch/nas/cover_xl/p5/p5_lifeisstrangereunion.jpg", "https://www.wog.ch/en/index.cfm/details/product/240467-Life-is-Strange-Reunion", "WorldOfGames", "curated-retailer-platform-box-front"), + ["ps5\0pragmata"] = ("https://www.wog.ch/nas/cover_xl/p5/p5_pragmatad.jpg", "https://www.wog.ch/en/index.cfm/details/product/248802-PRAGMATA", "WorldOfGames", "curated-retailer-platform-box-front"), + ["ps5\0rollercoastertycoonadventures"] = ("https://www.wog.ch/nas/cover_xl/p5/p5_rollercoastertycoonadventuresdeluxee.jpg", "https://www.wog.ch/en/index.cfm/details/product/184680-RollerCoaster-Tycoon-Adventures-Deluxe", "WorldOfGames", "curated-retailer-platform-box-front"), + ["ps5\0twopointmuseum"] = ("https://www.wog.ch/nas/cover_xl/p5/p5_twopointmuseumexploreredition.jpg", "https://www.wog.ch/en/index.cfm/details/product/210671-Two-Point-Museum-Explorer-Edition", "WorldOfGames", "curated-retailer-platform-box-front"), + ["psx\0abugslife"] = ("https://psxdatacenter.com/images/hires/U/D/SCUS-94288/SCUS-94288-F-ALL.jpg", "https://psxdatacenter.com/games/U/D/SCUS-94288.html", "PSXDataCenter", "curated-serial-platform-box-front"), + ["windows\0moviestycoon"] = ("https://shared.cloudflare.steamstatic.com/store_item_assets/steam/apps/2659050/library_600x900_2x.jpg", "https://store.steampowered.com/app/2659050/Movies_Tycoon/", "SteamDigital", "verified-digital-only-storefront-front") + }; + + public async Task<ArtworkEnrichmentResult> EnrichAsync( + IReadOnlyList<Game> games, + IReadOnlyList<Release> releases, + CancellationToken cancellationToken, + Func<int, int, Task>? progress = null) + { + await EnrichmentGate.WaitAsync(cancellationToken); + try + { + var byGame = releases.GroupBy(release => release.GameId).ToDictionary(group => group.Key, group => group.ToArray()); + var already = 0; + var downloaded = 0; + var unmatched = 0; + var failed = 0; + foreach (var game in games) + { + cancellationToken.ThrowIfCancellationRequested(); + var platforms = byGame.GetValueOrDefault(game.Id)? + .Select(item => item.Platform) + .Where(item => !string.IsNullOrWhiteSpace(item)) + .Select(item => item!) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray() ?? []; + var anySelected = false; + var anyDownloaded = false; + var anyFailure = false; + foreach (var platform in platforms) + { + GameArtwork? selected = await store.GetSelectedGameArtworkAsync(game.Id, cancellationToken, platform); + if (selected is not null) + { + var evaluated = ArtworkQuality.EvaluateForPlatform(selected, platform); + if (evaluated != selected) await store.UpsertGameArtworkAsync(evaluated, cancellationToken); + selected = evaluated; + if (await artworkStore.OpenAsync(game.Id, cancellationToken, selected.Id, fallbackToCurrent: false) is { } existing) + await existing.Content.DisposeAsync(); + else selected = null; + if (selected?.Source.Equals("Uploaded", StringComparison.OrdinalIgnoreCase) == true) + { + anySelected = true; + continue; + } + if (selected?.QualityScore >= ArtworkQuality.VerifiedThreshold) + { + anySelected = true; + continue; + } + if (selected is not null && selected.QualityScore == 0) + { + await store.UpsertGameArtworkAsync(selected with { Selected = false }, cancellationToken); + selected = null; + } + } + + try + { + var excludedProviders = new HashSet<string>(StringComparer.OrdinalIgnoreCase); + var candidates = new List<GameArtwork>(); + var candidateFailure = false; + while (excludedProviders.Count < 4) + { + var source = await FindAsync(game.Title, platform, cancellationToken, excludedProviders); + if (source is null) break; + excludedProviders.Add(source.Provider); + try + { + await using var content = await DownloadAsync(source.Url, cancellationToken); + await artworkStore.SaveAsync(game.Id, content, content.Length, cancellationToken, source.Provider); + if (await artworkStore.DescribeAsync(game.Id, source.Provider, + source.MatchMethod, Confidence.High, cancellationToken, platform, source.ExternalId) is { } artwork) + { + var candidate = ArtworkQuality.EvaluateForPlatform(artwork with { Selected = false }, platform); + await store.UpsertGameArtworkAsync(candidate, cancellationToken); + candidates.Add(candidate); + if (candidate.QualityScore >= ArtworkQuality.VerifiedThreshold) break; + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; } + catch { candidateFailure = true; } + } + var best = ArtworkQuality.SelectBest(candidates.Append(selected)); + if (best is not null) + { + await store.SelectGameArtworkAsync(game.Id, best.Id, cancellationToken); + anySelected = true; + anyDownloaded |= candidates.Count > 0; + } + else if (candidateFailure) anyFailure = true; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; } + catch { anyFailure = true; } + } + if (anyDownloaded) downloaded++; + else if (anySelected) already++; + else if (anyFailure) failed++; + else unmatched++; + if (progress is not null) await progress(already + downloaded + unmatched + failed, games.Count); + } + return new(games.Count, already, downloaded, unmatched, failed); + } + finally { EnrichmentGate.Release(); } + } + + private async Task<RetailArtworkSource?> FindAsync(string title, string? platform, CancellationToken cancellationToken, + HashSet<string>? excludedProviders = null) + { + excludedProviders ??= new HashSet<string>(StringComparer.OrdinalIgnoreCase); + title = ArtworkLookupTitle(title); + var curatedSource = FindCuratedRetailArtwork(title, platform); + if (!excludedProviders.Contains("WorldOfGames") && curatedSource is not null) return curatedSource; + if (!excludedProviders.Contains("Libretro") && platform is not null && LibretroPlatforms.TryGetValue(platform, out var playlist)) + { + var index = await indexes.GetOrAdd(playlist, key => LoadLibretroIndexAsync(key, cancellationToken)); + var normalizedTitle = Normalize(title); + if (!index.TryGetValue(normalizedTitle, out var file)) + { + file = index + .Where(candidate => IsLikelyMatch(normalizedTitle, candidate.Key)) + .OrderByDescending(candidate => Similarity(normalizedTitle, candidate.Key)) + .Select(candidate => candidate.Value) + .FirstOrDefault(); + } + if (file is not null) + { + var root = $"https://thumbnails.libretro.com/{Uri.EscapeDataString(playlist).Replace("%20", "%20")}/Named_Boxarts/"; + var url = root + Uri.EscapeDataString(file).Replace("%2F", "/"); + return new(url, "Libretro", "provider-title-platform-box-front", url); + } + } + if (!excludedProviders.Contains("Nintendo") && platform is ("switch" or "3ds" or "wii")) + { + var nintendo = await FindNintendoAsync(title, platform, cancellationToken); + if (nintendo is not null) return new(nintendo.Value.Url, nintendo.Value.Provider, + "provider-title-platform-box-front", nintendo.Value.Url); + } + if (!excludedProviders.Contains("LaunchBox") && platform is not null && LaunchBoxPlatforms.TryGetValue(platform, out var launchBoxPlatform)) + { + launchBoxIndex ??= LoadLaunchBoxIndexAsync(cancellationToken); + var launchBox = await launchBoxIndex; + var launchBoxPrefix = launchBoxPlatform + "\0"; + var launchBoxTitle = Normalize(title); + if (!launchBox.TryGetValue(launchBoxPrefix + launchBoxTitle, out var image)) + { + image = launchBox + .Where(candidate => candidate.Key.StartsWith(launchBoxPrefix, StringComparison.Ordinal) && + IsLikelyMatch(launchBoxTitle, candidate.Key[launchBoxPrefix.Length..])) + .OrderByDescending(candidate => Similarity(launchBoxTitle, candidate.Key[launchBoxPrefix.Length..])) + .Select(candidate => candidate.Value) + .FirstOrDefault(); + } + if (image is not null) + { + var url = $"https://gamesdb-images.launchbox.gg/{Uri.EscapeDataString(image)}"; + return new(url, "LaunchBox", "provider-title-platform-box-front", url); + } + } + return null; + } + + internal static RetailArtworkSource? FindCuratedRetailArtwork(string title, string? platform) + { + if (platform is null) return null; + var lookupTitle = ArtworkLookupTitle(title); + return CuratedArtwork.TryGetValue(platform.ToLowerInvariant() + "\0" + Normalize(lookupTitle), out var curated) + ? new(curated.Image, curated.Provider, curated.MatchMethod, curated.Product) + : null; + } + + private async Task<IReadOnlyDictionary<string, string>> LoadLaunchBoxIndexAsync(CancellationToken cancellationToken) + { + var catalogDirectory = Path.Combine(Path.GetDirectoryName(artworkStore.DirectoryPath)!, "catalogs"); + Directory.CreateDirectory(catalogDirectory); + var catalogPath = Path.Combine(catalogDirectory, "launchbox-metadata.zip"); + if (!File.Exists(catalogPath) || new FileInfo(catalogPath).Length is < 1_000_000 or > 128 * 1024 * 1024) + await DownloadCatalogAsync("https://gamesdb.launchbox-app.com/Metadata.zip", catalogPath, 128 * 1024 * 1024, cancellationToken); + + using var archive = ZipFile.OpenRead(catalogPath); + var entry = archive.GetEntry("Metadata.xml") ?? throw new InvalidDataException("LaunchBox metadata entry is missing."); + if (entry.Length > 650L * 1024 * 1024 || entry.CompressedLength == 0 || entry.Length / entry.CompressedLength > 20) + throw new InvalidDataException("LaunchBox metadata exceeds its extraction safety limits."); + await using var input = entry.Open(); + using var reader = XmlReader.Create(input, new XmlReaderSettings { Async = true, DtdProcessing = DtdProcessing.Prohibit, XmlResolver = null }); + var platformNames = LaunchBoxPlatforms.Values.ToHashSet(StringComparer.OrdinalIgnoreCase); + var games = new Dictionary<int, string>(); + var images = new Dictionary<string, (int Priority, string File)>(StringComparer.Ordinal); + while (await reader.ReadAsync()) + { + cancellationToken.ThrowIfCancellationRequested(); + if (reader.NodeType != XmlNodeType.Element || reader.Name is not ("Game" or "GameImage")) continue; + var elementName = reader.Name; + using var subtree = reader.ReadSubtree(); + string? name = null, platform = null, type = null, file = null; + int id = 0; + while (subtree.Read()) + { + if (subtree.NodeType != XmlNodeType.Element) continue; + switch (subtree.Name) + { + case "Name": name = subtree.ReadElementContentAsString(); break; + case "Platform": platform = subtree.ReadElementContentAsString(); break; + case "DatabaseID": + if (!int.TryParse(subtree.ReadElementContentAsString(), out id)) id = 0; + break; + case "Type": type = subtree.ReadElementContentAsString(); break; + case "FileName": file = subtree.ReadElementContentAsString(); break; + } + } + if (elementName == "Game" && id > 0 && name is not null && platform is not null && platformNames.Contains(platform)) + games[id] = platform + "\0" + Normalize(name); + else if (elementName == "GameImage" && id > 0 && file is not null && type is not null && games.TryGetValue(id, out var key)) + { + var priority = type switch { "Box - Front" => 0, "Box - Front - Reconstructed" => 1, "Box - 3D" => 2, _ => 99 }; + if (priority < 99 && (!images.TryGetValue(key, out var current) || priority < current.Priority)) images[key] = (priority, file); + } + } + return images.ToDictionary(pair => pair.Key, pair => pair.Value.File, StringComparer.Ordinal); + } + + private static async Task DownloadCatalogAsync(string url, string destination, long maximumBytes, CancellationToken cancellationToken) + { + var temporary = destination + ".tmp"; + await NetworkSlots.WaitAsync(cancellationToken); + try + { + using var response = await Client.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + response.EnsureSuccessStatusCode(); + if (response.Content.Headers.ContentLength is long length && length > maximumBytes) throw new InvalidDataException("Artwork catalog exceeds its download limit."); + await using var input = await response.Content.ReadAsStreamAsync(cancellationToken); + await using var output = new FileStream(temporary, FileMode.Create, FileAccess.Write, FileShare.None, 64 * 1024, FileOptions.Asynchronous); + var buffer = new byte[64 * 1024]; + while (true) + { + var read = await input.ReadAsync(buffer, cancellationToken); + if (read == 0) break; + if (output.Length + read > maximumBytes) throw new InvalidDataException("Artwork catalog exceeds its download limit."); + await output.WriteAsync(buffer.AsMemory(0, read), cancellationToken); + } + await output.FlushAsync(cancellationToken); + File.Move(temporary, destination, true); + } + finally { NetworkSlots.Release(); if (File.Exists(temporary)) File.Delete(temporary); } + } + + private static async Task<(string Url, string Provider)?> FindNintendoAsync(string title, string platform, CancellationToken cancellationToken) + { + var query = Uri.EscapeDataString(SearchTitle(title)); + var url = $"https://search.nintendo-europe.com/en/select?q={query}&fq=type%3AGAME&rows=12&wt=json"; + await NetworkSlots.WaitAsync(cancellationToken); + try + { + using var request = new HttpRequestMessage(HttpMethod.Get, url); + request.Headers.UserAgent.ParseAdd(ReleaseIdentity.UserAgent); + using var response = await Client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + if (!response.IsSuccessStatusCode) return null; + var json = await ReadTextBoundedAsync(response.Content, 2 * 1024 * 1024, cancellationToken); + using var document = JsonDocument.Parse(json); + if (!document.RootElement.TryGetProperty("response", out var result) || + !result.TryGetProperty("docs", out var items)) return null; + var wanted = Normalize(title); + foreach (var item in items.EnumerateArray()) + { + if (!item.TryGetProperty("title", out var name) || !IsLikelyMatch(wanted, Normalize(name.GetString() ?? ""))) continue; + if (!MatchesNintendoPlatform(item, platform)) continue; + if (item.TryGetProperty("image_url", out var image) && image.GetString() is { Length: > 0 } cover) + return (cover, "Nintendo"); + } + return null; + } + finally { NetworkSlots.Release(); } + } + + private static bool MatchesNintendoPlatform(JsonElement item, string platform) + { + if (!item.TryGetProperty("system_names_txt", out var systems) || systems.ValueKind != JsonValueKind.Array) return platform == "switch"; + var expected = platform switch { "switch" => "Nintendo Switch", "3ds" => "Nintendo 3DS", "wii" => "Wii", _ => "" }; + return systems.EnumerateArray().Any(value => string.Equals(value.GetString(), expected, StringComparison.OrdinalIgnoreCase)); + } + + private static async Task<IReadOnlyDictionary<string, string>> LoadLibretroIndexAsync(string playlist, CancellationToken cancellationToken) + { + var url = $"https://thumbnails.libretro.com/{Uri.EscapeDataString(playlist)}/Named_Boxarts/"; + await NetworkSlots.WaitAsync(cancellationToken); + try + { + using var request = new HttpRequestMessage(HttpMethod.Get, url); + request.Headers.UserAgent.ParseAdd(ReleaseIdentity.UserAgent); + using var response = await Client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + response.EnsureSuccessStatusCode(); + var html = await ReadTextBoundedAsync(response.Content, 32 * 1024 * 1024, cancellationToken); + var result = new Dictionary<string, string>(StringComparer.Ordinal); + foreach (Match match in ArtworkLinkRegex().Matches(html)) + { + var file = Uri.UnescapeDataString(WebUtility.HtmlDecode(match.Groups[1].Value)); + var key = Normalize(Path.GetFileNameWithoutExtension(file)); + if (key.Length > 2) result.TryAdd(key, file); + } + return result; + } + finally { NetworkSlots.Release(); } + } + + private static async Task<MemoryStream> DownloadAsync(string url, CancellationToken cancellationToken) + { + await NetworkSlots.WaitAsync(cancellationToken); + try + { + using var request = new HttpRequestMessage(HttpMethod.Get, url); + request.Headers.UserAgent.ParseAdd(ReleaseIdentity.UserAgent); + using var response = await Client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + response.EnsureSuccessStatusCode(); + if (response.Content.Headers.ContentLength is > GameArtworkStore.MaximumBytes) throw new ArtworkValidationException("Remote artwork exceeds the 10 MiB limit."); + var output = new MemoryStream(); + await using var input = await response.Content.ReadAsStreamAsync(cancellationToken); + var buffer = new byte[64 * 1024]; + while (true) + { + var read = await input.ReadAsync(buffer, cancellationToken); + if (read == 0) break; + if (output.Length + read > GameArtworkStore.MaximumBytes) throw new ArtworkValidationException("Remote artwork exceeds the 10 MiB limit."); + await output.WriteAsync(buffer.AsMemory(0, read), cancellationToken); + } + output.Position = 0; + return output; + } + finally { NetworkSlots.Release(); } + } + + private static async Task<string> ReadTextBoundedAsync(HttpContent content, int maximumBytes, CancellationToken cancellationToken) + { + if (content.Headers.ContentLength is long length && length > maximumBytes) throw new ArtworkValidationException("Remote artwork index exceeds its safety limit."); + await using var input = await content.ReadAsStreamAsync(cancellationToken); + using var output = new MemoryStream(); + var buffer = new byte[64 * 1024]; + while (true) + { + var read = await input.ReadAsync(buffer, cancellationToken); + if (read == 0) break; + if (output.Length + read > maximumBytes) throw new ArtworkValidationException("Remote artwork index exceeds its safety limit."); + await output.WriteAsync(buffer.AsMemory(0, read), cancellationToken); + } + return System.Text.Encoding.UTF8.GetString(output.GetBuffer(), 0, checked((int)output.Length)); + } + + internal static string Normalize(string value) => NonAlphaNumericRegex().Replace(EditionNoiseRegex().Replace(CanonicalTitle(value), " ").ToLowerInvariant(), ""); + internal static string ArtworkLookupTitle(string value) + { + var searched = SearchTitle(value); + var normalized = Normalize(searched); + if (normalized.StartsWith("marioluigibowsersinsidestory", StringComparison.Ordinal)) + return "Mario & Luigi Bowser's Inside Story + Bowser Jr.'s Journey"; + return ArtworkLookupAliases.GetValueOrDefault(normalized, searched); + } + internal static bool IsLikelyMatch(string wanted, string candidate) + { + if (wanted == candidate) return true; + if (wanted.Length < 8 || candidate.Length < 8) return false; + return Similarity(wanted, candidate) >= 0.82d; + } + + private static double Similarity(string left, string right) => + 1d - (double)EditDistance(left, right) / Math.Max(left.Length, right.Length); + + private static int EditDistance(string left, string right) + { + var previous = new int[right.Length + 1]; + var current = new int[right.Length + 1]; + for (var index = 0; index <= right.Length; index++) previous[index] = index; + for (var row = 1; row <= left.Length; row++) + { + current[0] = row; + for (var column = 1; column <= right.Length; column++) + { + var substitution = previous[column - 1] + (left[row - 1] == right[column - 1] ? 0 : 1); + current[column] = Math.Min(Math.Min(previous[column] + 1, current[column - 1] + 1), substitution); + } + (previous, current) = (current, previous); + } + return previous[right.Length]; + } + private static string SearchTitle(string value) => SeparatorRegex().Replace(EditionNoiseRegex().Replace(CanonicalTitle(value), " "), " ").Trim(); + private static string CanonicalTitle(string value) => PlatformSuffixRegex().Replace(BracketedSuffixRegex().Replace(value, " "), "").Replace("™", "").Replace("®", "").Trim(); + + [GeneratedRegex("href=\"([^\"]+\\.(?:png|jpg|jpeg|webp))\"", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] + private static partial Regex ArtworkLinkRegex(); + [GeneratedRegex("[\\(\\[].*?[\\)\\]]", RegexOptions.CultureInvariant)] + private static partial Regex BracketedSuffixRegex(); + [GeneratedRegex("\\s+(?:PS[2345]|PSP|PSV|PS Vita|Switch|NDS|PC)$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] + private static partial Regex PlatformSuffixRegex(); + [GeneratedRegex("\\b(?:complete|deluxe|ultimate|definitive|standard|gold|collector'?s?|game of the year|goty|edition|incl(?:uding)? dlc|dlc unlocker|ce|usa|eur|europe|ntsc-u|clean|multi\\d*)\\b|\\b(?:NDS|PSV|PSP)-[a-z0-9]+\\b|\\bv\\d+(?:[ ._]\\d+)*|\\b(?:PPSA|CUSA)\\d+.*$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] + private static partial Regex EditionNoiseRegex(); + [GeneratedRegex("\\s*[-–—_:]+\\s*", RegexOptions.CultureInvariant)] + private static partial Regex SeparatorRegex(); + [GeneratedRegex("[^a-z0-9]", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] + private static partial Regex NonAlphaNumericRegex(); +} diff --git a/src/Ludarium.Infrastructure/GameArtworkStore.cs b/src/Ludarium.Infrastructure/GameArtworkStore.cs new file mode 100644 index 0000000..0b655e1 --- /dev/null +++ b/src/Ludarium.Infrastructure/GameArtworkStore.cs @@ -0,0 +1,183 @@ +using System.Buffers.Binary; +using System.Security.Cryptography; +using Ludarium.Application; +using Ludarium.Domain; + +namespace Ludarium.Infrastructure; + +public sealed record StoredGameArtwork(Stream Content, string ContentType, long Length); + +public sealed class GameArtworkStore +{ + public const long MaximumBytes = 10 * 1024 * 1024; + internal string DirectoryPath => root; + private readonly string root; + + public GameArtworkStore(string root) + { + this.root = Path.GetFullPath(root); + Directory.CreateDirectory(this.root); + } + + public async Task<StoredGameArtwork?> OpenAsync(Guid gameId, CancellationToken cancellationToken, Guid? artworkId = null, bool fallbackToCurrent = true) + { + var candidate = artworkId is null ? null : CandidatePathFor(gameId, artworkId.Value); + var path = candidate is not null && File.Exists(candidate) ? candidate : fallbackToCurrent ? PathFor(gameId) : candidate; + if (!File.Exists(path)) return null; + var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 64 * 1024, FileOptions.Asynchronous | FileOptions.SequentialScan); + var signature = new byte[12]; + var read = await stream.ReadAsync(signature, cancellationToken); + stream.Position = 0; + var contentType = Detect(signature.AsSpan(0, read)); + if (contentType is null) + { + await stream.DisposeAsync(); + return null; + } + return new(stream, contentType, stream.Length); + } + + public async Task<string> SaveAsync(Guid gameId, Stream content, long? declaredLength, CancellationToken cancellationToken, string source = "Uploaded") + { + if (declaredLength is > MaximumBytes) throw new ArtworkValidationException("Artwork exceeds the 10 MiB limit."); + var destination = PathFor(gameId); + var temporary = destination + ".upload-" + Guid.NewGuid().ToString("N"); + try + { + await using (var output = new FileStream(temporary, FileMode.CreateNew, FileAccess.Write, FileShare.None, 64 * 1024, FileOptions.Asynchronous | FileOptions.SequentialScan)) + { + var buffer = new byte[64 * 1024]; + long total = 0; + while (true) + { + var read = await content.ReadAsync(buffer, cancellationToken); + if (read == 0) break; + total += read; + if (total > MaximumBytes) throw new ArtworkValidationException("Artwork exceeds the 10 MiB limit."); + await output.WriteAsync(buffer.AsMemory(0, read), cancellationToken); + } + if (total == 0) throw new ArtworkValidationException("Artwork is empty."); + } + + var signature = new byte[12]; + string? contentType; + await using (var input = new FileStream(temporary, FileMode.Open, FileAccess.Read, FileShare.Read)) + { + var read = await input.ReadAsync(signature, cancellationToken); + contentType = Detect(signature.AsSpan(0, read)); + if (contentType is null) throw new ArtworkValidationException("Use a valid JPEG, PNG or WebP image."); + } + File.Move(temporary, destination, true); + await File.WriteAllTextAsync(SourcePathFor(gameId), source, cancellationToken); + return contentType; + } + finally + { + if (File.Exists(temporary)) File.Delete(temporary); + } + } + + public bool Delete(Guid gameId) + { + var path = PathFor(gameId); + if (!File.Exists(path)) return false; + File.Delete(path); + var sourcePath = SourcePathFor(gameId); + if (File.Exists(sourcePath)) File.Delete(sourcePath); + return true; + } + + public int DeleteAll(Guid gameId) + { + var deleted = Delete(gameId) ? 1 : 0; + var candidates = Path.Combine(root, "candidates", gameId.ToString("N")); + if (Directory.Exists(candidates)) + { + deleted += Directory.EnumerateFiles(candidates).Count(); + Directory.Delete(candidates, true); + } + return deleted; + } + + public string GetSource(Guid gameId) + { + var path = SourcePathFor(gameId); + if (!File.Exists(path)) return "Uploaded"; + var source = File.ReadAllText(path).Trim(); + return source.Length is > 0 and <= 40 ? source : "Uploaded"; + } + + public async Task<GameArtwork?> DescribeAsync(Guid gameId, string? provider, string matchMethod, + Confidence confidence, CancellationToken cancellationToken, string? platform = null, string? externalId = null) + { + var path = PathFor(gameId); + if (!File.Exists(path)) return null; + await using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 64 * 1024, + FileOptions.Asynchronous | FileOptions.SequentialScan); + var dimensions = await ReadDimensionsAsync(stream, cancellationToken); + stream.Position = 0; + var hash = Convert.ToHexString(await SHA256.HashDataAsync(stream, cancellationToken)).ToLowerInvariant(); + var source = GetSource(gameId); + decimal? ratio = dimensions.Width is not null && dimensions.Height is > 0 + ? Math.Round((decimal)dimensions.Width.Value / dimensions.Height.Value, 4) + : null; + var score = ArtworkQuality.Score(dimensions.Width, dimensions.Height, ratio, source, confidence, platform); + var uploaded = source.Equals("Uploaded", StringComparison.OrdinalIgnoreCase); + var verified = uploaded || score >= ArtworkQuality.VerifiedThreshold; + // Candidate identity is content-addressed. Re-running enrichment must not create a + // fresh review candidate for the same bytes and source every time. + var identity = System.Text.Encoding.UTF8.GetBytes($"{gameId:N}|{platform ?? "global"}|{hash}|{source}|{provider ?? source}"); + var artworkId = new Guid(SHA256.HashData(identity)[..16]); + var candidatePath = CandidatePathFor(gameId, artworkId); + Directory.CreateDirectory(Path.GetDirectoryName(candidatePath)!); + File.Copy(path, candidatePath, true); + return new(artworkId, gameId, + uploaded ? ArtworkRole.UserUpload : verified ? ArtworkRole.VerifiedBoxFront : ArtworkRole.BoxFront, + uploaded ? ArtworkVerificationStatus.ManuallyVerified : verified ? ArtworkVerificationStatus.AutomaticallyVerified : ArtworkVerificationStatus.NeedsReview, + source, provider ?? source, externalId, matchMethod, confidence, dimensions.Width, dimensions.Height, ratio, + stream.Length, hash, score, DateTimeOffset.UtcNow, Platform: platform); + } + + internal static async Task<(int? Width, int? Height)> ReadDimensionsAsync(Stream stream, CancellationToken ct) + { + var header = new byte[32]; + var read = await stream.ReadAsync(header, ct); + if (read >= 24 && Detect(header.AsSpan(0, read)) == "image/png") + return (BinaryPrimitives.ReadInt32BigEndian(header.AsSpan(16, 4)), BinaryPrimitives.ReadInt32BigEndian(header.AsSpan(20, 4))); + if (read >= 30 && Detect(header.AsSpan(0, read)) == "image/webp" && header.AsSpan(12, 4).SequenceEqual("VP8X"u8)) + return (1 + header[24] + (header[25] << 8) + (header[26] << 16), 1 + header[27] + (header[28] << 8) + (header[29] << 16)); + if (read >= 3 && Detect(header.AsSpan(0, read)) == "image/jpeg") + { + stream.Position = 2; + var marker = new byte[4]; + while (await stream.ReadAsync(marker.AsMemory(0, 4), ct) == 4) + { + if (marker[0] != 0xff) break; + var length = BinaryPrimitives.ReadUInt16BigEndian(marker.AsSpan(2, 2)); + if (length < 2) break; + if (marker[1] is >= 0xc0 and <= 0xc3 or >= 0xc5 and <= 0xc7 or >= 0xc9 and <= 0xcb or >= 0xcd and <= 0xcf) + { + var size = new byte[5]; + if (await stream.ReadAsync(size, ct) != 5) break; + return (BinaryPrimitives.ReadUInt16BigEndian(size.AsSpan(3, 2)), BinaryPrimitives.ReadUInt16BigEndian(size.AsSpan(1, 2))); + } + stream.Seek(length - 2, SeekOrigin.Current); + } + } + return (null, null); + } + + private string PathFor(Guid gameId) => Path.Combine(root, gameId.ToString("N") + ".artwork"); + private string CandidatePathFor(Guid gameId, Guid artworkId) => Path.Combine(root, "candidates", gameId.ToString("N"), artworkId.ToString("N") + ".artwork"); + private string SourcePathFor(Guid gameId) => Path.Combine(root, gameId.ToString("N") + ".source"); + + internal static string? Detect(ReadOnlySpan<byte> bytes) + { + if (bytes.Length >= 3 && bytes[0] == 0xff && bytes[1] == 0xd8 && bytes[2] == 0xff) return "image/jpeg"; + if (bytes.Length >= 8 && bytes[..8].SequenceEqual(new byte[] { 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a })) return "image/png"; + if (bytes.Length >= 12 && bytes[..4].SequenceEqual("RIFF"u8) && bytes[8..12].SequenceEqual("WEBP"u8)) return "image/webp"; + return null; + } +} + +public sealed class ArtworkValidationException(string message) : Exception(message); diff --git a/src/Ludarium.Infrastructure/GameCatalogExportService.cs b/src/Ludarium.Infrastructure/GameCatalogExportService.cs new file mode 100644 index 0000000..24418b8 --- /dev/null +++ b/src/Ludarium.Infrastructure/GameCatalogExportService.cs @@ -0,0 +1,196 @@ +using System.Text; +using System.Text.Json; +using System.Xml; +using Ludarium.Application; +using Ludarium.Domain; + +namespace Ludarium.Infrastructure; + +public sealed class GameCatalogExportService( + ILudariumStore store, + ILibraryExperienceStore experienceStore, + string exportRoot) : IGameCatalogExportService +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + private readonly string exportRoot = Path.GetFullPath(exportRoot); + + public async Task<GameCatalogExportResult> CreateAsync(string format, CancellationToken cancellationToken) + { + var normalized = format.Trim().ToLowerInvariant() switch + { + "json" or "ludarium" => "json", + "emulationstation" or "gamelist" or "xml" => "emulationstation", + "pegasus" or "metadata.pegasus.txt" => "pegasus", + _ => throw new ArgumentException("Format must be json, emulationstation or pegasus.", nameof(format)) + }; + Directory.CreateDirectory(exportRoot); + var createdAt = DateTimeOffset.UtcNow; + var stamp = createdAt.ToString("yyyyMMdd-HHmmss-fff", System.Globalization.CultureInfo.InvariantCulture); + var fileName = normalized switch + { + "emulationstation" => $"gamelist-{stamp}.xml", + "pegasus" => $"metadata.pegasus-{stamp}.txt", + _ => $"game-catalog-{stamp}.json" + }; + var path = ContainedPath(fileName); + try + { + var count = normalized switch + { + "json" => await WriteJsonAsync(path, createdAt, cancellationToken), + "emulationstation" => await WriteEmulationStationAsync(path, cancellationToken), + _ => await WritePegasusAsync(path, cancellationToken) + }; + return new(fileName, normalized, count, createdAt); + } + catch + { + if (File.Exists(path)) File.Delete(path); + throw; + } + } + + public Task<GameCatalogExportFile?> OpenAsync(string fileName, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + if (string.IsNullOrWhiteSpace(fileName) || fileName != Path.GetFileName(fileName)) + return Task.FromResult<GameCatalogExportFile?>(null); + var path = ContainedPath(fileName); + if (!File.Exists(path)) return Task.FromResult<GameCatalogExportFile?>(null); + var contentType = Path.GetExtension(path).ToLowerInvariant() switch + { + ".json" => "application/json", + ".xml" => "application/xml", + _ => "text/plain; charset=utf-8" + }; + var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 64 * 1024, + FileOptions.Asynchronous | FileOptions.SequentialScan); + return Task.FromResult<GameCatalogExportFile?>(new(stream, contentType, stream.Length, fileName)); + } + + private async Task<long> WriteJsonAsync(string path, DateTimeOffset createdAt, CancellationToken cancellationToken) + { + await using var output = NewOutput(path); + using var writer = new Utf8JsonWriter(output, new JsonWriterOptions { Indented = true }); + writer.WriteStartObject(); + writer.WriteString("schemaVersion", "1"); + writer.WriteString("applicationVersion", ReleaseIdentity.Version); + writer.WriteString("createdAt", createdAt); + writer.WriteBoolean("sourceLibraryMutated", false); + writer.WriteStartArray("games"); + long count = 0; + await foreach (var game in ReadGamesAsync(cancellationToken)) + { + cancellationToken.ThrowIfCancellationRequested(); + writer.WriteStartObject(); + writer.WritePropertyName("game"); JsonSerializer.Serialize(writer, game, JsonOptions); + writer.WritePropertyName("releases"); JsonSerializer.Serialize(writer, await store.ListReleasesAsync(game.Id, cancellationToken), JsonOptions); + writer.WritePropertyName("claims"); JsonSerializer.Serialize(writer, await store.ListClaimsAsync("Game", game.Id, cancellationToken), JsonOptions); + writer.WritePropertyName("tags"); JsonSerializer.Serialize(writer, await experienceStore.ListGameTagsAsync(game.Id, cancellationToken), JsonOptions); + writer.WritePropertyName("state"); JsonSerializer.Serialize(writer, await experienceStore.GetGameUserStateAsync(game.Id, cancellationToken), JsonOptions); + writer.WritePropertyName("relations"); JsonSerializer.Serialize(writer, await experienceStore.ListGameRelationsAsync(game.Id, cancellationToken), JsonOptions); + writer.WritePropertyName("media"); JsonSerializer.Serialize(writer, await experienceStore.ListGameMediaAsync(game.Id, cancellationToken), JsonOptions); + writer.WritePropertyName("achievements"); JsonSerializer.Serialize(writer, await experienceStore.GetAchievementProgressAsync(game.Id, cancellationToken), JsonOptions); + writer.WriteEndObject(); + count++; + if (count % 50 == 0) await writer.FlushAsync(cancellationToken); + } + writer.WriteEndArray(); + writer.WriteEndObject(); + await writer.FlushAsync(cancellationToken); + return count; + } + + private async Task<long> WriteEmulationStationAsync(string path, CancellationToken cancellationToken) + { + await using var output = NewOutput(path); + await using var writer = XmlWriter.Create(output, new XmlWriterSettings + { + Async = true, + Encoding = new UTF8Encoding(false), + Indent = true, + CloseOutput = false + }); + await writer.WriteStartDocumentAsync(); + await writer.WriteCommentAsync("Metadata-only Ludarium export. ludarium:// paths never execute or mutate source content."); + await writer.WriteStartElementAsync(null, "gameList", null); + long count = 0; + await foreach (var game in ReadGamesAsync(cancellationToken)) + { + var claims = await store.ListClaimsAsync("Game", game.Id, cancellationToken); + var effective = claims.GroupBy(item => item.Field, StringComparer.OrdinalIgnoreCase) + .ToDictionary(group => group.Key, group => ClaimPolicy.SelectEffective(group)!.Value, StringComparer.OrdinalIgnoreCase); + await writer.WriteStartElementAsync(null, "game", null); + await writer.WriteElementStringAsync(null, "path", null, $"ludarium://game/{game.Id}"); + await writer.WriteElementStringAsync(null, "name", null, effective.GetValueOrDefault("officialTitle") ?? game.Title); + await WriteOptionalElementAsync(writer, "desc", effective.GetValueOrDefault("description")); + await WriteOptionalElementAsync(writer, "releasedate", NormalizeExportDate(effective.GetValueOrDefault("releaseDate"))); + await WriteOptionalElementAsync(writer, "developer", effective.GetValueOrDefault("developer")); + await WriteOptionalElementAsync(writer, "publisher", effective.GetValueOrDefault("publisher")); + await WriteOptionalElementAsync(writer, "genre", effective.GetValueOrDefault("genre")); + await writer.WriteEndElementAsync(); + count++; + } + await writer.WriteEndElementAsync(); + await writer.WriteEndDocumentAsync(); + await writer.FlushAsync(); + return count; + } + + private async Task<long> WritePegasusAsync(string path, CancellationToken cancellationToken) + { + await using var output = NewOutput(path); + await using var writer = new StreamWriter(output, new UTF8Encoding(false)); + await writer.WriteLineAsync("# Metadata-only Ludarium export; ludarium:// files are identifiers, not executable paths."); + long count = 0; + await foreach (var game in ReadGamesAsync(cancellationToken)) + { + var claims = await store.ListClaimsAsync("Game", game.Id, cancellationToken); + var effective = claims.GroupBy(item => item.Field, StringComparer.OrdinalIgnoreCase) + .ToDictionary(group => group.Key, group => ClaimPolicy.SelectEffective(group)!.Value, StringComparer.OrdinalIgnoreCase); + await writer.WriteLineAsync($"game: {SingleLine(effective.GetValueOrDefault("officialTitle") ?? game.Title)}"); + await writer.WriteLineAsync($"file: ludarium://game/{game.Id}"); + if (effective.GetValueOrDefault("description") is { } description) + await writer.WriteLineAsync($"description: {SingleLine(description)}"); + if (effective.GetValueOrDefault("developer") is { } developer) + await writer.WriteLineAsync($"developer: {SingleLine(developer)}"); + if (effective.GetValueOrDefault("publisher") is { } publisher) + await writer.WriteLineAsync($"publisher: {SingleLine(publisher)}"); + if (effective.GetValueOrDefault("genre") is { } genre) + await writer.WriteLineAsync($"genre: {SingleLine(genre)}"); + await writer.WriteLineAsync(); + count++; + } + return count; + } + + private async IAsyncEnumerable<Game> ReadGamesAsync( + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) + { + const int pageSize = 200; + for (var pageNumber = 1; ; pageNumber++) + { + var page = await store.SearchGamesAsync(null, null, pageNumber, pageSize, cancellationToken); + foreach (var game in page.Items) yield return game; + if (page.Items.Count == 0 || (long)pageNumber * pageSize >= page.Total) yield break; + } + } + + private string ContainedPath(string fileName) + { + var path = Path.GetFullPath(Path.Combine(exportRoot, fileName)); + if (!path.StartsWith(exportRoot + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) + throw new ArgumentException("Export path escapes the app-owned export directory.", nameof(fileName)); + return path; + } + private static FileStream NewOutput(string path) => new(path, FileMode.CreateNew, FileAccess.Write, + FileShare.None, 64 * 1024, FileOptions.Asynchronous | FileOptions.SequentialScan); + private static async Task WriteOptionalElementAsync(XmlWriter writer, string name, string? value) + { + if (!string.IsNullOrWhiteSpace(value)) await writer.WriteElementStringAsync(null, name, null, value); + } + private static string? NormalizeExportDate(string? value) => DateTimeOffset.TryParse(value, out var date) + ? date.ToString("yyyyMMdd'T'HHmmss", System.Globalization.CultureInfo.InvariantCulture) : null; + private static string SingleLine(string value) => value.Replace("\r", " ", StringComparison.Ordinal) + .Replace("\n", " ", StringComparison.Ordinal).Trim(); +} diff --git a/src/Ludarium.Infrastructure/GameCheatCatalog.cs b/src/Ludarium.Infrastructure/GameCheatCatalog.cs new file mode 100644 index 0000000..fa4599b --- /dev/null +++ b/src/Ludarium.Infrastructure/GameCheatCatalog.cs @@ -0,0 +1,222 @@ +using System.Net; +using System.Text.Json; +using System.Text.RegularExpressions; +using Ludarium.Application; + +namespace Ludarium.Infrastructure; + +public sealed record GameCheat(string Name, string Code); +public sealed record GameCheatSet(bool Available, string State, string Message, string Source, + string Version, IReadOnlyList<GameCheat> Cheats); +public sealed record GameCheatCatalogStatus(bool Cached, string Source, string Version, + int IndexedFiles, DateTimeOffset? UpdatedAt); + +/// <summary> +/// A bounded, app-owned index of the CC-BY-SA Libretro cheat database. Matching is deliberately +/// exact after title normalization; regional ambiguity returns no codes instead of guessing. +/// </summary> +public sealed partial class GameCheatCatalog : IDisposable +{ + public const string Source = "Libretro Database"; + public const string Version = "v1.21.1"; + public const string Commit = "ccb2dca5d04b0a44aa0a6eb7489a71bfc66419a8"; + public static readonly IReadOnlyList<string> BrowserPlatforms = + ["nes", "snes", "gb", "gbc", "gba", "n64", "nds", "psx"]; + private const int MaximumIndexBytes = 16 * 1024 * 1024; + private const int MaximumCheatBytes = 1024 * 1024; + private readonly string root; + private readonly string indexPath; + private readonly HttpClient client; + private readonly bool ownsClient; + private readonly SemaphoreSlim refreshGate = new(1, 1); + private IReadOnlyList<CheatIndexEntry>? index; + + private static readonly Dictionary<string, string> PlatformFolders = new(StringComparer.OrdinalIgnoreCase) + { + ["nes"] = "Nintendo - Nintendo Entertainment System", + ["snes"] = "Nintendo - Super Nintendo Entertainment System", + ["gb"] = "Nintendo - Game Boy", + ["gbc"] = "Nintendo - Game Boy Color", + ["gba"] = "Nintendo - Game Boy Advance", + ["n64"] = "Nintendo - Nintendo 64", + ["nds"] = "Nintendo - Nintendo DS", + ["psx"] = "Sony - PlayStation" + }; + + public GameCheatCatalog(string root, HttpClient? client = null) + { + this.root = Path.GetFullPath(root); + indexPath = Path.Combine(this.root, $"libretro-{Version}-{Commit[..12]}.json"); + ownsClient = client is null; + this.client = client ?? new HttpClient(new HttpClientHandler { AllowAutoRedirect = false }) + { + Timeout = TimeSpan.FromSeconds(30) + }; + this.client.DefaultRequestHeaders.UserAgent.ParseAdd(ReleaseIdentity.UserAgent); + } + + public async Task<GameCheatCatalogStatus> StatusAsync(CancellationToken ct) + { + var entries = await LoadIndexAsync(false, ct); + return new(File.Exists(indexPath), Source, Version, entries?.Count ?? 0, + File.Exists(indexPath) ? File.GetLastWriteTimeUtc(indexPath) : null); + } + + public async Task<GameCheatCatalogStatus> RefreshAsync(CancellationToken ct) + { + await LoadIndexAsync(true, ct); + return await StatusAsync(ct); + } + + public async Task<GameCheatSet> FindAsync(string title, string platform, CancellationToken ct) + { + if (!PlatformFolders.TryGetValue(platform, out var folder)) + return Unavailable("UnsupportedPlatform", "Automatic cheats are not integrated for this player yet."); + var entries = await LoadIndexAsync(false, ct) ?? await LoadIndexAsync(true, ct) ?? []; + var wanted = GameArtworkEnricher.Normalize(title); + var matches = entries.Where(entry => entry.Folder.Equals(folder, StringComparison.OrdinalIgnoreCase) && + GameArtworkEnricher.Normalize(Path.GetFileNameWithoutExtension(entry.Path)).Equals(wanted, StringComparison.Ordinal)) + .ToArray(); + if (matches.Length == 0) + return Unavailable("NotFound", "No exact title match exists in the cached Libretro cheat database."); + if (matches.Length > 1) + return Unavailable("Ambiguous", "Multiple regional cheat files match this title; Ludarium will not guess."); + + var entry = matches[0]; + var cachePath = Path.Combine(root, "files", entry.Sha + ".cht"); + if (!File.Exists(cachePath)) await DownloadCheatAsync(entry, cachePath, ct); + var length = new FileInfo(cachePath).Length; + if (length is <= 0 or > MaximumCheatBytes) + return Unavailable("Invalid", "The matched cheat file failed its bounded size check."); + var cheats = Parse(await File.ReadAllTextAsync(cachePath, ct)); + return cheats.Count == 0 + ? Unavailable("Empty", "The matched cheat file did not contain usable codes.") + : new(true, "Available", $"{cheats.Count} cheats matched by exact platform and title.", Source, Version, cheats); + } + + public static IReadOnlyList<GameCheat> Parse(string content) + { + var descriptions = new Dictionary<int, string>(); + var codes = new Dictionary<int, string>(); + foreach (var line in content.Split('\n', StringSplitOptions.RemoveEmptyEntries)) + { + var match = CheatLine().Match(line.Trim()); + if (!match.Success || !int.TryParse(match.Groups[1].Value, out var number) || number is < 0 or >= 1000) continue; + var value = match.Groups[3].Value.Trim(); + if (match.Groups[2].Value == "desc" && value.Length is > 0 and <= 200) descriptions[number] = value; + if (match.Groups[2].Value == "code" && value.Length is > 0 and <= 8192) codes[number] = value; + } + return descriptions.Keys.Intersect(codes.Keys).Order().Take(500) + .Select(number => new GameCheat(descriptions[number], codes[number])).ToArray(); + } + + private async Task<IReadOnlyList<CheatIndexEntry>?> LoadIndexAsync(bool refresh, CancellationToken ct) + { + if (!refresh && index is not null) return index; + await refreshGate.WaitAsync(ct); + try + { + if (!refresh && index is not null) return index; + Directory.CreateDirectory(root); + if (!refresh && !File.Exists(indexPath)) return null; + if (refresh) await DownloadIndexAsync(ct); + await using var stream = File.OpenRead(indexPath); + index = await JsonSerializer.DeserializeAsync<CheatIndexEntry[]>(stream, cancellationToken: ct) ?? []; + return index; + } + finally { refreshGate.Release(); } + } + + private async Task DownloadIndexAsync(CancellationToken ct) + { + var url = $"https://api.github.com/repos/libretro/libretro-database/git/trees/{Commit}?recursive=1"; + using var response = await client.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, ct); + if (response.StatusCode != HttpStatusCode.OK) throw new HttpRequestException("The pinned Libretro cheat index is unavailable."); + if (response.Content.Headers.ContentLength is > MaximumIndexBytes) throw new InvalidDataException("The cheat index exceeds its size limit."); + await using var input = await response.Content.ReadAsStreamAsync(ct); + using var output = new MemoryStream(); + var buffer = new byte[64 * 1024]; + while (true) + { + var read = await input.ReadAsync(buffer, ct); + if (read == 0) break; + if (output.Length + read > MaximumIndexBytes) throw new InvalidDataException("The cheat index exceeds its size limit."); + await output.WriteAsync(buffer.AsMemory(0, read), ct); + } + output.Position = 0; + using var document = await JsonDocument.ParseAsync(output, cancellationToken: ct); + var rootElement = document.RootElement; + if (!rootElement.GetProperty("sha").GetString()!.Equals(Commit, StringComparison.OrdinalIgnoreCase) || + rootElement.GetProperty("truncated").GetBoolean()) + throw new InvalidDataException("The cheat index did not match the pinned complete Git tree."); + var allowedFolders = PlatformFolders.Values.ToHashSet(StringComparer.OrdinalIgnoreCase); + var entries = new List<CheatIndexEntry>(); + foreach (var item in rootElement.GetProperty("tree").EnumerateArray()) + { + if (item.GetProperty("type").GetString() != "blob" || item.GetProperty("size").GetInt64() is <= 0 or > MaximumCheatBytes) continue; + var path = item.GetProperty("path").GetString() ?? ""; + var parts = path.Split('/'); + if (parts.Length != 3 || parts[0] != "cht" || !allowedFolders.Contains(parts[1]) || + !parts[2].EndsWith(".cht", StringComparison.OrdinalIgnoreCase)) continue; + var sha = item.GetProperty("sha").GetString() ?? ""; + if (sha.Length == 40 && sha.All(Uri.IsHexDigit)) entries.Add(new(parts[1], path, sha.ToLowerInvariant())); + } + if (entries.Count < 100) throw new InvalidDataException("The pinned cheat index did not contain the expected platform coverage."); + var temporary = indexPath + ".tmp"; + try + { + await File.WriteAllTextAsync(temporary, JsonSerializer.Serialize(entries), ct); + File.Move(temporary, indexPath, true); + } + finally + { + if (File.Exists(temporary)) File.Delete(temporary); + } + index = entries; + } + + private async Task DownloadCheatAsync(CheatIndexEntry entry, string destination, CancellationToken ct) + { + var encoded = string.Join('/', entry.Path.Split('/').Select(Uri.EscapeDataString)); + using var response = await client.GetAsync($"https://raw.githubusercontent.com/libretro/libretro-database/{Commit}/{encoded}", + HttpCompletionOption.ResponseHeadersRead, ct); + if (response.StatusCode != HttpStatusCode.OK) throw new HttpRequestException("The matched cheat file is unavailable."); + if (response.Content.Headers.ContentLength is > MaximumCheatBytes) throw new InvalidDataException("The cheat file exceeds its size limit."); + Directory.CreateDirectory(Path.GetDirectoryName(destination)!); + var temporary = destination + ".tmp"; + try + { + await using (var input = await response.Content.ReadAsStreamAsync(ct)) + await using (var output = new FileStream(temporary, FileMode.Create, FileAccess.Write, FileShare.None, 64 * 1024, FileOptions.Asynchronous)) + { + var buffer = new byte[32 * 1024]; + while (true) + { + var read = await input.ReadAsync(buffer, ct); + if (read == 0) break; + if (output.Length + read > MaximumCheatBytes) throw new InvalidDataException("The cheat file exceeds its size limit."); + await output.WriteAsync(buffer.AsMemory(0, read), ct); + } + } + File.Move(temporary, destination, true); + } + finally + { + if (File.Exists(temporary)) File.Delete(temporary); + } + } + + private static GameCheatSet Unavailable(string state, string message) => + new(false, state, message, Source, Version, []); + + private sealed record CheatIndexEntry(string Folder, string Path, string Sha); + + public void Dispose() + { + refreshGate.Dispose(); + if (ownsClient) client.Dispose(); + } + + [GeneratedRegex("^cheat(\\d+)_(desc|code)\\s*=\\s*\"(.*)\"$", RegexOptions.CultureInvariant)] + private static partial Regex CheatLine(); +} diff --git a/src/Ludarium.Infrastructure/GameDataVaultFileStore.cs b/src/Ludarium.Infrastructure/GameDataVaultFileStore.cs new file mode 100644 index 0000000..1f6a8a1 --- /dev/null +++ b/src/Ludarium.Infrastructure/GameDataVaultFileStore.cs @@ -0,0 +1,226 @@ +using System.Security.Cryptography; +using Ludarium.Application; +using Ludarium.Domain; + +namespace Ludarium.Infrastructure; + +public sealed record StoredGameDataRevision(Stream Content, long Length); +public sealed record GameDataReconciliationResult(int RemovedUploads, int RemovedOrphans, long ReclaimedBytes); + +public sealed class GameDataVaultFileStore +{ + public const long MaximumBytes = 64L * 1024 * 1024; + private readonly string root; + + public GameDataVaultFileStore(string root) + { + this.root = Path.GetFullPath(root); + Directory.CreateDirectory(this.root); + } + + public async Task<GameDataRevision> SaveAsync(Guid gameId, Guid entryId, Guid revisionId, + string fileName, Stream input, long? declaredLength, string actor, CancellationToken ct, + string sourceType = "ManualUpload", string? sourceId = null) + { + fileName = GameDataVaultPolicy.ValidateFileName(fileName); + if (declaredLength is > MaximumBytes) + throw new GameDataVaultValidationException("Game data exceeds the 64 MiB upload limit."); + var directory = ContainedPath($"{gameId:N}/{entryId:N}"); + Directory.CreateDirectory(directory); + var relative = $"{gameId:N}/{entryId:N}/{revisionId:N}.bin"; + var destination = ContainedPath(relative); + var temporary = ContainedPath($"{gameId:N}/{entryId:N}/{revisionId:N}.{Guid.NewGuid():N}.uploading"); + try + { + using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); + var header = new byte[16]; + var headerLength = 0; + long length = 0; + await using (var output = new FileStream(temporary, FileMode.CreateNew, FileAccess.Write, + FileShare.None, 64 * 1024, FileOptions.Asynchronous | FileOptions.SequentialScan)) + { + var buffer = new byte[64 * 1024]; + while (true) + { + var read = await input.ReadAsync(buffer, ct); + if (read == 0) break; + length += read; + if (length > MaximumBytes) + throw new GameDataVaultValidationException("Game data exceeds the 64 MiB upload limit."); + var headerBytes = Math.Min(header.Length - headerLength, read); + if (headerBytes > 0) + { + buffer.AsSpan(0, headerBytes).CopyTo(header.AsSpan(headerLength)); + headerLength += headerBytes; + } + hash.AppendData(buffer, 0, read); + await output.WriteAsync(buffer.AsMemory(0, read), ct); + } + await output.FlushAsync(ct); + } + if (length == 0) throw new GameDataVaultValidationException("Game data is empty."); + RejectExecutableContent(header.AsSpan(0, headerLength)); + var sha256 = Convert.ToHexString(hash.GetHashAndReset()).ToLowerInvariant(); + if (File.Exists(destination)) + { + var existing = await HashExistingAsync(destination, ct); + if (existing.Length != length || !existing.Sha256.Equals(sha256, StringComparison.Ordinal)) + throw new ResourceConflictException("The revision id is already used by different content."); + } + else + { + try + { + File.Move(temporary, destination, false); + } + catch (IOException) when (File.Exists(destination)) + { + var existing = await HashExistingAsync(destination, ct); + if (existing.Length != length || !existing.Sha256.Equals(sha256, StringComparison.Ordinal)) + throw new ResourceConflictException("The revision id is already used by different content."); + } + } + return new(revisionId, entryId, 0, fileName, "application/octet-stream", length, sha256, + relative, sourceType, sourceId ?? actor, DateTimeOffset.UtcNow); + } + finally + { + if (File.Exists(temporary)) File.Delete(temporary); + } + } + + public async Task<StoredGameDataRevision?> OpenAsync(GameDataRevision revision, CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + var path = ContainedPath(revision.Location); + if (!File.Exists(path)) return null; + var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 64 * 1024, + FileOptions.Asynchronous | FileOptions.SequentialScan); + try + { + if (stream.Length != revision.Length) + throw new GameDataIntegrityException("The stored game-data length no longer matches its revision evidence."); + var hash = Convert.ToHexString(await SHA256.HashDataAsync(stream, ct)).ToLowerInvariant(); + if (!hash.Equals(revision.Sha256, StringComparison.Ordinal)) + throw new GameDataIntegrityException("The stored game-data checksum no longer matches its revision evidence."); + stream.Position = 0; + return new(stream, stream.Length); + } + catch + { + await stream.DisposeAsync(); + throw; + } + } + + public bool Delete(GameDataRevision revision) + { + var path = ContainedPath(revision.Location); + if (!File.Exists(path)) return false; + File.Delete(path); + RemoveEmptyParents(Path.GetDirectoryName(path)); + return true; + } + + public int Delete(IEnumerable<GameDataRevision> revisions) + { + var count = 0; + foreach (var revision in revisions) + if (Delete(revision)) count++; + return count; + } + + public GameDataReconciliationResult Reconcile(IReadOnlySet<string> knownLocations) + { + var normalizedKnown = new HashSet<string>(knownLocations.Select(NormalizeRelative), + OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal); + var uploads = 0; + var orphans = 0; + long bytes = 0; + var options = new EnumerationOptions + { + RecurseSubdirectories = true, + IgnoreInaccessible = false, + AttributesToSkip = FileAttributes.ReparsePoint, + ReturnSpecialDirectories = false + }; + foreach (var path in Directory.EnumerateFiles(root, "*", options).ToArray()) + { + var relative = NormalizeRelative(Path.GetRelativePath(root, path)); + var upload = relative.EndsWith(".uploading", StringComparison.OrdinalIgnoreCase); + var orphan = relative.EndsWith(".bin", StringComparison.OrdinalIgnoreCase) && + !normalizedKnown.Contains(relative); + if (!upload && !orphan) continue; + var length = new FileInfo(path).Length; + File.Delete(path); + RemoveEmptyParents(Path.GetDirectoryName(path)); + bytes += length; + if (upload) uploads++; else orphans++; + } + return new(uploads, orphans, bytes); + } + + private void RemoveEmptyParents(string? directory) + { + while (directory is not null && !directory.Equals(root, StringComparison.OrdinalIgnoreCase) && + Directory.Exists(directory) && !Directory.EnumerateFileSystemEntries(directory).Any()) + { + Directory.Delete(directory); + directory = Path.GetDirectoryName(directory); + } + } + + private string ContainedPath(string relative) + { + if (Path.IsPathRooted(relative)) + throw new GameDataVaultValidationException("Game-data location must be app-relative."); + var path = Path.GetFullPath(Path.Combine(root, relative.Replace('/', Path.DirectorySeparatorChar))); + if (!path.StartsWith(root + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) + throw new GameDataVaultValidationException("Game-data location escapes the app-owned vault."); + return path; + } + + private static string NormalizeRelative(string value) => value.Replace('\\', '/').TrimStart('/'); + + private static void RejectExecutableContent(ReadOnlySpan<byte> header) + { + if (header.StartsWith("MZ"u8) || header.StartsWith("#!"u8) || + (header.Length >= 4 && header[..4].SequenceEqual(new byte[] { 0x7F, 0x45, 0x4C, 0x46 })) || + IsMachO(header)) + throw new GameDataVaultValidationException("Executable and script content is not accepted by the game-data vault."); + } + + private static bool IsMachO(ReadOnlySpan<byte> header) + { + if (header.Length < 4) return false; + var magic = header[..4]; + return magic.SequenceEqual(new byte[] { 0xFE, 0xED, 0xFA, 0xCE }) || + magic.SequenceEqual(new byte[] { 0xFE, 0xED, 0xFA, 0xCF }) || + magic.SequenceEqual(new byte[] { 0xCE, 0xFA, 0xED, 0xFE }) || + magic.SequenceEqual(new byte[] { 0xCF, 0xFA, 0xED, 0xFE }) || + magic.SequenceEqual(new byte[] { 0xCA, 0xFE, 0xBA, 0xBE }); + } + + private static async Task<(long Length, string Sha256)> HashExistingAsync(string path, CancellationToken ct) + { + for (var attempt = 0; ; attempt++) + { + try + { + await using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, + 64 * 1024, FileOptions.Asynchronous | FileOptions.SequentialScan); + var hash = await SHA256.HashDataAsync(stream, ct); + return (stream.Length, Convert.ToHexString(hash).ToLowerInvariant()); + } + catch (IOException) when (attempt < 4) + { + // Windows can retain the atomic move's exclusive handle for a few milliseconds. + // Retry only the immutable destination read; uploads and source libraries are untouched. + await Task.Delay(TimeSpan.FromMilliseconds(10 * (attempt + 1)), ct); + } + } + } +} + +public sealed class GameDataVaultValidationException(string message) : Exception(message); +public sealed class GameDataIntegrityException(string message) : Exception(message); diff --git a/src/Ludarium.Infrastructure/GameMediaStore.cs b/src/Ludarium.Infrastructure/GameMediaStore.cs new file mode 100644 index 0000000..34b2f7e --- /dev/null +++ b/src/Ludarium.Infrastructure/GameMediaStore.cs @@ -0,0 +1,131 @@ +using System.Security.Cryptography; +using Ludarium.Application; +using Ludarium.Domain; + +namespace Ludarium.Infrastructure; + +public sealed record StoredGameMedia(Stream Content, string ContentType, long Length); + +public sealed class GameMediaStore +{ + public const long MaximumBytes = 32L * 1024 * 1024; + private readonly string root; + + public GameMediaStore(string root) + { + this.root = Path.GetFullPath(root); + Directory.CreateDirectory(this.root); + } + + public async Task<GameMedia> SaveAsync(Guid gameId, Guid mediaId, GameMediaKind kind, string title, + Stream input, long? declaredLength, CancellationToken cancellationToken) + { + if (declaredLength is > MaximumBytes) + throw new MediaValidationException("Media exceeds the 32 MiB upload limit."); + if (kind is not (GameMediaKind.Screenshot or GameMediaKind.Manual or GameMediaKind.Background or GameMediaKind.Logo)) + throw new MediaValidationException("Local uploads are limited to screenshots, manuals, backgrounds and logos."); + + var directory = ContainedPath(gameId.ToString("N")); + Directory.CreateDirectory(directory); + var temporary = ContainedPath($"{gameId:N}/{mediaId:N}.uploading"); + try + { + using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); + var header = new byte[12]; + var headerLength = 0; + long length = 0; + await using (var output = new FileStream(temporary, FileMode.CreateNew, FileAccess.Write, + FileShare.None, 64 * 1024, FileOptions.Asynchronous | FileOptions.SequentialScan)) + { + var buffer = new byte[64 * 1024]; + while (true) + { + var read = await input.ReadAsync(buffer, cancellationToken); + if (read == 0) break; + length += read; + if (length > MaximumBytes) + throw new MediaValidationException("Media exceeds the 32 MiB upload limit."); + var take = Math.Min(header.Length - headerLength, read); + if (take > 0) + { + buffer.AsSpan(0, take).CopyTo(header.AsSpan(headerLength)); + headerLength += take; + } + hash.AppendData(buffer, 0, read); + await output.WriteAsync(buffer.AsMemory(0, read), cancellationToken); + } + await output.FlushAsync(cancellationToken); + } + if (length == 0) throw new MediaValidationException("Media is empty."); + var (contentType, extension) = Detect(header.AsSpan(0, headerLength)); + ValidateKind(kind, contentType); + var relative = $"{gameId:N}/{mediaId:N}{extension}"; + var destination = ContainedPath(relative); + File.Move(temporary, destination, false); + var now = DateTimeOffset.UtcNow; + return new(mediaId, gameId, kind, LibraryExperiencePolicy.CleanTitle(title, "Media title"), + "Upload", "Manual", null, relative.Replace('\\', '/'), contentType, length, + Convert.ToHexString(hash.GetHashAndReset()).ToLowerInvariant(), true, false, now); + } + finally + { + if (File.Exists(temporary)) File.Delete(temporary); + } + } + + public Task<StoredGameMedia?> OpenAsync(GameMedia media, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + if (!media.AppOwned || string.IsNullOrWhiteSpace(media.Location) || string.IsNullOrWhiteSpace(media.ContentType)) + return Task.FromResult<StoredGameMedia?>(null); + var path = ContainedPath(media.Location); + if (!File.Exists(path)) return Task.FromResult<StoredGameMedia?>(null); + var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 64 * 1024, + FileOptions.Asynchronous | FileOptions.SequentialScan); + return Task.FromResult<StoredGameMedia?>(new(stream, media.ContentType, stream.Length)); + } + + public bool Delete(GameMedia media) + { + if (!media.AppOwned || string.IsNullOrWhiteSpace(media.Location)) return false; + var path = ContainedPath(media.Location); + if (!File.Exists(path)) return false; + File.Delete(path); + var directory = Path.GetDirectoryName(path); + if (directory is not null && Directory.Exists(directory) && !Directory.EnumerateFileSystemEntries(directory).Any()) + Directory.Delete(directory); + return true; + } + + private string ContainedPath(string relative) + { + if (Path.IsPathRooted(relative)) throw new MediaValidationException("Media location must be app-relative."); + var path = Path.GetFullPath(Path.Combine(root, relative.Replace('/', Path.DirectorySeparatorChar))); + if (!path.StartsWith(root + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) + throw new MediaValidationException("Media location escapes the app-owned media directory."); + return path; + } + + private static (string ContentType, string Extension) Detect(ReadOnlySpan<byte> header) + { + if (header.Length >= 8 && header[..8].SequenceEqual(new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A })) + return ("image/png", ".png"); + if (header.Length >= 3 && header[0] == 0xFF && header[1] == 0xD8 && header[2] == 0xFF) + return ("image/jpeg", ".jpg"); + if (header.Length >= 12 && header[..4].SequenceEqual("RIFF"u8) && header[8..12].SequenceEqual("WEBP"u8)) + return ("image/webp", ".webp"); + if (header.Length >= 5 && header[..5].SequenceEqual("%PDF-"u8)) + return ("application/pdf", ".pdf"); + throw new MediaValidationException("Use a valid JPEG, PNG, WebP or PDF file."); + } + + private static void ValidateKind(GameMediaKind kind, string contentType) + { + if (kind == GameMediaKind.Manual && contentType != "application/pdf") + throw new MediaValidationException("Manual uploads must be PDF files."); + if (kind != GameMediaKind.Manual && !contentType.StartsWith("image/", StringComparison.Ordinal)) + throw new MediaValidationException("This media role requires a JPEG, PNG or WebP image."); + } +} + +public sealed class MediaValidationException(string message) : Exception(message); diff --git a/src/Ludarium.Infrastructure/GameMetadataEnricher.cs b/src/Ludarium.Infrastructure/GameMetadataEnricher.cs new file mode 100644 index 0000000..c409eeb --- /dev/null +++ b/src/Ludarium.Infrastructure/GameMetadataEnricher.cs @@ -0,0 +1,202 @@ +using System.IO.Compression; +using System.Security.Cryptography; +using System.Text; +using System.Xml; +using Ludarium.Application; +using Ludarium.Domain; + +namespace Ludarium.Infrastructure; + +public sealed record MetadataEnrichmentResult(int Games, int Matched, int ClaimsAdded, int Unmatched, + ProviderHubResult? ProviderHub = null, string? LaunchBoxError = null); + +public sealed class GameMetadataEnricher(ILudariumStore store, GameArtworkStore artworkStore, IGameProviderHub providerHub) +{ + private static readonly HttpClient Client = new() { Timeout = TimeSpan.FromSeconds(30) }; + private static readonly Guid SnapshotId = Guid.Parse("9871a5d1-5c0c-4ad0-8e59-6c94339073cb"); + private const long MaximumArchiveBytes = 128L * 1024 * 1024; + + public async Task<MetadataEnrichmentResult> EnrichAsync(IReadOnlyList<Game> games, IReadOnlyList<Release> releases, + CancellationToken ct, Func<int, int, Task>? progress = null) + { + MetadataEnrichmentResult launchBox; + try + { + launchBox = await EnrichLaunchBoxAsync(games, releases, + progress is null ? null : (completed, total) => progress(completed, checked(total * 2)), ct); + } + catch (Exception exception) when (exception is not OperationCanceledException and not OutOfMemoryException) + { + launchBox = new(games.Count, 0, 0, games.Count, LaunchBoxError: exception.Message); + } + var providerResult = await providerHub.EnrichAsync(games, releases, ct, + progress is null ? null : (completed, total) => progress(checked(total + completed), checked(total * 2))); + return launchBox with { ProviderHub = providerResult }; + } + + private async Task<MetadataEnrichmentResult> EnrichLaunchBoxAsync(IReadOnlyList<Game> games, IReadOnlyList<Release> releases, + Func<int, int, Task>? progress, CancellationToken ct) + { + var directory = Path.Combine(Path.GetDirectoryName(artworkStore.DirectoryPath)!, "catalogs"); + Directory.CreateDirectory(directory); + var path = Path.Combine(directory, "launchbox-metadata.zip"); + string? etag = null; + string? refreshError = null; + try + { + if (!File.Exists(path) || new FileInfo(path).Length is < 1_000_000 or > MaximumArchiveBytes || File.GetLastWriteTimeUtc(path) < DateTime.UtcNow.AddDays(-7)) + { + try { etag = await DownloadAsync(path, ct); } + catch (Exception exception) when (exception is not OperationCanceledException && IsUsableSnapshot(path)) + { + // A failed refresh must not discard the last-known-good, app-owned snapshot. + refreshError = exception.Message; + } + } + var hash = await HashAsync(path, ct); + var matched = await ImportAsync(path, games, releases, progress, ct); + var now = DateTimeOffset.UtcNow; + await store.UpsertProviderSnapshotAsync(new(SnapshotId, "LaunchBox", hash[..12], + refreshError is null ? "Ready" : "Degraded", etag, hash, + now, refreshError is null ? now.AddDays(7) : now, now, refreshError), ct); + return matched; + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + var existing = (await store.ListProviderSnapshotsAsync(ct)).FirstOrDefault(item => item.Provider == "LaunchBox"); + var now = DateTimeOffset.UtcNow; + await store.UpsertProviderSnapshotAsync(existing is null + ? new(SnapshotId, "LaunchBox", "unavailable", "Offline", etag, new string('0', 64), now, now, null, exception.Message) + : existing with { State = "Degraded", Error = exception.Message, ExpiresAt = now }, ct); + throw; + } + } + + private static bool IsUsableSnapshot(string path) + { + try + { + var info = new FileInfo(path); + return info.Exists && info.Length is >= 1_000_000 and <= MaximumArchiveBytes; + } + catch (IOException) { return false; } + catch (UnauthorizedAccessException) { return false; } + } + + private async Task<MetadataEnrichmentResult> ImportAsync(string path, IReadOnlyList<Game> games, + IReadOnlyList<Release> releases, Func<int, int, Task>? progress, CancellationToken ct) + { + var platforms = releases.GroupBy(item => item.GameId).ToDictionary(group => group.Key, + group => group.Select(item => LaunchBoxPlatform(item.Platform)).Where(item => item is not null).ToHashSet(StringComparer.OrdinalIgnoreCase)); + var wanted = games.GroupBy(game => GameArtworkEnricher.Normalize(game.Title)).ToDictionary(group => group.Key, group => group.ToArray()); + var matchedIds = new HashSet<Guid>(); + var claims = 0; + using var archive = ZipFile.OpenRead(path); + var entry = archive.GetEntry("Metadata.xml") ?? throw new InvalidDataException("LaunchBox metadata entry is missing."); + if (entry.Length > 650L * 1024 * 1024 || entry.CompressedLength == 0 || entry.Length / entry.CompressedLength > 20) + throw new InvalidDataException("LaunchBox metadata exceeds its decompression safety limits."); + await using var input = entry.Open(); + using var reader = XmlReader.Create(input, new XmlReaderSettings { Async = true, DtdProcessing = DtdProcessing.Prohibit, XmlResolver = null }); + while (await reader.ReadAsync()) + { + ct.ThrowIfCancellationRequested(); + if (reader.NodeType != XmlNodeType.Element || reader.Name != "Game") continue; + using var subtree = reader.ReadSubtree(); + var values = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); + while (subtree.Read()) + { + if (subtree.NodeType != XmlNodeType.Element || !SupportedElement(subtree.Name)) continue; + values[subtree.Name] = subtree.ReadElementContentAsString(); + } + if (!values.TryGetValue("Name", out var name) || !values.TryGetValue("Platform", out var platform) || + !wanted.TryGetValue(GameArtworkEnricher.Normalize(name), out var candidates)) continue; + var game = candidates.FirstOrDefault(candidate => platforms.GetValueOrDefault(candidate.Id)?.Contains(platform) == true); + if (game is null || !matchedIds.Add(game.Id)) continue; + foreach (var mapping in FieldMappings) + { + if (!values.TryGetValue(mapping.Key, out var value) || string.IsNullOrWhiteSpace(value)) continue; + var externalId = values.GetValueOrDefault("DatabaseID") ?? name; + var claimId = DeterministicGuid($"launchbox|{externalId}|{game.Id:N}|{mapping.Value}"); + await store.AddClaimAsync(new(claimId, "Game", game.Id, mapping.Value, value.Trim(), "ProviderSnapshot", + $"LaunchBox:{externalId}", Confidence.High, + [new Evidence("metadata.provider", mapping.Key, "LaunchBox", "1", Confidence.High, DateTimeOffset.UtcNow)], + false, DateTimeOffset.UtcNow), ct); + claims++; + } + if (progress is not null) await progress(matchedIds.Count, games.Count); + } + if (progress is not null) await progress(games.Count, games.Count); + return new(games.Count, matchedIds.Count, claims, games.Count - matchedIds.Count); + } + + private static readonly Dictionary<string, string> FieldMappings = new() + { + ["Name"] = "officialTitle", + ["AlternateNames"] = "alternateTitles", + ["Overview"] = "description", + ["ReleaseDate"] = "releaseDate", + ["Developer"] = "developer", + ["Publisher"] = "publisher", + ["Genres"] = "genre", + ["MaxPlayers"] = "players", + ["Region"] = "region", + ["Series"] = "franchise", + ["Version"] = "edition" + }; + + private static bool SupportedElement(string name) => name == "DatabaseID" || name == "Platform" || FieldMappings.ContainsKey(name); + private static string? LaunchBoxPlatform(string? platform) => platform switch + { + "windows" => "Windows", + "ps5" => "Sony Playstation 5", + "ps4" => "Sony Playstation 4", + "psvita" => "Sony Playstation Vita", + "psp" => "Sony PSP", + "ps2" => "Sony Playstation 2", + "psx" => "Sony Playstation", + "switch" => "Nintendo Switch", + "wii" => "Nintendo Wii", + "3ds" => "Nintendo 3DS", + "nds" => "Nintendo DS", + "n64" => "Nintendo 64", + "gba" => "Nintendo Game Boy Advance", + "gbc" => "Nintendo Game Boy Color", + "gb" => "Nintendo Game Boy", + "snes" => "Super Nintendo Entertainment System", + "nes" => "Nintendo Entertainment System", + _ => null + }; + + private static async Task<string?> DownloadAsync(string destination, CancellationToken ct) + { + var temporary = destination + ".metadata-" + Guid.NewGuid().ToString("N"); + try + { + using var request = new HttpRequestMessage(HttpMethod.Get, "https://gamesdb.launchbox-app.com/Metadata.zip"); + request.Headers.UserAgent.ParseAdd(ReleaseIdentity.UserAgent); + using var response = await Client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct); + response.EnsureSuccessStatusCode(); + if (response.Content.Headers.ContentLength > MaximumArchiveBytes) throw new InvalidDataException("Metadata catalog exceeds 128 MiB."); + await using var input = await response.Content.ReadAsStreamAsync(ct); + await using var output = new FileStream(temporary, FileMode.CreateNew, FileAccess.Write, FileShare.None, 64 * 1024, FileOptions.Asynchronous); + var buffer = new byte[64 * 1024]; + while (true) + { + var read = await input.ReadAsync(buffer, ct); if (read == 0) break; + if (output.Length + read > MaximumArchiveBytes) throw new InvalidDataException("Metadata catalog exceeds 128 MiB."); + await output.WriteAsync(buffer.AsMemory(0, read), ct); + } + await output.FlushAsync(ct); File.Move(temporary, destination, true); + return response.Headers.ETag?.Tag; + } + finally { if (File.Exists(temporary)) File.Delete(temporary); } + } + + private static async Task<string> HashAsync(string path, CancellationToken ct) + { + await using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 64 * 1024, FileOptions.Asynchronous | FileOptions.SequentialScan); + return Convert.ToHexString(await SHA256.HashDataAsync(stream, ct)).ToLowerInvariant(); + } + + private static Guid DeterministicGuid(string value) => new(SHA256.HashData(Encoding.UTF8.GetBytes(value))[..16]); +} diff --git a/src/Ludarium.Infrastructure/GameProviderHub.cs b/src/Ludarium.Infrastructure/GameProviderHub.cs new file mode 100644 index 0000000..8150623 --- /dev/null +++ b/src/Ludarium.Infrastructure/GameProviderHub.cs @@ -0,0 +1,226 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Ludarium.Application; +using Ludarium.Domain; + +namespace Ludarium.Infrastructure; + +public sealed class GameProviderHub( + IEnumerable<IGameMetadataProvider> providers, + ILudariumStore store, + ILibraryExperienceStore experienceStore, + GameArtworkStore artworkStore) : IGameProviderHub +{ + private static readonly HttpClient ArtworkClient = CreateArtworkClient(); + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + private readonly IReadOnlyList<IGameMetadataProvider> providers = providers.ToArray(); + + public IReadOnlyList<ProviderDescriptor> DescribeProviders() => providers.Select(item => item.Describe()).ToArray(); + + public async Task<ProviderValidationResult> ValidateAsync(string providerId, CancellationToken cancellationToken) + { + var provider = providers.SingleOrDefault(item => item.Describe().Id.Equals(providerId, StringComparison.OrdinalIgnoreCase)) + ?? throw new KeyNotFoundException("Metadata provider not found."); + var descriptor = provider.Describe(); + var now = DateTimeOffset.UtcNow; + if (!descriptor.Configured) + return new(descriptor.Id, descriptor.Name, "NotConfigured", false, false, false, + descriptor.Message ?? "Provider credentials are not configured.", now); + + try + { + var game = new Game(DeterministicGuid("provider-validation|sonic-the-hedgehog"), + "Sonic the Hedgehog", DateTimeOffset.UnixEpoch, Origin: GameOrigin.Manual); + var release = new Release(DeterministicGuid("provider-validation|sonic-the-hedgehog|genesis"), + game.Id, game.Title, "genesis", "world", null, DateTimeOffset.UnixEpoch, Origin: ReleaseOrigin.Manual); + var result = await provider.EnrichAsync(game, release, cancellationToken); + var matched = result is not null; + var message = matched + ? "Credentials, endpoint and deterministic validation lookup succeeded." + : "Credentials and endpoint succeeded, but the deterministic validation title was not returned."; + await SaveValidationSnapshotAsync(descriptor, matched ? "Ready" : "Degraded", message, + matched ? now : null, now, cancellationToken); + return new(descriptor.Id, descriptor.Name, matched ? "Ready" : "Degraded", true, true, matched, + message, now); + } + catch (Exception exception) when (!cancellationToken.IsCancellationRequested && exception is not OutOfMemoryException) + { + var message = $"Live validation failed: {ProviderFailureMessage(exception)}"; + await SaveValidationSnapshotAsync(descriptor, "Offline", message, null, now, cancellationToken); + return new(descriptor.Id, descriptor.Name, "Offline", true, false, false, message, now); + } + } + + public async Task<ProviderHubResult> EnrichAsync(IReadOnlyList<Game> games, IReadOnlyList<Release> releases, + CancellationToken cancellationToken, Func<int, int, Task>? progress = null) + { + var configured = providers.Where(item => item.Describe().Configured).ToArray(); + var byGame = releases.GroupBy(item => item.GameId).ToDictionary(group => group.Key, group => group.First()); + var matched = 0; + var claimsAdded = 0; + var artworkCandidates = 0; + var achievementSnapshots = 0; + var reviewItems = 0; + var failed = 0; + var providerSuccess = configured.ToDictionary(item => item.Describe().Id, _ => 0, StringComparer.Ordinal); + var providerFailure = configured.ToDictionary(item => item.Describe().Id, _ => 0, StringComparer.Ordinal); + + for (var index = 0; index < games.Count; index++) + { + cancellationToken.ThrowIfCancellationRequested(); + var game = games[index]; + var gameMatched = false; + foreach (var provider in configured) + { + var descriptor = provider.Describe(); + try + { + var result = await provider.EnrichAsync(game, byGame.GetValueOrDefault(game.Id), cancellationToken); + providerSuccess[descriptor.Id]++; + if (result is null) continue; + gameMatched = true; + var existing = await store.ListClaimsAsync("Game", game.Id, cancellationToken); + foreach (var field in result.Fields) + { + var value = field.Value.Trim(); + if (value.Length == 0) continue; + var conflicting = existing.Where(item => item.Field.Equals(field.Field, StringComparison.OrdinalIgnoreCase) + && !item.Value.Equals(value, StringComparison.OrdinalIgnoreCase)).ToArray(); + var claim = new MetadataClaim( + DeterministicGuid($"claim|{field.Provider}|{field.ExternalId}|{game.Id:N}|{field.Field}|{value}"), + "Game", game.Id, field.Field, value, "ProviderApi", $"{field.Provider}:{field.ExternalId}", + field.Confidence, + [new Evidence("metadata.provider-api", field.Field, field.Provider, field.SourceVersion, + field.Confidence, DateTimeOffset.UtcNow)], false, DateTimeOffset.UtcNow); + await store.AddClaimAsync(claim, cancellationToken); + claimsAdded++; + existing = existing.Append(claim).ToArray(); + if (conflicting.Any(item => item.ManualLock || item.Confidence >= Confidence.High)) + { + await AddConflictReviewAsync(game, field, conflicting, cancellationToken); + reviewItems++; + } + } + + foreach (var candidate in result.Artwork.Take(2)) + { + if (!AllowedArtworkUri(candidate, out var uri)) continue; + try + { + using var response = await ArtworkClient.GetAsync(uri, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + response.EnsureSuccessStatusCode(); + if (response.Content.Headers.ContentLength is > GameArtworkStore.MaximumBytes) continue; + await using var input = await response.Content.ReadAsStreamAsync(cancellationToken); + await artworkStore.SaveAsync(game.Id, input, response.Content.Headers.ContentLength, + cancellationToken, candidate.Provider); + var artwork = await artworkStore.DescribeAsync(game.Id, candidate.Provider, + "provider-exact-title-box-front", candidate.Confidence, cancellationToken, + result.Platform); + if (artwork is null) continue; + artwork = artwork with { ExternalId = candidate.ExternalId }; + await store.UpsertGameArtworkAsync(artwork, cancellationToken); + artworkCandidates++; + } + catch (Exception exception) when (exception is not OperationCanceledException and not OutOfMemoryException) + { + providerFailure[descriptor.Id]++; + } + } + + if (result.AchievementProgress is { } achievements) + { + await experienceStore.UpsertAchievementProgressAsync(achievements with { GameId = game.Id }, cancellationToken); + achievementSnapshots++; + } + } + catch (Exception exception) when (exception is not OperationCanceledException and not OutOfMemoryException) + { + providerFailure[descriptor.Id]++; + failed++; + } + } + if (gameMatched) matched++; + if (progress is not null) await progress(index + 1, games.Count); + } + + foreach (var provider in configured) + { + var descriptor = provider.Describe(); + var now = DateTimeOffset.UtcNow; + var failures = providerFailure[descriptor.Id]; + await store.UpsertProviderSnapshotAsync(new( + DeterministicGuid($"provider|{descriptor.Id}"), descriptor.Name, "api-v1", + failures == 0 ? "Ready" : "Degraded", null, new string('0', 64), now, + now.AddHours(12), providerSuccess[descriptor.Id] > 0 ? now : null, + failures == 0 ? null : $"{failures} request(s) failed; successful matches were preserved."), cancellationToken); + } + + return new(games.Count, configured.Length, matched, claimsAdded, artworkCandidates, + achievementSnapshots, reviewItems, failed); + } + + private async Task AddConflictReviewAsync(Game game, MetadataFieldCandidate field, + IReadOnlyList<MetadataClaim> conflicting, CancellationToken cancellationToken) + { + var payload = JsonSerializer.Serialize(new + { + gameId = game.Id, + game.Title, + field = field.Field, + candidate = field.Value, + provider = field.Provider, + existing = conflicting.Select(item => new { item.Value, item.SourceId, item.Confidence, item.ManualLock }) + }, JsonOptions); + var id = DeterministicGuid($"provider-conflict|{game.Id:N}|{field.Field}|{field.Provider}|{field.ExternalId}"); + await store.AddReviewAsync(new(id, "Provider metadata conflicts with an existing high-confidence or manual value.", + Severity.Warning, ReviewState.Open, payload, DateTimeOffset.UtcNow), cancellationToken); + } + + private Task SaveValidationSnapshotAsync(ProviderDescriptor descriptor, string state, string message, + DateTimeOffset? successfulAt, DateTimeOffset now, CancellationToken cancellationToken) => + store.UpsertProviderSnapshotAsync(new( + DeterministicGuid($"provider|{descriptor.Id}"), descriptor.Name, "api-v1", state, null, + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes($"{descriptor.Id}|{state}|{now:O}"))).ToLowerInvariant(), + now, now.AddHours(12), successfulAt, message), cancellationToken); + + private static string ProviderFailureMessage(Exception exception) => exception switch + { + HttpRequestException { StatusCode: { } status } => $"provider returned HTTP {(int)status}", + HttpRequestException => "provider endpoint could not be reached", + TaskCanceledException => "provider request timed out", + InvalidDataException => "provider returned an invalid response", + JsonException => "provider returned invalid JSON", + _ => "provider request could not be completed" + }; + + private static bool AllowedArtworkUri(ProviderArtworkCandidate candidate, out Uri uri) + { + if (!Uri.TryCreate(candidate.Url, UriKind.Absolute, out uri!) || uri.Scheme != Uri.UriSchemeHttps) return false; + var allowed = candidate.Provider.ToLowerInvariant() switch + { + "igdb" => uri.Host.Equals("images.igdb.com", StringComparison.OrdinalIgnoreCase), + "mobygames" => IsHostOrSubdomain(uri.Host, "mobygames.com"), + "screenscraper" => IsHostOrSubdomain(uri.Host, "screenscraper.fr"), + "retroachievements" => IsHostOrSubdomain(uri.Host, "retroachievements.org"), + _ => false + }; + return allowed; + } + + private static bool IsHostOrSubdomain(string host, string expected) => + host.Equals(expected, StringComparison.OrdinalIgnoreCase) || + host.EndsWith('.' + expected, StringComparison.OrdinalIgnoreCase); + + private static HttpClient CreateArtworkClient() + { + var client = new HttpClient(new HttpClientHandler { AllowAutoRedirect = false }) + { + Timeout = TimeSpan.FromSeconds(20) + }; + client.DefaultRequestHeaders.UserAgent.ParseAdd(ReleaseIdentity.UserAgent); + return client; + } + + internal static Guid DeterministicGuid(string value) => new(SHA256.HashData(Encoding.UTF8.GetBytes(value))[..16]); +} diff --git a/src/Ludarium.Infrastructure/HttpMetadataProviders.cs b/src/Ludarium.Infrastructure/HttpMetadataProviders.cs new file mode 100644 index 0000000..55287d1 --- /dev/null +++ b/src/Ludarium.Infrastructure/HttpMetadataProviders.cs @@ -0,0 +1,507 @@ +using System.Collections.Concurrent; +using System.Globalization; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using System.Xml; +using Ludarium.Application; +using Ludarium.Domain; + +namespace Ludarium.Infrastructure; + +public sealed class IgdbMetadataProvider(HttpClient client, string? clientId, string? clientSecret) : IGameMetadataProvider +{ + private static readonly SemaphoreSlim TokenLock = new(1, 1); + private string? accessToken; + private DateTimeOffset tokenExpiresAt; + + public ProviderDescriptor Describe() + { + var configured = !string.IsNullOrWhiteSpace(clientId) && !string.IsNullOrWhiteSpace(clientSecret); + return new("igdb", "IGDB", configured, configured ? "Ready" : "Not configured", + ["metadata", "artwork"], true, "https://api-docs.igdb.com/", + configured ? null : "Set IGDB_CLIENT_ID and IGDB_CLIENT_SECRET to enable this provider."); + } + + public async Task<ProviderGameResult?> EnrichAsync(Game game, Release? release, CancellationToken cancellationToken) + { + if (!Describe().Configured) return null; + var token = await GetTokenAsync(cancellationToken); + using var request = new HttpRequestMessage(HttpMethod.Post, "https://api.igdb.com/v4/games"); + request.Headers.Add("Client-ID", clientId); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + var title = game.Title.Replace("\\", "\\\\", StringComparison.Ordinal).Replace("\"", "\\\"", StringComparison.Ordinal); + request.Content = new StringContent($"search \"{title}\"; fields id,name,summary,first_release_date,genres.name,involved_companies.company.name,involved_companies.developer,involved_companies.publisher,franchises.name,cover.image_id,platforms.slug,platforms.name; limit 8;", Encoding.UTF8, "text/plain"); + using var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + response.EnsureSuccessStatusCode(); + var json = await ProviderHttp.ReadTextAsync(response.Content, 2 * 1024 * 1024, cancellationToken); + return Parse(json, game, release); + } + + internal static ProviderGameResult? Parse(string json, Game game, Release? release) + { + using var document = JsonDocument.Parse(json); + if (document.RootElement.ValueKind != JsonValueKind.Array) return null; + var exact = document.RootElement.EnumerateArray() + .Where(item => ProviderText.Normalize(ProviderJson.String(item, "name")) == ProviderText.Normalize(game.Title)) + .ToArray(); + if (exact.Length == 0) return null; + var platform = ProviderText.NormalizePlatform(release?.Platform); + var matches = platform is null ? exact : exact.Where(item => ProviderJson.Array(item, "platforms") + .Any(value => ProviderText.PlatformMatches(platform, ProviderJson.String(value, "slug"), ProviderJson.String(value, "name")))).ToArray(); + var selected = matches.Length == 1 ? matches[0] : exact.Length == 1 ? exact[0] : default; + if (selected.ValueKind == JsonValueKind.Undefined) return null; + var externalId = ProviderJson.Int(selected, "id")?.ToString(CultureInfo.InvariantCulture) ?? game.Title; + var fields = new List<MetadataFieldCandidate>(); + Add(fields, "officialTitle", ProviderJson.String(selected, "name"), externalId); + Add(fields, "description", ProviderJson.String(selected, "summary"), externalId); + if (ProviderJson.Long(selected, "first_release_date") is { } timestamp) + Add(fields, "releaseDate", DateTimeOffset.FromUnixTimeSeconds(timestamp).ToString("yyyy-MM-dd", CultureInfo.InvariantCulture), externalId); + Add(fields, "genre", JoinNames(selected, "genres"), externalId); + Add(fields, "franchise", JoinNames(selected, "franchises"), externalId); + var companies = ProviderJson.Array(selected, "involved_companies"); + Add(fields, "developer", JoinCompanies(companies, "developer"), externalId); + Add(fields, "publisher", JoinCompanies(companies, "publisher"), externalId); + var artwork = new List<ProviderArtworkCandidate>(); + if (ProviderJson.Object(selected, "cover") is { } cover && ProviderJson.String(cover, "image_id") is { Length: > 0 } imageId) + artwork.Add(new($"https://images.igdb.com/igdb/image/upload/t_cover_big_2x/{Uri.EscapeDataString(imageId)}.jpg", + "box-front", "IGDB", externalId, Confidence.High)); + return new("IGDB", externalId, ProviderJson.String(selected, "name") ?? game.Title, + release?.Platform, fields, artwork); + } + + private async Task<string> GetTokenAsync(CancellationToken cancellationToken) + { + if (accessToken is not null && tokenExpiresAt > DateTimeOffset.UtcNow.AddMinutes(2)) return accessToken; + await TokenLock.WaitAsync(cancellationToken); + try + { + if (accessToken is not null && tokenExpiresAt > DateTimeOffset.UtcNow.AddMinutes(2)) return accessToken; + var uri = $"https://id.twitch.tv/oauth2/token?client_id={Uri.EscapeDataString(clientId!)}&client_secret={Uri.EscapeDataString(clientSecret!)}&grant_type=client_credentials"; + using var response = await client.PostAsync(uri, new ByteArrayContent([]), cancellationToken); + response.EnsureSuccessStatusCode(); + var json = await ProviderHttp.ReadTextAsync(response.Content, 64 * 1024, cancellationToken); + using var document = JsonDocument.Parse(json); + accessToken = ProviderJson.String(document.RootElement, "access_token") + ?? throw new InvalidDataException("IGDB token response did not contain an access token."); + var lifetime = ProviderJson.Int(document.RootElement, "expires_in") ?? 3600; + tokenExpiresAt = DateTimeOffset.UtcNow.AddSeconds(Math.Max(60, lifetime)); + return accessToken; + } + finally { TokenLock.Release(); } + } + + private static string? JoinNames(JsonElement item, string property) => Join(ProviderJson.Array(item, property) + .Select(value => ProviderJson.String(value, "name"))); + private static string? JoinCompanies(IEnumerable<JsonElement> items, string role) => Join(items + .Where(item => ProviderJson.Bool(item, role) == true) + .Select(item => ProviderJson.Object(item, "company") is { } company ? ProviderJson.String(company, "name") : null)); + private static string? Join(IEnumerable<string?> values) + { + var result = string.Join(", ", values.Where(value => !string.IsNullOrWhiteSpace(value)).Distinct(StringComparer.OrdinalIgnoreCase)); + return result.Length == 0 ? null : result; + } + private static void Add(List<MetadataFieldCandidate> fields, string field, string? value, string externalId) + { + if (!string.IsNullOrWhiteSpace(value)) fields.Add(new(field, value.Trim(), "IGDB", externalId, Confidence.High, "v4")); + } +} + +public sealed class MobyGamesMetadataProvider(HttpClient client, string? apiKey) : IGameMetadataProvider +{ + public ProviderDescriptor Describe() + { + var configured = !string.IsNullOrWhiteSpace(apiKey); + return new("mobygames", "MobyGames", configured, configured ? "Ready" : "Not configured", + ["metadata", "artwork"], true, "https://www.mobygames.com/info/api/", + configured ? null : "Set MOBYGAMES_API_KEY to enable this provider."); + } + + public async Task<ProviderGameResult?> EnrichAsync(Game game, Release? release, CancellationToken cancellationToken) + { + if (!Describe().Configured) return null; + var uri = $"https://api.mobygames.com/v1/games?api_key={Uri.EscapeDataString(apiKey!)}&title={Uri.EscapeDataString(game.Title)}&format=normal&limit=10"; + using var response = await client.GetAsync(uri, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + response.EnsureSuccessStatusCode(); + var json = await ProviderHttp.ReadTextAsync(response.Content, 4 * 1024 * 1024, cancellationToken); + return Parse(json, game, release); + } + + internal static ProviderGameResult? Parse(string json, Game game, Release? release) + { + using var document = JsonDocument.Parse(json); + var games = ProviderJson.Array(document.RootElement, "games") + .Where(item => ProviderText.Normalize(ProviderJson.String(item, "title")) == ProviderText.Normalize(game.Title)).ToArray(); + if (games.Length == 0) return null; + var platform = ProviderText.NormalizePlatform(release?.Platform); + var matches = platform is null ? games : games.Where(item => ProviderJson.Array(item, "platforms") + .Any(value => ProviderText.PlatformMatches(platform, ProviderJson.String(value, "platform_name"), ProviderJson.String(value, "platform")))).ToArray(); + var selected = matches.Length == 1 ? matches[0] : games.Length == 1 ? games[0] : default; + if (selected.ValueKind == JsonValueKind.Undefined) return null; + var externalId = ProviderJson.Int(selected, "game_id")?.ToString(CultureInfo.InvariantCulture) ?? game.Title; + var fields = new List<MetadataFieldCandidate>(); + Add(fields, "officialTitle", ProviderJson.String(selected, "title"), externalId); + Add(fields, "description", ProviderJson.String(selected, "description"), externalId); + var genres = string.Join(", ", ProviderJson.Array(selected, "genres").Select(item => + ProviderJson.String(item, "genre_name") ?? ProviderJson.String(item, "name")).Where(value => !string.IsNullOrWhiteSpace(value))); + Add(fields, "genre", genres, externalId); + var artwork = new List<ProviderArtworkCandidate>(); + var cover = ProviderJson.String(selected, "sample_cover") ?? ProviderJson.String(selected, "cover_url"); + if (Uri.TryCreate(cover, UriKind.Absolute, out var coverUri) && coverUri.Scheme == Uri.UriSchemeHttps) + artwork.Add(new(cover, "box-front", "MobyGames", externalId, Confidence.High)); + return new("MobyGames", externalId, ProviderJson.String(selected, "title") ?? game.Title, + release?.Platform, fields, artwork); + } + + private static void Add(List<MetadataFieldCandidate> fields, string field, string? value, string externalId) + { + if (!string.IsNullOrWhiteSpace(value)) fields.Add(new(field, value.Trim(), "MobyGames", externalId, Confidence.High, "v1")); + } +} + +public sealed class ScreenScraperMetadataProvider( + HttpClient client, + string? developerId, + string? developerPassword, + string? softwareName, + string? userName, + string? userPassword) : IGameMetadataProvider +{ + private static readonly Dictionary<string, int> SystemIds = new(StringComparer.OrdinalIgnoreCase) + { + ["genesis"] = 1, + ["master-system"] = 2, + ["nes"] = 3, + ["snes"] = 4, + ["gba"] = 12, + ["gbc"] = 10, + ["gb"] = 9, + ["n64"] = 14, + ["gamecube"] = 13, + ["nds"] = 15, + ["3ds"] = 17, + ["dreamcast"] = 23, + ["saturn"] = 22, + ["game-gear"] = 21, + ["psx"] = 57, + ["ps2"] = 58, + ["psp"] = 61, + ["atari-2600"] = 26, + ["arcade"] = 75 + }; + + public ProviderDescriptor Describe() + { + var configured = !string.IsNullOrWhiteSpace(developerId) && !string.IsNullOrWhiteSpace(developerPassword) + && !string.IsNullOrWhiteSpace(softwareName); + return new("screenscraper", "ScreenScraper", configured, configured ? "Ready" : "Not configured", + ["metadata", "artwork"], true, "https://www.screenscraper.fr/webapi2.php", + configured ? null : "Set SCREENSCRAPER_DEVID, SCREENSCRAPER_DEVPASSWORD and SCREENSCRAPER_SOFTNAME."); + } + + public async Task<ProviderGameResult?> EnrichAsync(Game game, Release? release, CancellationToken cancellationToken) + { + if (!Describe().Configured || !SystemIds.TryGetValue(ProviderText.NormalizePlatform(release?.Platform) ?? "", out var systemId)) return null; + var query = new Dictionary<string, string?> + { + ["devid"] = developerId, + ["devpassword"] = developerPassword, + ["softname"] = softwareName, + ["ssid"] = userName, + ["sspassword"] = userPassword, + ["output"] = "xml", + ["romnom"] = game.Title, + ["systemeid"] = systemId.ToString(CultureInfo.InvariantCulture) + }; + var uri = "https://www.screenscraper.fr/api2/jeuInfos.php?" + string.Join("&", query + .Where(item => !string.IsNullOrWhiteSpace(item.Value)) + .Select(item => $"{Uri.EscapeDataString(item.Key)}={Uri.EscapeDataString(item.Value!)}")); + using var response = await client.GetAsync(uri, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + response.EnsureSuccessStatusCode(); + var xml = await ProviderHttp.ReadTextAsync(response.Content, 4 * 1024 * 1024, cancellationToken); + return Parse(xml, game, release); + } + + internal static ProviderGameResult? Parse(string xml, Game game, Release? release) + { + var document = new XmlDocument { XmlResolver = null }; + using var textReader = new StringReader(xml); + using var reader = XmlReader.Create(textReader, new XmlReaderSettings + { + DtdProcessing = DtdProcessing.Prohibit, + XmlResolver = null + }); + document.Load(reader); + var node = document.SelectSingleNode("//jeu"); + if (node is null) return null; + var names = node.SelectNodes("noms/nom")?.Cast<XmlNode>().Select(item => item.InnerText.Trim()).Where(item => item.Length > 0).ToArray() ?? []; + var title = names.FirstOrDefault(item => ProviderText.Normalize(item) == ProviderText.Normalize(game.Title)); + if (title is null) return null; + var externalId = node.Attributes?["id"]?.Value ?? title; + var fields = new List<MetadataFieldCandidate>(); + Add(fields, "officialTitle", title, externalId); + Add(fields, "alternateTitles", string.Join(", ", names.Where(item => !item.Equals(title, StringComparison.OrdinalIgnoreCase))), externalId); + Add(fields, "description", PreferredText(node, "synopsis/synopsis"), externalId); + Add(fields, "genre", PreferredText(node, "genres/genre/noms/nom"), externalId); + Add(fields, "developer", node.SelectSingleNode("developpeur")?.InnerText, externalId); + Add(fields, "publisher", node.SelectSingleNode("editeur")?.InnerText, externalId); + Add(fields, "players", node.SelectSingleNode("joueurs")?.InnerText, externalId); + Add(fields, "releaseDate", PreferredText(node, "dates/date"), externalId); + var artwork = new List<ProviderArtworkCandidate>(); + var mediaNodes = node.SelectNodes("medias/media"); + if (mediaNodes is not null) + { + foreach (XmlNode media in mediaNodes) + { + var type = media.Attributes?["type"]?.Value; + var url = media.InnerText.Trim(); + if (type is "box-2D" or "box-3D" && Uri.TryCreate(url, UriKind.Absolute, out var parsed) && parsed.Scheme == Uri.UriSchemeHttps) + { + artwork.Add(new(url, "box-front", "ScreenScraper", externalId, Confidence.High)); + break; + } + } + } + return new("ScreenScraper", externalId, title, release?.Platform, fields, artwork); + } + + private static string? PreferredText(XmlNode node, string path) + { + var values = node.SelectNodes(path)?.Cast<XmlNode>().ToArray() ?? []; + return values.FirstOrDefault(item => item.Attributes?["langue"]?.Value is "en" or "fr")?.InnerText + ?? values.FirstOrDefault()?.InnerText; + } + private static void Add(List<MetadataFieldCandidate> fields, string field, string? value, string externalId) + { + if (!string.IsNullOrWhiteSpace(value)) fields.Add(new(field, value.Trim(), "ScreenScraper", externalId, Confidence.High, "api2")); + } + +} + +public sealed class RetroAchievementsMetadataProvider(HttpClient client, string? apiKey, string? userName) : IGameMetadataProvider +{ + private readonly ConcurrentDictionary<int, Task<IReadOnlyList<RetroGame>>> gameLists = new(); + private static readonly Dictionary<string, int> SystemIds = new(StringComparer.OrdinalIgnoreCase) + { + ["genesis"] = 1, + ["n64"] = 2, + ["snes"] = 3, + ["gb"] = 4, + ["gba"] = 5, + ["gbc"] = 6, + ["nes"] = 7, + ["pc-engine"] = 8, + ["sega-cd"] = 9, + ["sega-32x"] = 10, + ["master-system"] = 11, + ["psx"] = 12, + ["atari-lynx"] = 13, + ["neo-geo-pocket"] = 14, + ["game-gear"] = 15, + ["gamecube"] = 16, + ["atari-jaguar"] = 17, + ["nds"] = 18, + ["wii"] = 19, + ["wii-u"] = 20, + ["ps2"] = 21, + ["xbox"] = 22, + ["odyssey2"] = 23, + ["pokemon-mini"] = 24, + ["atari-2600"] = 25, + ["dos"] = 26, + ["arcade"] = 27, + ["virtual-boy"] = 28, + ["c64"] = 30, + ["amiga"] = 35, + ["atari-st"] = 36, + ["saturn"] = 39, + ["dreamcast"] = 40, + ["psp"] = 41, + ["3do"] = 43, + ["colecovision"] = 44, + ["intellivision"] = 45, + ["vectrex"] = 46, + ["pc-98"] = 48, + ["pc-fx"] = 49, + ["atari-5200"] = 50, + ["atari-7800"] = 51, + ["wonderswan"] = 53, + ["neo-geo-cd"] = 56, + ["zx-spectrum"] = 59 + }; + + public ProviderDescriptor Describe() + { + var configured = !string.IsNullOrWhiteSpace(apiKey); + return new("retroachievements", "RetroAchievements", configured, configured ? "Ready" : "Not configured", + ["metadata", "artwork", "achievements"], true, "https://api-docs.retroachievements.org/", + configured && string.IsNullOrWhiteSpace(userName) + ? "Achievement definitions are enabled. Set RETROACHIEVEMENTS_USERNAME for personal unlock progress." + : configured ? null : "Set RETROACHIEVEMENTS_API_KEY to enable this provider."); + } + + public async Task<ProviderGameResult?> EnrichAsync(Game game, Release? release, CancellationToken cancellationToken) + { + if (!Describe().Configured || !SystemIds.TryGetValue(ProviderText.NormalizePlatform(release?.Platform) ?? "", out var systemId)) return null; + var games = await gameLists.GetOrAdd(systemId, id => LoadGamesAsync(id, CancellationToken.None)); + cancellationToken.ThrowIfCancellationRequested(); + var matches = games.Where(item => ProviderText.Normalize(item.Title) == ProviderText.Normalize(game.Title)).ToArray(); + if (matches.Length != 1) return null; + var match = matches[0]; + var endpoint = string.IsNullOrWhiteSpace(userName) + ? $"API_GetGameExtended.php?i={match.Id}" + : $"API_GetGameInfoAndUserProgress.php?g={match.Id}&u={Uri.EscapeDataString(userName)}"; + using var response = await client.GetAsync($"https://retroachievements.org/API/{endpoint}&y={Uri.EscapeDataString(apiKey!)}", + HttpCompletionOption.ResponseHeadersRead, cancellationToken); + response.EnsureSuccessStatusCode(); + var json = await ProviderHttp.ReadTextAsync(response.Content, 8 * 1024 * 1024, cancellationToken); + return ParseDetails(json, game, release, match.Id); + } + + internal static ProviderGameResult? ParseDetails(string json, Game game, Release? release, int expectedId) + { + using var document = JsonDocument.Parse(json); + var root = document.RootElement; + var id = ProviderJson.Int(root, "id") ?? ProviderJson.Int(root, "ID"); + if (id != expectedId) return null; + var externalId = expectedId.ToString(CultureInfo.InvariantCulture); + var title = ProviderJson.String(root, "title") ?? ProviderJson.String(root, "Title") ?? game.Title; + if (ProviderText.Normalize(title) != ProviderText.Normalize(game.Title)) return null; + var fields = new List<MetadataFieldCandidate>(); + Add(fields, "officialTitle", title, externalId); + Add(fields, "developer", ProviderJson.String(root, "developer"), externalId); + Add(fields, "publisher", ProviderJson.String(root, "publisher"), externalId); + Add(fields, "genre", ProviderJson.String(root, "genre"), externalId); + Add(fields, "releaseDate", ProviderJson.String(root, "released"), externalId); + var artwork = new List<ProviderArtworkCandidate>(); + var box = ProviderJson.String(root, "imageBoxArt"); + if (box is { Length: > 0 }) artwork.Add(new(new Uri(new Uri("https://retroachievements.org"), box).AbsoluteUri, + "box-front", "RetroAchievements", externalId, Confidence.High)); + var achievements = ParseAchievements(root, game.Id, externalId); + return new("RetroAchievements", externalId, title, release?.Platform, fields, artwork, achievements); + } + + private async Task<IReadOnlyList<RetroGame>> LoadGamesAsync(int systemId, CancellationToken cancellationToken) + { + var result = new List<RetroGame>(); + const int pageSize = 1000; + for (var offset = 0; ; offset += pageSize) + { + var uri = $"https://retroachievements.org/API/API_GetGameList.php?i={systemId}&f=1&h=0&o={offset}&c={pageSize}&y={Uri.EscapeDataString(apiKey!)}"; + using var response = await client.GetAsync(uri, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + response.EnsureSuccessStatusCode(); + var json = await ProviderHttp.ReadTextAsync(response.Content, 8 * 1024 * 1024, cancellationToken); + using var document = JsonDocument.Parse(json); + if (document.RootElement.ValueKind != JsonValueKind.Array) break; + var page = document.RootElement.EnumerateArray().Select(item => new RetroGame( + ProviderJson.Int(item, "id") ?? 0, ProviderJson.String(item, "title") ?? "")) + .Where(item => item.Id > 0 && item.Title.Length > 0).ToArray(); + result.AddRange(page); + if (page.Length < pageSize) break; + } + return result; + } + + private static GameAchievementProgress ParseAchievements(JsonElement root, Guid gameId, string externalId) + { + var items = new List<Achievement>(); + if (ProviderJson.Object(root, "achievements") is { } achievements) + { + foreach (var property in achievements.EnumerateObject()) + { + var item = property.Value; + var id = ProviderJson.Int(item, "id") ?? (int.TryParse(property.Name, out var parsed) ? parsed : 0); + if (id <= 0) continue; + var unlockedAtText = ProviderJson.String(item, "dateEarnedHardcore") ?? ProviderJson.String(item, "dateEarned"); + var unlockedAt = DateTimeOffset.TryParse(unlockedAtText, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var parsedDate) + ? parsedDate : (DateTimeOffset?)null; + var badge = ProviderJson.String(item, "badgeName"); + items.Add(new(GameProviderHub.DeterministicGuid($"retroachievements|achievement|{id}"), + id.ToString(CultureInfo.InvariantCulture), ProviderJson.String(item, "title") ?? $"Achievement {id}", + ProviderJson.String(item, "description"), ProviderJson.Int(item, "points") ?? 0, + unlockedAt is not null, unlockedAt, badge is null ? null : $"https://media.retroachievements.org/Badge/{Uri.EscapeDataString(badge)}.png")); + } + } + return new(gameId, "RetroAchievements", externalId, items.Count(item => item.Unlocked), items.Count, + DateTimeOffset.UtcNow, items); + } + private static void Add(List<MetadataFieldCandidate> fields, string field, string? value, string externalId) + { + if (!string.IsNullOrWhiteSpace(value)) fields.Add(new(field, value.Trim(), "RetroAchievements", externalId, Confidence.High, "web-api-v1")); + } + private sealed record RetroGame(int Id, string Title); +} + +internal static class ProviderHttp +{ + public static async Task<string> ReadTextAsync(HttpContent content, int maximumBytes, CancellationToken cancellationToken) + { + if (content.Headers.ContentLength is > 0 && content.Headers.ContentLength > maximumBytes) + throw new InvalidDataException($"Provider response exceeds the {maximumBytes / 1024 / 1024} MiB safety limit."); + await using var input = await content.ReadAsStreamAsync(cancellationToken); + using var output = new MemoryStream(Math.Min(maximumBytes, 64 * 1024)); + var buffer = new byte[32 * 1024]; + while (true) + { + var read = await input.ReadAsync(buffer, cancellationToken); + if (read == 0) break; + if (output.Length + read > maximumBytes) throw new InvalidDataException("Provider response exceeds its safety limit."); + await output.WriteAsync(buffer.AsMemory(0, read), cancellationToken); + } + return Encoding.UTF8.GetString(output.GetBuffer(), 0, checked((int)output.Length)); + } +} + +internal static class ProviderText +{ + public static string Normalize(string? value) => GameArtworkEnricher.Normalize(value ?? ""); + public static string? NormalizePlatform(string? value) + { + if (string.IsNullOrWhiteSpace(value)) return null; + var normalized = Normalize(value); + return normalized switch + { + "mega drive" or "sega mega drive" or "sega genesis" => "genesis", + "sega master system" => "master-system", + "sega game gear" => "game-gear", + "sony playstation" or "playstation" or "playstation 1" => "psx", + "sony playstation 2" or "playstation 2" => "ps2", + "sony psp" or "playstation portable" => "psp", + "nintendo entertainment system" or "famicom" => "nes", + "super nintendo entertainment system" or "super famicom" => "snes", + "nintendo game boy" => "gb", + "nintendo game boy color" => "gbc", + "nintendo game boy advance" => "gba", + "nintendo ds" => "nds", + "nintendo 64" => "n64", + "nintendo gamecube" => "gamecube", + "commodore 64" => "c64", + "ms dos" => "dos", + _ => normalized.Replace(' ', '-') + }; + } + public static bool PlatformMatches(string normalized, params string?[] candidates) => candidates + .Where(item => !string.IsNullOrWhiteSpace(item)).Select(NormalizePlatform).Any(item => item == normalized); +} + +internal static class ProviderJson +{ + public static string? String(JsonElement item, string name) + { + if (!TryGet(item, name, out var value)) return null; + return value.ValueKind == JsonValueKind.String ? value.GetString() : value.ValueKind == JsonValueKind.Number ? value.GetRawText() : null; + } + public static int? Int(JsonElement item, string name) => TryGet(item, name, out var value) && value.TryGetInt32(out var result) ? result : null; + public static long? Long(JsonElement item, string name) => TryGet(item, name, out var value) && value.TryGetInt64(out var result) ? result : null; + public static bool? Bool(JsonElement item, string name) => TryGet(item, name, out var value) && value.ValueKind is JsonValueKind.True or JsonValueKind.False ? value.GetBoolean() : null; + public static JsonElement? Object(JsonElement item, string name) => TryGet(item, name, out var value) && value.ValueKind == JsonValueKind.Object ? value : null; + public static IEnumerable<JsonElement> Array(JsonElement item, string name) => TryGet(item, name, out var value) && value.ValueKind == JsonValueKind.Array ? value.EnumerateArray().ToArray() : []; + private static bool TryGet(JsonElement item, string name, out JsonElement value) + { + if (item.ValueKind == JsonValueKind.Object) + foreach (var property in item.EnumerateObject()) + if (property.Name.Equals(name, StringComparison.OrdinalIgnoreCase)) { value = property.Value; return true; } + value = default; + return false; + } +} diff --git a/src/Ludarium.Infrastructure/InventoryExportService.cs b/src/Ludarium.Infrastructure/InventoryExportService.cs new file mode 100644 index 0000000..0d95406 --- /dev/null +++ b/src/Ludarium.Infrastructure/InventoryExportService.cs @@ -0,0 +1,107 @@ +using System.Text; +using System.Text.Json; +using Ludarium.Application; +using Ludarium.Domain; + +namespace Ludarium.Infrastructure; + +public sealed class InventoryExportService(ILudariumStore store, string exportRoot) : IInventoryExportService +{ + private const string SchemaVersion = "1"; + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + + public async Task<InventoryExportResult> CreateAsync(string format, CancellationToken cancellationToken) + { + var normalized = format.ToLowerInvariant() switch { "json" => "json", "csv" => "csv", "sha256" => "sha256", _ => throw new ArgumentException("Format must be json, csv or sha256.", nameof(format)) }; + Directory.CreateDirectory(exportRoot); + var createdAt = DateTimeOffset.UtcNow; + var stamp = createdAt.ToString("yyyyMMdd-HHmmss-fff", System.Globalization.CultureInfo.InvariantCulture); + var fileName = normalized switch { "csv" => $"artifacts-{stamp}.csv", "sha256" => $"SHA256SUMS-{stamp}", _ => $"inventory-{stamp}.json" }; + var target = Path.Combine(Path.GetFullPath(exportRoot), fileName); + try + { + var count = normalized switch + { + "json" => await WriteJsonAsync(target, createdAt, cancellationToken), + "csv" => await WriteCsvAsync(target, cancellationToken), + _ => await WriteSha256Async(target, cancellationToken) + }; + return new(fileName, normalized, count, createdAt); + } + catch + { + if (File.Exists(target)) File.Delete(target); + throw; + } + } + + private async Task<long> WriteJsonAsync(string target, DateTimeOffset createdAt, CancellationToken ct) + { + await using var output = NewOutput(target); + using var writer = new Utf8JsonWriter(output, new JsonWriterOptions { Indented = true }); + writer.WriteStartObject(); + writer.WriteString("schemaVersion", SchemaVersion); + writer.WriteString("applicationVersion", ReleaseIdentity.Version); + writer.WriteString("createdAt", createdAt); + writer.WriteStartArray("artifacts"); + long count = 0; + await foreach (var artifact in ReadPagesAsync(ct)) + { + JsonSerializer.Serialize(writer, artifact, JsonOptions); + count++; + if (count % 200 == 0) await writer.FlushAsync(ct); + } + writer.WriteEndArray(); + writer.WriteEndObject(); + await writer.FlushAsync(ct); + return count; + } + + private async Task<long> WriteCsvAsync(string target, CancellationToken ct) + { + await using var output = NewOutput(target); + await using var writer = new StreamWriter(output, new UTF8Encoding(false), leaveOpen: false); + await writer.WriteLineAsync($"# ludarium-schema-version={SchemaVersion}".AsMemory(), ct); + await writer.WriteLineAsync($"# ludarium-application-version={ReleaseIdentity.Version}".AsMemory(), ct); + await writer.WriteLineAsync("id,libraryId,path,size,state,type".AsMemory(), ct); + long count = 0; + await foreach (var artifact in ReadPagesAsync(ct)) + { + await writer.WriteLineAsync($"{artifact.Id},{artifact.LibraryId},{Csv(artifact.RelativePath)},{artifact.Size},{artifact.State},{artifact.MediaType}".AsMemory(), ct); + count++; + } + return count; + } + + private async Task<long> WriteSha256Async(string target, CancellationToken ct) + { + await using var output = NewOutput(target); + await using var writer = new StreamWriter(output, new UTF8Encoding(false), leaveOpen: false); + await writer.WriteLineAsync($"# ludarium-schema-version={SchemaVersion}".AsMemory(), ct); + await writer.WriteLineAsync($"# ludarium-application-version={ReleaseIdentity.Version}".AsMemory(), ct); + long count = 0; + await foreach (var artifact in ReadPagesAsync(ct)) + { + if (artifact.Sha256 is null) continue; + await writer.WriteLineAsync($"{artifact.Sha256} {EscapePath(artifact.RelativePath)}".AsMemory(), ct); + count++; + } + return count; + } + + private async IAsyncEnumerable<Artifact> ReadPagesAsync([System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct) + { + const int pageSize = 200; + for (var pageNumber = 1; ; pageNumber++) + { + ct.ThrowIfCancellationRequested(); + var page = await store.SearchArtifactsAsync(null, pageNumber, pageSize, ct); + foreach (var artifact in page.Items) yield return artifact; + if ((long)pageNumber * pageSize >= page.Total || page.Items.Count == 0) yield break; + } + } + + private static FileStream NewOutput(string target) => new(target, FileMode.CreateNew, FileAccess.Write, FileShare.None, 64 * 1024, FileOptions.Asynchronous); + private static string Csv(string value) => $"\"{value.Replace("\"", "\"\"", StringComparison.Ordinal)}\""; + private static string EscapePath(string value) => value.Replace("\\", "\\\\", StringComparison.Ordinal).Replace("\r", "\\r", StringComparison.Ordinal).Replace("\n", "\\n", StringComparison.Ordinal); +} diff --git a/src/Ludarium.Infrastructure/Ludarium.Infrastructure.csproj b/src/Ludarium.Infrastructure/Ludarium.Infrastructure.csproj new file mode 100644 index 0000000..fe4d94e --- /dev/null +++ b/src/Ludarium.Infrastructure/Ludarium.Infrastructure.csproj @@ -0,0 +1,19 @@ +<Project Sdk="Microsoft.NET.Sdk"> + + <ItemGroup> + <ProjectReference Include="..\Ludarium.Domain\Ludarium.Domain.csproj" /> + <ProjectReference Include="..\Ludarium.Application\Ludarium.Application.csproj" /> + </ItemGroup> + + <PropertyGroup> + <TargetFramework>net10.0</TargetFramework> + <ImplicitUsings>enable</ImplicitUsings> + <AllowUnsafeBlocks>true</AllowUnsafeBlocks> + <Nullable>enable</Nullable> +</PropertyGroup> + + <ItemGroup> + <PackageReference Include="Npgsql" Version="10.0.3" /> + </ItemGroup> + +</Project> diff --git a/src/Ludarium.Infrastructure/N64ZipRom.cs b/src/Ludarium.Infrastructure/N64ZipRom.cs new file mode 100644 index 0000000..3cbf6b1 --- /dev/null +++ b/src/Ludarium.Infrastructure/N64ZipRom.cs @@ -0,0 +1,94 @@ +using System.IO.Compression; +using Ludarium.Application; + +namespace Ludarium.Infrastructure; + +public static class N64ZipRom +{ + private const int MaximumEntries = 1024; + private static readonly HashSet<string> Extensions = new(StringComparer.OrdinalIgnoreCase) + { ".n64", ".z64", ".v64" }; + + public static async Task<bool> ValidateAsync(Stream source, CancellationToken ct) + { + try + { + using var archive = new ZipArchive(source, ZipArchiveMode.Read, leaveOpen: true); + var entry = SelectEntry(archive); + if (entry is null) return false; + await using var content = entry.Open(); + var header = new byte[4]; + var offset = 0; + while (offset < header.Length) + { + var read = await content.ReadAsync(header.AsMemory(offset), ct); + if (read == 0) return false; + offset += read; + } + return header is [0x80, 0x37, 0x12, 0x40] or [0x37, 0x80, 0x40, 0x12] or + [0x40, 0x12, 0x37, 0x80]; + } + catch (Exception exception) when (exception is InvalidDataException or IOException or NotSupportedException) + { + return false; + } + } + + public static async Task<BrowserPlayContent> OpenAsync(Stream source, CancellationToken ct) + { + ZipArchive? archive = null; + try + { + archive = new ZipArchive(source, ZipArchiveMode.Read, leaveOpen: true); + var entry = SelectEntry(archive) ?? throw new InvalidDataException( + "The N64 ZIP must contain exactly one bounded ROM entry."); + await using (var probe = entry.Open()) + { + var header = new byte[4]; + await probe.ReadExactlyAsync(header, ct); + if (header is not ([0x80, 0x37, 0x12, 0x40] or [0x37, 0x80, 0x40, 0x12] or + [0x40, 0x12, 0x37, 0x80])) + throw new InvalidDataException("The N64 ROM header is invalid."); + } + var content = new OwnedZipEntryStream(entry.Open(), archive, source, entry.Length); + archive = null; + return new(content, BrowserPlayPolicy.SafeFileName(entry.FullName), entry.Length, null); + } + catch + { + archive?.Dispose(); + await source.DisposeAsync(); + throw; + } + } + + private static ZipArchiveEntry? SelectEntry(ZipArchive archive) + { + if (archive.Entries.Count is 0 or > MaximumEntries) return null; + var candidates = archive.Entries.Where(entry => !string.IsNullOrEmpty(entry.Name) && + Extensions.Contains(Path.GetExtension(entry.Name)) && entry.Length is > 0 and <= BrowserPlayPolicy.MaximumRomBytes) + .Take(2).ToArray(); + return candidates.Length == 1 ? candidates[0] : null; + } + + private sealed class OwnedZipEntryStream(Stream content, ZipArchive archive, Stream source, long length) : Stream + { + public override bool CanRead => content.CanRead; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => length; + public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); } + public override void Flush() => content.Flush(); + public override int Read(byte[] buffer, int offset, int count) => content.Read(buffer, offset, count); + public override ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default) => + content.ReadAsync(buffer, cancellationToken); + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + protected override void Dispose(bool disposing) + { + if (disposing) { content.Dispose(); archive.Dispose(); source.Dispose(); } + base.Dispose(disposing); + } + } +} diff --git a/src/Ludarium.Infrastructure/NativeBrowserPlayService.cs b/src/Ludarium.Infrastructure/NativeBrowserPlayService.cs new file mode 100644 index 0000000..e9b18e2 --- /dev/null +++ b/src/Ludarium.Infrastructure/NativeBrowserPlayService.cs @@ -0,0 +1,259 @@ +using System.Security.Cryptography; +using System.Text; +using Ludarium.Application; +using Ludarium.Domain; + +namespace Ludarium.Infrastructure; + +/// <summary> +/// Creates short-lived, capability-scoped browser sessions. The service never accepts a source path +/// from the browser: a session is always resolved back to the exact scan-derived artifact link. +/// </summary> +public sealed class NativeBrowserPlayService( + IBrowserPlayStore store, + ILudariumStore catalog, + IReadOnlyLibraryFileSystem files, + bool emulatorAssetsAvailable, + int maximumConcurrentSessions = 2, + IFirmwareStore? firmwareStore = null, + FirmwareVaultFileStore? firmwareFiles = null) : IBrowserPlayService +{ + private readonly int maximumConcurrentSessions = Math.Clamp(maximumConcurrentSessions, 1, 8); + + public async Task<BrowserPlayCapability> GetCapabilityAsync(Guid gameId, CancellationToken ct) + { + var candidate = await store.GetBrowserPlayCandidateAsync(gameId, ct); + var capability = BrowserPlayPolicy.Evaluate(candidate, configured: true, emulatorAssetsAvailable, + DateTimeOffset.UtcNow); + if (candidate is not null && capability.Available && IsN64Zip(candidate)) + { + var validation = await ValidateN64ZipAsync(candidate, ct); + if (validation is not N64ZipValidation.Valid) + capability = capability with + { + Available = false, + State = validation is N64ZipValidation.SourceUnavailable + ? BrowserPlayState.MissingRom + : BrowserPlayState.UnsupportedFormat, + Message = validation is N64ZipValidation.SourceUnavailable + ? "The scan-derived ROM is no longer present or readable. Run a quick scan to reconcile the library." + : "The N64 ZIP must contain exactly one bounded ROM with a verified N64 header." + }; + } + if (candidate is not null && capability.Available && DiscSetArchive.IsDescriptor(candidate.RelativePath) && + await ResolveDiscSetAsync(candidate, ct) is null) + capability = capability with + { + Available = false, + State = BrowserPlayState.UnsupportedFormat, + Message = DiscSetArchive.IsPlaylist(candidate.RelativePath) + ? "The playlist does not resolve to a complete, bounded set of present read-only discs." + : "The CUE sheet does not resolve to a complete, bounded set of present read-only track files." + }; + return capability with + { GameId = candidate?.GameId ?? gameId }; + } + + public async Task<BrowserPlayLaunch> StartAsync(Guid gameId, string actor, CancellationToken ct) + { + var candidate = await store.GetBrowserPlayCandidateAsync(gameId, ct) + ?? throw new KeyNotFoundException("No unique present ROM is linked to this game."); + var capability = BrowserPlayPolicy.Evaluate(candidate, configured: true, emulatorAssetsAvailable, + DateTimeOffset.UtcNow); + if (capability.Available && IsN64Zip(candidate)) + { + var validation = await ValidateN64ZipAsync(candidate, ct); + if (validation is N64ZipValidation.SourceUnavailable) + throw new ResourceConflictException("The scan-derived ROM is no longer present or readable. Run a quick scan to reconcile the library."); + if (validation is not N64ZipValidation.Valid) + throw new ResourceConflictException("The N64 ZIP must contain exactly one bounded ROM with a verified N64 header."); + } + if (capability.Available && DiscSetArchive.IsDescriptor(candidate.RelativePath) && + await ResolveDiscSetAsync(candidate, ct) is null) + throw new ResourceConflictException(DiscSetArchive.IsPlaylist(candidate.RelativePath) + ? "The playlist does not resolve to a complete, bounded set of present read-only discs." + : "The CUE sheet does not resolve to a complete, bounded set of present read-only track files."); + if (!capability.Available) throw new ResourceConflictException(capability.Message); + if (await store.CountActiveBrowserPlaySessionsAsync(DateTimeOffset.UtcNow, ct) >= maximumConcurrentSessions) + throw new ResourceConflictException("Browser-play capacity is currently in use. Exit another session and try again."); + + var platform = BrowserPlayPolicy.GetPlatform(candidate.Platform)!; + var id = Guid.NewGuid(); + var secret = Convert.ToHexString(RandomNumberGenerator.GetBytes(32)).ToLowerInvariant(); + var now = DateTimeOffset.UtcNow; + var firmwareQuery = await HasBrowserFirmwareAsync(platform.Platform, ct) ? "&firmware=1" : string.Empty; + var playerPage = platform.Core switch + { + "playjs" => "/ps2-player.html", + "n64wasm" => "/n64-player.html", + _ => "/player.html" + }; + var emulator = BrowserPlayPolicy.EmulatorName(platform.Core); + var safeLaunchUrl = $"{playerPage}?session={id:D}&core={Uri.EscapeDataString(platform.Core)}&name={Uri.EscapeDataString(candidate.GameTitle)}{firmwareQuery}"; + var stored = new BrowserPlaySession(id, gameId, candidate.ArtifactId, PlaySessionState.Ready, + platform.Platform, emulator, platform.Core, safeLaunchUrl, Sha256(secret), now, + now.AddHours(2)); + await store.SaveBrowserPlaySessionAsync(stored, actor, ct); + + // The capability secret lives in the fragment so it is never sent with the initial page request + // or persisted in the database. player.js forwards it only to the scoped content endpoint. + var launch = stored with { LaunchUrl = $"{safeLaunchUrl}#token={secret}", TokenHash = string.Empty }; + return new(launch, secret); + } + + public async Task<BrowserPlaySession?> GetSessionAsync(Guid sessionId, CancellationToken ct) + { + var session = await store.GetBrowserPlaySessionAsync(sessionId, ct); + if (session is null) return null; + if (IsOpen(session.State) && session.ExpiresAt <= DateTimeOffset.UtcNow) + { + await store.EndBrowserPlaySessionAsync(session.Id, PlaySessionState.Expired, "system", "expired", + "Session expired.", ct); + return session with + { + State = PlaySessionState.Expired, + EndedAt = DateTimeOffset.UtcNow, + ErrorCode = "expired", + ErrorMessage = "Session expired.", + TokenHash = string.Empty + }; + } + return session with { TokenHash = string.Empty }; + } + + public async Task<BrowserPlayContent?> OpenContentAsync(Guid sessionId, string token, CancellationToken ct) + { + var source = await AuthorizeSourceAsync(sessionId, token, ct); + if (source is null) return null; + + if (DiscSetArchive.IsDescriptor(source.Details.Artifact.RelativePath)) + { + var set = await ResolveDiscSetAsync(source.Details, source.Session.Platform, ct); + return set is null ? null : DiscSetArchive.Open(set, files, source.Details.Library, ct); + } + var content = await files.OpenReadAsync(source.Details.Library, source.Details.Artifact.RelativePath, ct); + if (source.Session.Platform.Equals("n64", StringComparison.OrdinalIgnoreCase) && + Path.GetExtension(source.Details.Artifact.RelativePath).Equals(".zip", StringComparison.OrdinalIgnoreCase)) + return await N64ZipRom.OpenAsync(content, ct); + return new(content, BrowserPlayPolicy.SafeFileName(source.Details.Artifact.RelativePath), + source.Details.Artifact.Size, source.Details.Artifact.Sha256); + } + + public async Task<FirmwareContent?> OpenFirmwareAsync(Guid sessionId, string token, CancellationToken ct) + { + var session = await AuthorizeAsync(sessionId, token, ct); + if (session is null || firmwareStore is null || firmwareFiles is null) return null; + FirmwareRequirement requirement; + try { requirement = FirmwarePolicy.Get(session.Platform, "bios"); } + catch (FirmwareValidationException) { return null; } + if (!requirement.BrowserDelivery) return null; + var asset = await firmwareStore.GetSelectedFirmwareAssetAsync(requirement.Platform, requirement.Slot, ct); + return asset is null ? null : await firmwareFiles.OpenAsync(asset, ct); + } + + public async Task<AuthorizedBrowserPlaySource?> AuthorizeSourceAsync(Guid sessionId, string token, + CancellationToken ct) + { + var session = await AuthorizeAsync(sessionId, token, ct); + if (session is null) return null; + var candidate = await store.GetBrowserPlayCandidateAsync(session.GameId, ct); + var capability = BrowserPlayPolicy.Evaluate(candidate, true, emulatorAssetsAvailable, DateTimeOffset.UtcNow); + if (candidate is null || candidate.ArtifactId != session.ArtifactId || !capability.Available || + !string.Equals(capability.Core, session.Core, StringComparison.OrdinalIgnoreCase)) return null; + var details = await catalog.GetArtifactDetailsAsync(session.ArtifactId, ct); + if (details is null || details.Library.Id == Guid.Empty || details.Library.IsReadOnly != true || + details.Artifact.State is not ArtifactState.Present || details.Artifact.Size != candidate.Size || + !details.Artifact.RelativePath.Equals(candidate.RelativePath, StringComparison.Ordinal)) return null; + if (IsN64Zip(candidate) && await ValidateN64ZipAsync(candidate, details, ct) is not N64ZipValidation.Valid) return null; + if (DiscSetArchive.IsDescriptor(details.Artifact.RelativePath) && + await ResolveDiscSetAsync(details, session.Platform, ct) is null) return null; + return new(session, details); + } + + public async Task<BrowserPlaySession?> AuthorizeAsync(Guid sessionId, string token, CancellationToken ct) + { + var session = await store.GetBrowserPlaySessionAsync(sessionId, ct); + return session is not null && IsOpen(session.State) && session.ExpiresAt > DateTimeOffset.UtcNow && + MatchesToken(token, session.TokenHash) ? session with { TokenHash = string.Empty } : null; + } + + public Task CancelAsync(Guid sessionId, string actor, CancellationToken ct) => + store.EndBrowserPlaySessionAsync(sessionId, PlaySessionState.Cancelled, actor, "cancelled", + "Exited by the operator.", ct); + + private async Task<bool> HasBrowserFirmwareAsync(string platform, CancellationToken ct) + { + if (firmwareStore is null) return false; + FirmwareRequirement requirement; + try { requirement = FirmwarePolicy.Get(platform, "bios"); } + catch (FirmwareValidationException) { return false; } + return requirement.BrowserDelivery && + await firmwareStore.GetSelectedFirmwareAssetAsync(requirement.Platform, requirement.Slot, ct) is not null; + } + + /// <summary> + /// Resolve a linked CUE sheet or M3U playlist to its complete set. The platform's own size limit + /// bounds the whole game, not just the descriptor, so an oversized or incomplete set never + /// becomes playable. + /// </summary> + private async Task<DiscSet?> ResolveDiscSetAsync(BrowserPlayCandidate candidate, CancellationToken ct) + { + var details = await catalog.GetArtifactDetailsAsync(candidate.ArtifactId, ct); + return details is null ? null : await ResolveDiscSetAsync(details, candidate.Platform, ct); + } + + private async Task<DiscSet?> ResolveDiscSetAsync(ArtifactDetails details, string platform, CancellationToken ct) + { + if (details.Library.IsReadOnly != true || details.Artifact.State is not ArtifactState.Present) return null; + var maximum = BrowserPlayPolicy.GetPlatform(platform)?.MaximumBytes ?? BrowserPlayPolicy.MaximumRomBytes; + return await DiscSetArchive.ResolveAsync(files, details.Library, details.Artifact.RelativePath, + details.Artifact.Size, maximum, ct); + } + + private static bool IsN64Zip(BrowserPlayCandidate candidate) => + candidate.Platform.Equals("n64", StringComparison.OrdinalIgnoreCase) && + Path.GetExtension(candidate.RelativePath).Equals(".zip", StringComparison.OrdinalIgnoreCase); + + private enum N64ZipValidation { Valid, Invalid, SourceUnavailable } + + private async Task<N64ZipValidation> ValidateN64ZipAsync(BrowserPlayCandidate candidate, CancellationToken ct) + { + var details = await catalog.GetArtifactDetailsAsync(candidate.ArtifactId, ct); + return details is null ? N64ZipValidation.Invalid : await ValidateN64ZipAsync(candidate, details, ct); + } + + private async Task<N64ZipValidation> ValidateN64ZipAsync(BrowserPlayCandidate candidate, ArtifactDetails details, + CancellationToken ct) + { + if (details.Library.IsReadOnly != true || details.Artifact.State is not ArtifactState.Present || + details.Artifact.Size != candidate.Size || + !details.Artifact.RelativePath.Equals(candidate.RelativePath, StringComparison.Ordinal)) + return N64ZipValidation.Invalid; + try + { + await using var archive = await files.OpenReadAsync(details.Library, details.Artifact.RelativePath, ct); + return await N64ZipRom.ValidateAsync(archive, ct) + ? N64ZipValidation.Valid + : N64ZipValidation.Invalid; + } + catch (Exception exception) when (exception is FileNotFoundException or DirectoryNotFoundException or + UnauthorizedAccessException) + { + return N64ZipValidation.SourceUnavailable; + } + } + + private static bool IsOpen(PlaySessionState state) => state is + PlaySessionState.Starting or PlaySessionState.Ready or PlaySessionState.Active; + + private static bool MatchesToken(string value, string expectedHash) + { + if (string.IsNullOrWhiteSpace(value) || expectedHash.Length != 64) return false; + var actual = Encoding.ASCII.GetBytes(Sha256(value)); + var expected = Encoding.ASCII.GetBytes(expectedHash); + return CryptographicOperations.FixedTimeEquals(actual, expected); + } + + private static string Sha256(string value) => + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value))).ToLowerInvariant(); +} diff --git a/src/Ludarium.Infrastructure/PostgresStore.BrowserPlay.cs b/src/Ludarium.Infrastructure/PostgresStore.BrowserPlay.cs new file mode 100644 index 0000000..0a81fb7 --- /dev/null +++ b/src/Ludarium.Infrastructure/PostgresStore.BrowserPlay.cs @@ -0,0 +1,128 @@ +using Ludarium.Application; +using Ludarium.Domain; +using Npgsql; + +namespace Ludarium.Infrastructure; + +public sealed partial class PostgresStore +{ + private static readonly string[] DolphinExtensionPatterns = ["%.iso", "%.gcm", "%.rvz", "%.gcz", "%.wbfs", "%.wia"]; + + public async Task<IReadOnlyList<NativeLaunchCandidate>> ListDolphinLaunchCandidatesAsync(Guid gameId, + string platform, CancellationToken ct) + { + if (platform is not ("gamecube" or "wii")) return []; + const string sql = """ + SELECT DISTINCT a.id,a.relative_path,a.size,coalesce((l.data->>'isReadOnly')::boolean,false) + FROM game_artifact_links x + JOIN artifacts a ON a.id=x.artifact_id + JOIN library_roots l ON l.id=a.library_id + WHERE x.game_id=$1 AND a.data->>'state'='0' AND a.data->>'platform'=$2 + AND lower(a.relative_path) LIKE ANY($3) + ORDER BY a.relative_path + LIMIT 3 + """; + await using var command = dataSource.CreateCommand(sql); + command.Parameters.AddWithValue(gameId); + command.Parameters.AddWithValue(platform); + command.Parameters.AddWithValue(DolphinExtensionPatterns); + var result = new List<NativeLaunchCandidate>(); + await using var reader = await command.ExecuteReaderAsync(ct); + while (await reader.ReadAsync(ct)) + result.Add(new(reader.GetGuid(0), reader.GetString(1), reader.GetInt64(2), reader.GetBoolean(3))); + return result; + } + + public async Task<IReadOnlyList<NativeLaunchCandidate>> ListSwitchLaunchCandidatesAsync(Guid gameId, CancellationToken ct) + { + const string sql = """ + WITH linked AS ( + SELECT a.library_id,regexp_replace(a.relative_path,'(^|/)[^/]+$','') AS directory + FROM game_artifact_links x JOIN artifacts a ON a.id=x.artifact_id + WHERE x.game_id=$1 AND a.data->>'platform'='switch' + ) + SELECT DISTINCT a.id,a.relative_path,a.size,coalesce((l.data->>'isReadOnly')::boolean,false) + FROM linked x + JOIN artifacts a ON a.library_id=x.library_id AND regexp_replace(a.relative_path,'(^|/)[^/]+$','')=x.directory + JOIN library_roots l ON l.id=a.library_id + WHERE a.data->>'state'='0' AND a.data->>'platform'='switch' + AND (lower(a.relative_path) LIKE '%.xci' OR lower(a.relative_path) LIKE '%.nsp') + ORDER BY a.relative_path + LIMIT 100 + """; + await using var command = dataSource.CreateCommand(sql); + command.Parameters.AddWithValue(gameId); + var result = new List<NativeLaunchCandidate>(); + await using var reader = await command.ExecuteReaderAsync(ct); + while (await reader.ReadAsync(ct)) + result.Add(new(reader.GetGuid(0), reader.GetString(1), reader.GetInt64(2), reader.GetBoolean(3))); + return result; + } + + public async Task<BrowserPlayCandidate?> GetBrowserPlayCandidateAsync(Guid gameId, CancellationToken ct) + { + // Descriptors are included so a CUE/BIN disc or a multi-disc playlist can be played as a + // set. Their members are linked to the same game and would otherwise make every + // multi-track or multi-disc release look ambiguous. + const string sql = """ + SELECT g.id,g.title,a.id,a.relative_path,a.size,a.sha256, + a.data->>'platform',coalesce((l.data->>'isReadOnly')::boolean,false) + FROM game_artifact_links x + JOIN games g ON g.id=x.game_id + JOIN artifacts a ON a.id=x.artifact_id + JOIN library_roots l ON l.id=a.library_id + WHERE x.game_id=$1 AND a.data->>'state'='0' AND + (a.data->>'mediaType' IN ('0','2') OR + (a.data->>'mediaType'='1' AND (lower(a.relative_path) LIKE '%.cue' + OR lower(a.relative_path) LIKE '%.m3u')) OR + (a.data->>'mediaType'='3' AND a.data->>'platform'='n64' AND lower(a.relative_path) LIKE '%.zip')) + ORDER BY a.relative_path LIMIT 64 + """; + await using var command = dataSource.CreateCommand(sql); + command.Parameters.AddWithValue(gameId); + var candidates = new List<BrowserPlayCandidate>(); + await using var reader = await command.ExecuteReaderAsync(ct); + while (await reader.ReadAsync(ct)) + { + var platform = reader.IsDBNull(6) ? null : reader.GetString(6); + if (!string.IsNullOrWhiteSpace(platform)) + candidates.Add(new(reader.GetGuid(0), reader.GetGuid(2), reader.GetString(1), platform, + reader.GetString(3), reader.GetInt64(4), reader.IsDBNull(5) ? null : reader.GetString(5), reader.GetBoolean(7))); + } + return BrowserPlayPolicy.SelectCandidate(candidates); + } + + public async Task<int> CountActiveBrowserPlaySessionsAsync(DateTimeOffset now, CancellationToken ct) + { + await using var command = dataSource.CreateCommand("SELECT count(*)::int FROM browser_play_sessions WHERE state IN (0,1,2) AND expires_at>$1"); + command.Parameters.AddWithValue(now); + return Convert.ToInt32(await command.ExecuteScalarAsync(ct), System.Globalization.CultureInfo.InvariantCulture); + } + + public async Task SaveBrowserPlaySessionAsync(BrowserPlaySession session, string actor, CancellationToken ct) + { + var audit = new AuditEvent(Guid.NewGuid(), actor, "BrowserPlayStarted", "Game", session.GameId, + null, System.Text.Json.JsonSerializer.Serialize(new { session.Id, session.Platform, session.Emulator, session.Core, session.ExpiresAt }), + Guid.NewGuid(), session.CreatedAt); + await ExecuteTransactionAsync(ct, + ("INSERT INTO browser_play_sessions(id,game_id,artifact_id,state,expires_at,data,created_at,updated_at) VALUES($1,$2,$3,$4,$5,$6,$7,$7)", + [session.Id, session.GameId, session.ArtifactId, (int)session.State, session.ExpiresAt, Json(session), session.CreatedAt]), + ("INSERT INTO audit_events(id,data,created_at) VALUES($1,$2,$3)", [audit.Id, Json(audit), audit.CreatedAt])); + } + + public Task<BrowserPlaySession?> GetBrowserPlaySessionAsync(Guid sessionId, CancellationToken ct) => + SingleJsonAsync<BrowserPlaySession>("SELECT data FROM browser_play_sessions WHERE id=$1", ct, sessionId); + + public async Task EndBrowserPlaySessionAsync(Guid sessionId, PlaySessionState state, string actor, + string? errorCode, string? errorMessage, CancellationToken ct) + { + var current = await GetBrowserPlaySessionAsync(sessionId, ct); + if (current is null) return; + var ended = current with { State = state, EndedAt = DateTimeOffset.UtcNow, ErrorCode = errorCode, ErrorMessage = errorMessage }; + var audit = new AuditEvent(Guid.NewGuid(), actor, "BrowserPlayEnded", "Game", current.GameId, + null, System.Text.Json.JsonSerializer.Serialize(new { sessionId, state, errorCode }), Guid.NewGuid(), ended.EndedAt.Value); + await ExecuteTransactionAsync(ct, + ("UPDATE browser_play_sessions SET state=$2,data=$3,updated_at=$4 WHERE id=$1", [sessionId, (int)state, Json(ended), ended.EndedAt.Value]), + ("INSERT INTO audit_events(id,data,created_at) VALUES($1,$2,$3)", [audit.Id, Json(audit), audit.CreatedAt])); + } +} diff --git a/src/Ludarium.Infrastructure/PostgresStore.Firmware.cs b/src/Ludarium.Infrastructure/PostgresStore.Firmware.cs new file mode 100644 index 0000000..f7d5a49 --- /dev/null +++ b/src/Ludarium.Infrastructure/PostgresStore.Firmware.cs @@ -0,0 +1,44 @@ +using Ludarium.Application; +using Ludarium.Domain; + +namespace Ludarium.Infrastructure; + +public sealed partial class PostgresStore +{ + public async Task<IReadOnlyList<FirmwareAsset>> ListFirmwareAssetsAsync(CancellationToken ct) => + await ListJsonAsync<FirmwareAsset>("SELECT data FROM firmware_assets ORDER BY platform,slot,created_at DESC", ct); + + public Task<FirmwareAsset?> GetFirmwareAssetAsync(Guid id, CancellationToken ct) => + SingleJsonAsync<FirmwareAsset>("SELECT data FROM firmware_assets WHERE id=$1", ct, id); + + public Task<FirmwareAsset?> GetSelectedFirmwareAssetAsync(string platform, string slot, CancellationToken ct) => + SingleJsonAsync<FirmwareAsset>("SELECT data FROM firmware_assets WHERE platform=$1 AND slot=$2 AND selected=true", ct, + platform.ToLowerInvariant(), slot.ToLowerInvariant()); + + public async Task SaveFirmwareAssetAsync(FirmwareAsset asset, string actor, CancellationToken ct) + { + var audit = new AuditEvent(Guid.NewGuid(), actor, "FirmwareUploaded", "FirmwareAsset", asset.Id, null, + System.Text.Json.JsonSerializer.Serialize(new { asset.Platform, asset.Slot, asset.Kind, asset.FileName, asset.Length, Sha256Prefix = asset.Sha256[..12] }), + Guid.NewGuid(), asset.CreatedAt); + await ExecuteTransactionAsync(ct, + ("UPDATE firmware_assets SET selected=false,data=jsonb_set(data,'{selected}','false'::jsonb,true),updated_at=$3 WHERE platform=$1 AND slot=$2 AND selected=true", + [asset.Platform, asset.Slot, asset.CreatedAt]), + ("INSERT INTO firmware_assets(id,platform,slot,selected,version,data,created_at,updated_at) VALUES($1,$2,$3,true,1,$4,$5,$5)", + [asset.Id, asset.Platform, asset.Slot, Json(asset), asset.CreatedAt]), + ("INSERT INTO audit_events(id,data,created_at) VALUES($1,$2,$3)", [audit.Id, Json(audit), audit.CreatedAt])); + } + + public async Task<IReadOnlyList<FirmwareAsset>> DeleteFirmwareAssetAsync(Guid id, long expectedVersion, + string actor, CancellationToken ct) + { + var current = await GetFirmwareAssetAsync(id, ct) ?? throw new KeyNotFoundException("Firmware asset was not found."); + if (current.Version != expectedVersion) throw new ConcurrencyException("Firmware changed since it was loaded."); + var audit = new AuditEvent(Guid.NewGuid(), actor, "FirmwareDeleted", "FirmwareAsset", id, + System.Text.Json.JsonSerializer.Serialize(new { current.Platform, current.Slot, current.Kind, current.FileName, current.Length, Sha256Prefix = current.Sha256[..12] }), + null, Guid.NewGuid(), DateTimeOffset.UtcNow); + await ExecuteTransactionAsync(ct, + ("DELETE FROM firmware_assets WHERE id=$1 AND version=$2", [id, expectedVersion]), + ("INSERT INTO audit_events(id,data,created_at) VALUES($1,$2,$3)", [audit.Id, Json(audit), audit.CreatedAt])); + return [current]; + } +} diff --git a/src/Ludarium.Infrastructure/PostgresStore.GameDataVault.cs b/src/Ludarium.Infrastructure/PostgresStore.GameDataVault.cs new file mode 100644 index 0000000..efb00ee --- /dev/null +++ b/src/Ludarium.Infrastructure/PostgresStore.GameDataVault.cs @@ -0,0 +1,308 @@ +using System.Globalization; +using Ludarium.Application; +using Ludarium.Domain; +using Npgsql; + +namespace Ludarium.Infrastructure; + +public sealed partial class PostgresStore +{ + public async Task<IReadOnlyList<GameDataItem>> ListGameDataAsync(Guid gameId, CancellationToken ct) + { + await RequireGameAsync(gameId, ct); + await using var command = dataSource.CreateCommand(""" + SELECT e.data,r.data + FROM game_data_entries e + JOIN game_data_revisions r ON r.id=e.current_revision_id + WHERE e.game_id=$1 + ORDER BY e.kind,lower(e.data->>'name'),e.updated_at DESC,e.id + """); + Add(command, gameId); + var result = new List<GameDataItem>(); + await using var reader = await command.ExecuteReaderAsync(ct); + while (await reader.ReadAsync(ct)) + result.Add(new(Deserialize<GameDataEntry>(reader.GetString(0)), + Deserialize<GameDataRevision>(reader.GetString(1)))); + return result; + } + + public async Task<GameDataItem?> GetGameDataAsync(Guid entryId, CancellationToken ct) + { + await using var command = dataSource.CreateCommand(""" + SELECT e.data,r.data + FROM game_data_entries e + JOIN game_data_revisions r ON r.id=e.current_revision_id + WHERE e.id=$1 + """); + Add(command, entryId); + await using var reader = await command.ExecuteReaderAsync(ct); + return await reader.ReadAsync(ct) + ? new(Deserialize<GameDataEntry>(reader.GetString(0)), Deserialize<GameDataRevision>(reader.GetString(1))) + : null; + } + + public async Task<Page<GameDataRevision>> ListGameDataRevisionsAsync(Guid entryId, int page, + int pageSize, CancellationToken ct) + { + if (await GetGameDataAsync(entryId, ct) is null) throw new KeyNotFoundException("Game-data entry not found."); + page = Math.Max(page, 1); + pageSize = Math.Clamp(pageSize, 1, 100); + await using var count = dataSource.CreateCommand("SELECT count(*) FROM game_data_revisions WHERE entry_id=$1"); + Add(count, entryId); + var total = Convert.ToInt64(await count.ExecuteScalarAsync(ct), CultureInfo.InvariantCulture); + await using var command = dataSource.CreateCommand($""" + SELECT data FROM game_data_revisions + WHERE entry_id=$1 + ORDER BY sequence DESC,id + LIMIT {pageSize} OFFSET {(page - 1) * pageSize} + """); + Add(command, entryId); + return new(await ReadAsync<GameDataRevision>(command, ct), page, pageSize, total); + } + + public Task<GameDataRevision?> GetGameDataRevisionAsync(Guid revisionId, CancellationToken ct) => + SingleJsonAsync<GameDataRevision>("SELECT data FROM game_data_revisions WHERE id=$1", revisionId, ct); + + public async Task<GameDataItem> SaveGameDataRevisionAsync(Guid gameId, Guid entryId, + GameDataEntryInput input, long expectedVersion, GameDataRevision revision, string actor, + CancellationToken ct) + { + input = GameDataVaultPolicy.Normalize(input); + await RequireGameAsync(gameId, ct); + ValidateRevision(gameId, entryId, revision); + await using var connection = await dataSource.OpenConnectionAsync(ct); + await using var transaction = await connection.BeginTransactionAsync(ct); + await using (var entryLock = new NpgsqlCommand("SELECT pg_advisory_xact_lock($1)", connection, transaction)) + { + Add(entryLock, BitConverter.ToInt64(entryId.ToByteArray(), 0)); + await entryLock.ExecuteNonQueryAsync(ct); + } + + var existingRevision = await ReadRevisionAsync(connection, transaction, revision.Id, ct); + if (existingRevision is not null) + { + if (existingRevision.EntryId != entryId || !existingRevision.Sha256.Equals(revision.Sha256, StringComparison.OrdinalIgnoreCase)) + throw new ResourceConflictException("The revision id is already used by different content."); + await transaction.CommitAsync(ct); + return await GetGameDataAsync(entryId, ct) ?? throw new KeyNotFoundException("Game-data entry not found."); + } + + var before = await ReadEntryForUpdateAsync(connection, transaction, entryId, ct); + if (before is null) + { + if (expectedVersion != 0) throw new ConcurrencyException("The game-data entry no longer matches the requested version."); + var storedRevision = revision with { Sequence = 1 }; + var now = storedRevision.CreatedAt; + var created = new GameDataEntry(entryId, gameId, input.Kind, input.Name, input.Emulator, + input.Device, input.Notes, storedRevision.Id, 1, now, now); + await using (var insertEntry = new NpgsqlCommand(""" + INSERT INTO game_data_entries(id,game_id,kind,current_revision_id,version,data,created_at,updated_at) + VALUES($1,$2,$3,$4,$5,$6,$7,$8) + """, connection, transaction)) + { + Add(insertEntry, created.Id, created.GameId, (int)created.Kind, created.CurrentRevisionId, + created.Version, Json(created), created.CreatedAt, created.UpdatedAt); + await insertEntry.ExecuteNonQueryAsync(ct); + } + await InsertRevisionAsync(connection, transaction, storedRevision, ct); + await InsertGameDataAuditAsync(connection, transaction, actor, "GameDataCreated", created.Id, + null, new { Entry = created, Revision = AuditRevision(storedRevision) }, now, ct); + await transaction.CommitAsync(ct); + return new(created, storedRevision); + } + + if (before.GameId != gameId) throw new KeyNotFoundException("Game-data entry not found for this game."); + if (before.Kind != input.Kind) throw new ResourceConflictException("Save and savestate entries cannot change kind."); + if (before.Version != expectedVersion) throw new ConcurrencyException("The game-data entry changed after it was loaded."); + var current = await ReadRevisionAsync(connection, transaction, before.CurrentRevisionId, ct) + ?? throw new InvalidDataException("The current game-data revision is missing."); + if (current.Length == revision.Length && + current.Sha256.Equals(revision.Sha256, StringComparison.OrdinalIgnoreCase) && + current.SourceType.Equals(revision.SourceType, StringComparison.Ordinal) && + current.SourceId.Equals(revision.SourceId, StringComparison.Ordinal)) + { + await transaction.CommitAsync(ct); + return new(before, current); + } + + var nextRevision = revision with { Sequence = before.RevisionCount + 1 }; + var after = before with + { + Name = input.Name, + Emulator = input.Emulator, + Device = input.Device, + Notes = input.Notes, + CurrentRevisionId = nextRevision.Id, + RevisionCount = before.RevisionCount + 1, + UpdatedAt = nextRevision.CreatedAt, + Version = before.Version + 1 + }; + await InsertRevisionAsync(connection, transaction, nextRevision, ct); + await using (var update = new NpgsqlCommand(""" + UPDATE game_data_entries SET current_revision_id=$2,version=$3,data=$4,updated_at=$5 + WHERE id=$1 AND version=$6 + """, connection, transaction)) + { + Add(update, entryId, after.CurrentRevisionId, after.Version, Json(after), after.UpdatedAt, expectedVersion); + if (await update.ExecuteNonQueryAsync(ct) != 1) + throw new ConcurrencyException("The game-data entry changed while the revision was being saved."); + } + await InsertGameDataAuditAsync(connection, transaction, actor, "GameDataRevisionAdded", entryId, + before, new { Entry = after, Revision = AuditRevision(nextRevision) }, after.UpdatedAt, ct); + await transaction.CommitAsync(ct); + return new(after, nextRevision); + } + + public async Task<GameDataItem> UpdateGameDataAsync(Guid gameId, Guid entryId, + GameDataMetadataInput input, long expectedVersion, string actor, CancellationToken ct) + { + input = GameDataVaultPolicy.Normalize(input); + await using var connection = await dataSource.OpenConnectionAsync(ct); + await using var transaction = await connection.BeginTransactionAsync(ct); + var before = await ReadEntryForUpdateAsync(connection, transaction, entryId, ct) + ?? throw new KeyNotFoundException("Game-data entry not found."); + if (before.GameId != gameId) throw new KeyNotFoundException("Game-data entry not found for this game."); + if (before.Version != expectedVersion) throw new ConcurrencyException("The game-data entry changed after it was loaded."); + var after = before with + { + Name = input.Name, + Emulator = input.Emulator, + Device = input.Device, + Notes = input.Notes, + UpdatedAt = DateTimeOffset.UtcNow, + Version = before.Version + 1 + }; + await using (var update = new NpgsqlCommand(""" + UPDATE game_data_entries SET version=$2,data=$3,updated_at=$4 WHERE id=$1 AND version=$5 + """, connection, transaction)) + { + Add(update, entryId, after.Version, Json(after), after.UpdatedAt, expectedVersion); + if (await update.ExecuteNonQueryAsync(ct) != 1) + throw new ConcurrencyException("The game-data entry changed while it was being saved."); + } + await InsertGameDataAuditAsync(connection, transaction, actor, "GameDataMetadataUpdated", entryId, + before, after, after.UpdatedAt, ct); + var current = await ReadRevisionAsync(connection, transaction, after.CurrentRevisionId, ct) + ?? throw new InvalidDataException("The current game-data revision is missing."); + await transaction.CommitAsync(ct); + return new(after, current); + } + + public async Task<IReadOnlyList<GameDataRevision>> DeleteGameDataAsync(Guid gameId, Guid entryId, + long expectedVersion, string actor, CancellationToken ct) + { + await using var connection = await dataSource.OpenConnectionAsync(ct); + await using var transaction = await connection.BeginTransactionAsync(ct); + var before = await ReadEntryForUpdateAsync(connection, transaction, entryId, ct) + ?? throw new KeyNotFoundException("Game-data entry not found."); + if (before.GameId != gameId) throw new KeyNotFoundException("Game-data entry not found for this game."); + if (before.Version != expectedVersion) throw new ConcurrencyException("The game-data entry changed after it was loaded."); + var revisions = await ReadRevisionsAsync(connection, transaction, entryId, ct); + await using (var delete = new NpgsqlCommand("DELETE FROM game_data_entries WHERE id=$1 AND version=$2", connection, transaction)) + { + Add(delete, entryId, expectedVersion); + if (await delete.ExecuteNonQueryAsync(ct) != 1) + throw new ConcurrencyException("The game-data entry changed while it was being deleted."); + } + await InsertGameDataAuditAsync(connection, transaction, actor, "GameDataDeleted", entryId, + before, new { Revisions = revisions.Select(AuditRevision).ToArray() }, DateTimeOffset.UtcNow, ct); + await transaction.CommitAsync(ct); + return revisions; + } + + public async Task<GameDataSummary> GetGameDataSummaryAsync(Guid? gameId, CancellationToken ct) + { + var sql = """ + SELECT count(DISTINCT e.id) FILTER (WHERE e.kind=0),count(DISTINCT e.id) FILTER (WHERE e.kind=1), + count(r.id),coalesce(sum(r.length),0) + FROM game_data_entries e + LEFT JOIN game_data_revisions r ON r.entry_id=e.id + """ + (gameId is null ? string.Empty : " WHERE e.game_id=$1"); + await using var command = dataSource.CreateCommand(sql); + if (gameId is not null) Add(command, gameId.Value); + await using var reader = await command.ExecuteReaderAsync(ct); + await reader.ReadAsync(ct); + return new(reader.GetInt64(0), reader.GetInt64(1), reader.GetInt64(2), reader.GetInt64(3)); + } + + public async Task<IReadOnlyList<string>> ListGameDataRevisionLocationsAsync(CancellationToken ct) + { + await using var command = dataSource.CreateCommand("SELECT data->>'location' FROM game_data_revisions WHERE data ? 'location' ORDER BY data->>'location'"); + var result = new List<string>(); + await using var reader = await command.ExecuteReaderAsync(ct); + while (await reader.ReadAsync(ct)) result.Add(reader.GetString(0)); + return result; + } + + private static async Task<GameDataEntry?> ReadEntryForUpdateAsync(NpgsqlConnection connection, + NpgsqlTransaction transaction, Guid entryId, CancellationToken ct) + { + await using var command = new NpgsqlCommand("SELECT data FROM game_data_entries WHERE id=$1 FOR UPDATE", connection, transaction); + Add(command, entryId); + var value = await command.ExecuteScalarAsync(ct); + return value is null or DBNull ? null : Deserialize<GameDataEntry>((string)value); + } + + private static async Task<GameDataRevision?> ReadRevisionAsync(NpgsqlConnection connection, + NpgsqlTransaction transaction, Guid revisionId, CancellationToken ct) + { + await using var command = new NpgsqlCommand("SELECT data FROM game_data_revisions WHERE id=$1", connection, transaction); + Add(command, revisionId); + var value = await command.ExecuteScalarAsync(ct); + return value is null or DBNull ? null : Deserialize<GameDataRevision>((string)value); + } + + private static async Task<IReadOnlyList<GameDataRevision>> ReadRevisionsAsync(NpgsqlConnection connection, + NpgsqlTransaction transaction, Guid entryId, CancellationToken ct) + { + await using var command = new NpgsqlCommand("SELECT data FROM game_data_revisions WHERE entry_id=$1 ORDER BY sequence", connection, transaction); + Add(command, entryId); + return await ReadAsync<GameDataRevision>(command, ct); + } + + private static async Task InsertRevisionAsync(NpgsqlConnection connection, NpgsqlTransaction transaction, + GameDataRevision revision, CancellationToken ct) + { + await using var command = new NpgsqlCommand(""" + INSERT INTO game_data_revisions(id,entry_id,sequence,sha256,length,data,created_at) + VALUES($1,$2,$3,$4,$5,$6,$7) + """, connection, transaction); + Add(command, revision.Id, revision.EntryId, revision.Sequence, revision.Sha256, revision.Length, + Json(revision), revision.CreatedAt); + await command.ExecuteNonQueryAsync(ct); + } + + private static async Task InsertGameDataAuditAsync(NpgsqlConnection connection, + NpgsqlTransaction transaction, string actor, string action, Guid entryId, object? before, + object? after, DateTimeOffset at, CancellationToken ct) + { + var audit = Audit(actor, action, "GameDataEntry", entryId, before, after, at); + await using var command = new NpgsqlCommand("INSERT INTO audit_events(id,data,created_at) VALUES($1,$2,$3)", connection, transaction); + Add(command, audit.Id, Json(audit), audit.CreatedAt); + await command.ExecuteNonQueryAsync(ct); + } + + private static object AuditRevision(GameDataRevision revision) => new + { + revision.Id, + revision.EntryId, + revision.Sequence, + revision.FileName, + revision.Length, + revision.Sha256, + revision.SourceType, + revision.SourceId, + revision.CreatedAt + }; + + private static void ValidateRevision(Guid gameId, Guid entryId, GameDataRevision revision) + { + if (revision.Id == Guid.Empty || revision.EntryId != entryId || revision.Length <= 0 || + revision.Sha256.Length != 64 || revision.Sha256.Any(character => !Uri.IsHexDigit(character))) + throw new ArgumentException("Game-data revision evidence is invalid.", nameof(revision)); + var requiredPrefix = $"{gameId:N}/{entryId:N}/"; + if (!revision.Location.StartsWith(requiredPrefix, StringComparison.Ordinal) || + revision.Location.Contains("..", StringComparison.Ordinal)) + throw new ArgumentException("Game-data revision location is outside its app-owned entry.", nameof(revision)); + } +} diff --git a/src/Ludarium.Infrastructure/PostgresStore.LibraryExperience.cs b/src/Ludarium.Infrastructure/PostgresStore.LibraryExperience.cs new file mode 100644 index 0000000..3ceaf3b --- /dev/null +++ b/src/Ludarium.Infrastructure/PostgresStore.LibraryExperience.cs @@ -0,0 +1,503 @@ +using System.Globalization; +using Ludarium.Application; +using Ludarium.Domain; +using Npgsql; + +namespace Ludarium.Infrastructure; + +public sealed partial class PostgresStore +{ + internal static readonly (string Id, string Name, string Category, string[] Aliases, bool MetadataOnly)[] BuiltInPlatforms = + [ + ("windows", "Windows PC", "Computer", ["pc", "win", "windows-pc"], false), + ("dos", "MS-DOS", "Computer", ["msdos"], true), + ("linux", "Linux", "Computer", [], true), + ("macintosh", "Apple Macintosh", "Computer", ["mac", "macos"], true), + ("amiga", "Commodore Amiga", "Computer", [], true), + ("amiga-cd32", "Amiga CD32", "Console", ["cd32"], true), + ("c64", "Commodore 64", "Computer", ["commodore-64"], true), + ("zx-spectrum", "ZX Spectrum", "Computer", ["zxspectrum"], true), + ("atari-st", "Atari ST", "Computer", ["atarist"], true), + ("pc-98", "NEC PC-9800", "Computer", ["pc98"], true), + ("flash", "Adobe Flash", "Computer", ["swf"], true), + ("scummvm", "ScummVM", "Computer", [], true), + ("nes", "Nintendo Entertainment System", "Nintendo", ["famicom"], false), + ("fds", "Famicom Disk System", "Nintendo", [], true), + ("snes", "Super Nintendo Entertainment System", "Nintendo", ["sfc", "super-famicom"], false), + ("n64", "Nintendo 64", "Nintendo", ["nintendo-64"], false), + ("n64dd", "Nintendo 64DD", "Nintendo", [], true), + ("gamecube", "Nintendo GameCube", "Nintendo", ["gc", "ngc"], true), + ("wii", "Nintendo Wii", "Nintendo", [], true), + ("wii-u", "Nintendo Wii U", "Nintendo", ["wiiu"], true), + ("switch", "Nintendo Switch", "Nintendo", ["nsw"], true), + ("gb", "Nintendo Game Boy", "Nintendo", ["game-boy"], false), + ("gbc", "Nintendo Game Boy Color", "Nintendo", ["game-boy-color"], false), + ("gba", "Nintendo Game Boy Advance", "Nintendo", ["game-boy-advance"], false), + ("nds", "Nintendo DS", "Nintendo", ["ds"], false), + ("3ds", "Nintendo 3DS", "Nintendo", ["n3ds"], true), + ("virtual-boy", "Nintendo Virtual Boy", "Nintendo", ["vb"], true), + ("pokemon-mini", "Pokémon Mini", "Nintendo", [], true), + ("psx", "Sony PlayStation", "PlayStation", ["ps1", "playstation"], true), + ("ps2", "Sony PlayStation 2", "PlayStation", ["playstation-2"], true), + ("ps3", "Sony PlayStation 3", "PlayStation", ["playstation-3"], true), + ("ps4", "Sony PlayStation 4", "PlayStation", ["playstation-4"], true), + ("ps5", "Sony PlayStation 5", "PlayStation", ["playstation-5"], true), + ("psp", "PlayStation Portable", "PlayStation", [], true), + ("psvita", "PlayStation Vita", "PlayStation", ["vita"], true), + ("xbox", "Microsoft Xbox", "Xbox", ["xbox-original"], true), + ("xbox-360", "Microsoft Xbox 360", "Xbox", ["xbox360"], true), + ("xbox-one", "Microsoft Xbox One", "Xbox", ["xboxone"], true), + ("xbox-series", "Xbox Series X|S", "Xbox", ["series-x", "series-s"], true), + ("master-system", "Sega Master System", "Sega", ["sms"], false), + ("genesis", "Sega Genesis / Mega Drive", "Sega", ["megadrive", "mega-drive"], false), + ("sega-cd", "Sega CD / Mega-CD", "Sega", ["megacd"], true), + ("sega-32x", "Sega 32X", "Sega", ["32x"], true), + ("saturn", "Sega Saturn", "Sega", [], true), + ("dreamcast", "Sega Dreamcast", "Sega", ["dc"], true), + ("game-gear", "Sega Game Gear", "Sega", ["gamegear", "gg"], false), + ("sg-1000", "Sega SG-1000", "Sega", [], false), + ("atari-2600", "Atari 2600", "Atari", ["a2600"], true), + ("atari-5200", "Atari 5200", "Atari", ["a5200"], true), + ("atari-7800", "Atari 7800", "Atari", ["a7800"], true), + ("atari-jaguar", "Atari Jaguar", "Atari", ["jaguar"], true), + ("atari-lynx", "Atari Lynx", "Atari", ["lynx"], true), + ("neo-geo", "SNK Neo Geo", "SNK", ["neogeo"], true), + ("neo-geo-cd", "SNK Neo Geo CD", "SNK", ["neogeocd"], true), + ("neo-geo-pocket", "Neo Geo Pocket", "SNK", ["ngp"], true), + ("neo-geo-pocket-color", "Neo Geo Pocket Color", "SNK", ["ngpc"], true), + ("pc-engine", "NEC PC Engine / TurboGrafx-16", "NEC", ["tg16", "turbografx-16"], true), + ("pc-engine-cd", "PC Engine CD", "NEC", ["tg-cd", "turbografx-cd"], true), + ("pc-fx", "NEC PC-FX", "NEC", [], true), + ("3do", "3DO Interactive Multiplayer", "Other", [], true), + ("colecovision", "ColecoVision", "Other", [], true), + ("intellivision", "Mattel Intellivision", "Other", [], true), + ("odyssey2", "Magnavox Odyssey²", "Other", ["videopac"], true), + ("vectrex", "GCE Vectrex", "Other", [], true), + ("wonderswan", "Bandai WonderSwan", "Other", ["ws"], true), + ("wonderswan-color", "Bandai WonderSwan Color", "Other", ["wsc"], true), + ("arcade", "Arcade", "Arcade", ["mame", "fbneo"], true), + ("pico-8", "PICO-8", "Fantasy console", ["pico8"], true) + ]; + + private async Task SeedBuiltInPlatformsAsync(CancellationToken ct) + { + await using var connection = await dataSource.OpenConnectionAsync(ct); + await using var transaction = await connection.BeginTransactionAsync(ct); + foreach (var item in BuiltInPlatforms) + { + var definition = new PlatformDefinition(item.Id, item.Name, item.Category, item.Aliases, + item.MetadataOnly, false, true, DateTimeOffset.UtcNow); + await using var command = new NpgsqlCommand("INSERT INTO platform_definitions(id,custom,enabled,version,data,updated_at) VALUES($1,false,true,1,$2,$3) ON CONFLICT(id) DO NOTHING", connection, transaction); + Add(command, definition.Id, Json(definition), definition.UpdatedAt); + await command.ExecuteNonQueryAsync(ct); + } + await transaction.CommitAsync(ct); + } + + public async Task<IReadOnlyList<GameGroup>> ListCollectionsAsync(CancellationToken ct) + { + var collections = await ListJsonAsync<GameGroup>("SELECT data FROM game_collections ORDER BY (data->>'pinned')::boolean DESC,lower(name)", ct); + var result = new List<GameGroup>(collections.Count); + foreach (var collection in collections) + result.Add(collection with { GameCount = await CountCollectionGamesAsync(collection, ct) }); + return result; + } + + public async Task<GameGroup?> GetCollectionAsync(Guid id, CancellationToken ct) + { + var collection = await SingleJsonAsync<GameGroup>("SELECT data FROM game_collections WHERE id=$1", id, ct); + return collection is null ? null : collection with { GameCount = await CountCollectionGamesAsync(collection, ct) }; + } + + public async Task<GameGroup> CreateCollectionAsync(GameCollectionInput input, string actor, CancellationToken ct) + { + input = LibraryExperiencePolicy.Normalize(input); + var now = DateTimeOffset.UtcNow; + var collection = new GameGroup(Guid.NewGuid(), input.Name, input.Description, input.Kind, input.Rule, + input.Pinned, now, now); + var audit = Audit(actor, "GameCollectionCreated", "GameCollection", collection.Id, null, collection, now); + try + { + await ExecuteTransactionAsync(ct, + ("INSERT INTO game_collections(id,name,kind,version,data,created_at,updated_at) VALUES($1,$2,$3,$4,$5,$6,$7)", [collection.Id, collection.Name, (int)collection.Kind, collection.Version, Json(collection), now, now]), + ("INSERT INTO audit_events(id,data,created_at) VALUES($1,$2,$3)", [audit.Id, Json(audit), now])); + } + catch (PostgresException exception) when (exception.SqlState == PostgresErrorCodes.UniqueViolation) + { + throw new ResourceConflictException("A collection with this name already exists."); + } + return collection; + } + + public async Task<GameGroup> UpdateCollectionAsync(Guid id, GameCollectionInput input, long expectedVersion, string actor, CancellationToken ct) + { + input = LibraryExperiencePolicy.Normalize(input); + var before = await SingleJsonAsync<GameGroup>("SELECT data FROM game_collections WHERE id=$1", id, ct) ?? throw new KeyNotFoundException("Collection not found."); + if (before.Version != expectedVersion) throw new ConcurrencyException("The collection changed after it was loaded."); + var after = before with { Name = input.Name, Description = input.Description, Kind = input.Kind, Rule = input.Rule, Pinned = input.Pinned, UpdatedAt = DateTimeOffset.UtcNow, Version = before.Version + 1, GameCount = 0 }; + await using var connection = await dataSource.OpenConnectionAsync(ct); + await using var transaction = await connection.BeginTransactionAsync(ct); + try + { + await using var update = new NpgsqlCommand("UPDATE game_collections SET name=$2,kind=$3,version=$4,data=$5,updated_at=$6 WHERE id=$1 AND version=$7", connection, transaction); + Add(update, id, after.Name, (int)after.Kind, after.Version, Json(after), after.UpdatedAt, expectedVersion); + if (await update.ExecuteNonQueryAsync(ct) != 1) throw new ConcurrencyException("The collection changed while it was being saved."); + if (after.Kind != CollectionKind.Static) + { + await using var clear = new NpgsqlCommand("DELETE FROM game_collection_members WHERE collection_id=$1", connection, transaction); + Add(clear, id); await clear.ExecuteNonQueryAsync(ct); + } + var audit = Audit(actor, "GameCollectionUpdated", "GameCollection", id, before, after, after.UpdatedAt); + await using var auditCommand = new NpgsqlCommand("INSERT INTO audit_events(id,data,created_at) VALUES($1,$2,$3)", connection, transaction); + Add(auditCommand, audit.Id, Json(audit), audit.CreatedAt); await auditCommand.ExecuteNonQueryAsync(ct); + await transaction.CommitAsync(ct); + } + catch (PostgresException exception) when (exception.SqlState == PostgresErrorCodes.UniqueViolation) + { + throw new ResourceConflictException("A collection with this name already exists."); + } + return after with { GameCount = await CountCollectionGamesAsync(after, ct) }; + } + + public async Task DeleteCollectionAsync(Guid id, long expectedVersion, string actor, CancellationToken ct) + { + var before = await SingleJsonAsync<GameGroup>("SELECT data FROM game_collections WHERE id=$1", id, ct) ?? throw new KeyNotFoundException("Collection not found."); + if (before.Version != expectedVersion) throw new ConcurrencyException("The collection changed after it was loaded."); + var audit = Audit(actor, "GameCollectionDeleted", "GameCollection", id, before, null, DateTimeOffset.UtcNow); + await ExecuteTransactionAsync(ct, + ("DELETE FROM game_collection_members WHERE collection_id=$1", [id]), + ("DELETE FROM game_collections WHERE id=$1 AND version=$2", [id, expectedVersion]), + ("INSERT INTO audit_events(id,data,created_at) VALUES($1,$2,$3)", [audit.Id, Json(audit), audit.CreatedAt])); + } + + public async Task AddCollectionGameAsync(Guid collectionId, Guid gameId, string actor, CancellationToken ct) + { + var collection = await GetCollectionAsync(collectionId, ct) ?? throw new KeyNotFoundException("Collection not found."); + if (collection.Kind != CollectionKind.Static) throw new ResourceConflictException("Smart and virtual collections are populated by their rules."); + await RequireGameAsync(gameId, ct); + var now = DateTimeOffset.UtcNow; + var audit = Audit(actor, "CollectionGameAdded", "GameCollection", collectionId, null, new { gameId }, now); + await ExecuteTransactionAsync(ct, + ("INSERT INTO game_collection_members(collection_id,game_id,added_at) VALUES($1,$2,$3) ON CONFLICT DO NOTHING", [collectionId, gameId, now]), + ("INSERT INTO audit_events(id,data,created_at) VALUES($1,$2,$3)", [audit.Id, Json(audit), now])); + } + + public async Task RemoveCollectionGameAsync(Guid collectionId, Guid gameId, string actor, CancellationToken ct) + { + var collection = await GetCollectionAsync(collectionId, ct) ?? throw new KeyNotFoundException("Collection not found."); + if (collection.Kind != CollectionKind.Static) throw new ResourceConflictException("Smart and virtual collections are populated by their rules."); + var now = DateTimeOffset.UtcNow; + var audit = Audit(actor, "CollectionGameRemoved", "GameCollection", collectionId, new { gameId }, null, now); + await ExecuteTransactionAsync(ct, + ("DELETE FROM game_collection_members WHERE collection_id=$1 AND game_id=$2", [collectionId, gameId]), + ("INSERT INTO audit_events(id,data,created_at) VALUES($1,$2,$3)", [audit.Id, Json(audit), now])); + } + + public async Task<Page<Game>> SearchCollectionGamesAsync(Guid collectionId, int page, int pageSize, CancellationToken ct) + { + var collection = await GetCollectionAsync(collectionId, ct) ?? throw new KeyNotFoundException("Collection not found."); + page = Math.Max(1, page); pageSize = Math.Clamp(pageSize, 1, 200); + var (where, values) = CollectionFilter(collection); + await using var count = dataSource.CreateCommand("SELECT count(*) FROM games g" + where); Add(count, values); + var total = Convert.ToInt64(await count.ExecuteScalarAsync(ct), CultureInfo.InvariantCulture); + await using var command = dataSource.CreateCommand("SELECT g.data FROM games g" + where + $" ORDER BY lower(g.title),g.id LIMIT {pageSize} OFFSET {(page - 1) * pageSize}"); Add(command, values); + return new(await ReadAsync<Game>(command, ct), page, pageSize, total); + } + + private async Task<long> CountCollectionGamesAsync(GameGroup collection, CancellationToken ct) + { + var (where, values) = CollectionFilter(collection); + await using var command = dataSource.CreateCommand("SELECT count(*) FROM games g" + where); Add(command, values); + return Convert.ToInt64(await command.ExecuteScalarAsync(ct), CultureInfo.InvariantCulture); + } + + private static (string Sql, object[] Values) CollectionFilter(GameGroup collection) + { + var predicates = new List<string> { CatalogVisibility.SqlPredicate("g") }; + var values = new List<object>(); + void AddValue(object value, string predicate) { values.Add(value); predicates.Add(string.Format(CultureInfo.InvariantCulture, predicate, values.Count)); } + if (collection.Kind == CollectionKind.Static) + AddValue(collection.Id, "EXISTS (SELECT 1 FROM game_collection_members cm WHERE cm.collection_id=${0} AND cm.game_id=g.id)"); + else + { + var rule = collection.Rule ?? new CollectionRule(); + if (!string.IsNullOrWhiteSpace(rule.Query)) AddValue($"%{rule.Query.Trim()}%", "(g.title ILIKE ${0} OR EXISTS (SELECT 1 FROM metadata_claims mc WHERE mc.entity_id=g.id AND mc.value ILIKE ${0}))"); + if (!string.IsNullOrWhiteSpace(rule.Platform)) AddValue(rule.Platform.Trim(), "EXISTS (SELECT 1 FROM releases r WHERE r.game_id=g.id AND r.data->>'platform'=${0})"); + foreach (var tag in rule.Tags ?? []) AddValue(tag, "EXISTS (SELECT 1 FROM game_tags gt WHERE gt.game_id=g.id AND gt.normalized_name=${0})"); + if (rule.Status is not null) AddValue((int)rule.Status.Value, "coalesce((SELECT gus.status FROM game_user_states gus WHERE gus.game_id=g.id),0)=${0}"); + if (rule.Favorite is not null) AddValue(rule.Favorite.Value, "coalesce((SELECT gus.favorite FROM game_user_states gus WHERE gus.game_id=g.id),false)=${0}"); + if (!string.IsNullOrWhiteSpace(rule.MetadataField)) + { + values.Add(rule.MetadataField.Trim()); + var predicate = $"EXISTS (SELECT 1 FROM metadata_claims mc WHERE mc.entity_id=g.id AND mc.field=${values.Count}"; + if (!string.IsNullOrWhiteSpace(rule.MetadataValue)) + { + values.Add($"%{rule.MetadataValue.Trim()}%"); + predicate += $" AND mc.value ILIKE ${values.Count}"; + } + predicates.Add(predicate + ")"); + } + } + return (" WHERE " + string.Join(" AND ", predicates), [.. values]); + } + + public async Task<IReadOnlyList<TagSummary>> ListTagsAsync(CancellationToken ct) + { + await using var command = dataSource.CreateCommand("SELECT min(data->>'name'),normalized_name,count(DISTINCT game_id) FROM game_tags GROUP BY normalized_name ORDER BY normalized_name"); + var result = new List<TagSummary>(); + await using var reader = await command.ExecuteReaderAsync(ct); + while (await reader.ReadAsync(ct)) result.Add(new(reader.GetString(0), reader.GetString(1), reader.GetInt64(2))); + return result; + } + + public Task<IReadOnlyList<GameTag>> ListGameTagsAsync(Guid gameId, CancellationToken ct) => + ListJsonAsync<GameTag>("SELECT data FROM game_tags WHERE game_id=$1 ORDER BY normalized_name,created_at", ct, gameId); + + public async Task<GameTag> AddGameTagAsync(Guid gameId, string name, string actor, CancellationToken ct) + { + await RequireGameAsync(gameId, ct); + var normalized = LibraryExperiencePolicy.NormalizeTag(name); + var existing = await SingleJsonAsync<GameTag>("SELECT data FROM game_tags WHERE game_id=$1 AND normalized_name=$2 AND data->>'sourceType'='Manual' LIMIT 1", ct, gameId, normalized); + if (existing is not null) return existing; + var tag = new GameTag(Guid.NewGuid(), gameId, name.Trim(), normalized, "Manual", actor, + Confidence.Deterministic, true, DateTimeOffset.UtcNow); + var audit = Audit(actor, "GameTagAdded", "Game", gameId, null, tag, tag.CreatedAt); + await ExecuteTransactionAsync(ct, + ("INSERT INTO game_tags(id,game_id,normalized_name,data,created_at) VALUES($1,$2,$3,$4,$5)", [tag.Id, gameId, normalized, Json(tag), tag.CreatedAt]), + ("INSERT INTO audit_events(id,data,created_at) VALUES($1,$2,$3)", [audit.Id, Json(audit), audit.CreatedAt])); + return tag; + } + + public async Task DeleteGameTagAsync(Guid gameId, Guid tagId, string actor, CancellationToken ct) + { + var before = await SingleJsonAsync<GameTag>("SELECT data FROM game_tags WHERE id=$1 AND game_id=$2", ct, tagId, gameId) ?? throw new KeyNotFoundException("Tag not found."); + if (!before.ManualLock) throw new ResourceConflictException("Provider tags remain as provenance and cannot be deleted manually."); + var audit = Audit(actor, "GameTagDeleted", "Game", gameId, before, null, DateTimeOffset.UtcNow); + await ExecuteTransactionAsync(ct, + ("DELETE FROM game_tags WHERE id=$1 AND game_id=$2", [tagId, gameId]), + ("INSERT INTO audit_events(id,data,created_at) VALUES($1,$2,$3)", [audit.Id, Json(audit), audit.CreatedAt])); + } + + public async Task<GameUserState> GetGameUserStateAsync(Guid gameId, CancellationToken ct) + { + await RequireGameAsync(gameId, ct); + return await SingleJsonAsync<GameUserState>("SELECT data FROM game_user_states WHERE game_id=$1", gameId, ct) ?? GameUserState.Empty(gameId); + } + + public async Task<GameUserState> SaveGameUserStateAsync(Guid gameId, GameUserStateInput input, long expectedVersion, string actor, CancellationToken ct) + { + await RequireGameAsync(gameId, ct); + input = LibraryExperiencePolicy.Normalize(input); + var before = await SingleJsonAsync<GameUserState>("SELECT data FROM game_user_states WHERE game_id=$1", gameId, ct); + var currentVersion = before?.Version ?? 0; + if (currentVersion != expectedVersion) throw new ConcurrencyException("Personal game state changed after it was loaded."); + var now = DateTimeOffset.UtcNow; + var after = new GameUserState(gameId, input.Favorite, input.Status, input.Rating, input.Difficulty, + input.CompletionPercent, input.Notes, input.PlayCount, input.LastPlayedAt, now, currentVersion + 1); + var audit = Audit(actor, "GameUserStateSaved", "Game", gameId, before, after, now); + await using var connection = await dataSource.OpenConnectionAsync(ct); await using var transaction = await connection.BeginTransactionAsync(ct); + await using var command = before is null + ? new NpgsqlCommand("INSERT INTO game_user_states(game_id,status,favorite,version,data,updated_at) VALUES($1,$2,$3,$4,$5,$6)", connection, transaction) + : new NpgsqlCommand("UPDATE game_user_states SET status=$2,favorite=$3,version=$4,data=$5,updated_at=$6 WHERE game_id=$1 AND version=$7", connection, transaction); + Add(command, gameId, (int)after.Status, after.Favorite, after.Version, Json(after), now); + if (before is not null) command.Parameters.AddWithValue(expectedVersion); + if (await command.ExecuteNonQueryAsync(ct) != 1) throw new ConcurrencyException("Personal game state changed while it was being saved."); + await using var auditCommand = new NpgsqlCommand("INSERT INTO audit_events(id,data,created_at) VALUES($1,$2,$3)", connection, transaction); + Add(auditCommand, audit.Id, Json(audit), now); await auditCommand.ExecuteNonQueryAsync(ct); + await transaction.CommitAsync(ct); return after; + } + + public async Task<GameUserState> RecordGamePlayedAsync(Guid gameId, string actor, CancellationToken ct) + { + await RequireGameAsync(gameId, ct); + var now = DateTimeOffset.UtcNow; + await using var connection = await dataSource.OpenConnectionAsync(ct); + await using var transaction = await connection.BeginTransactionAsync(ct); + await using (var gameLock = new NpgsqlCommand("SELECT pg_advisory_xact_lock(hashtextextended(($1)::text,0))", connection, transaction)) + { + gameLock.Parameters.AddWithValue(gameId); + await gameLock.ExecuteNonQueryAsync(ct); + } + GameUserState? before; + await using (var read = new NpgsqlCommand("SELECT data FROM game_user_states WHERE game_id=$1 FOR UPDATE", connection, transaction)) + { + read.Parameters.AddWithValue(gameId); + var value = await read.ExecuteScalarAsync(ct); + before = value is null or DBNull ? null : System.Text.Json.JsonSerializer.Deserialize<GameUserState>((string)value, JsonOptions); + } + var current = before ?? GameUserState.Empty(gameId); + var after = current with + { + PlayCount = current.PlayCount == int.MaxValue ? int.MaxValue : current.PlayCount + 1, + LastPlayedAt = now, + UpdatedAt = now, + Version = current.Version + 1, + }; + await using (var save = before is null + ? new NpgsqlCommand("INSERT INTO game_user_states(game_id,status,favorite,version,data,updated_at) VALUES($1,$2,$3,$4,$5,$6)", connection, transaction) + : new NpgsqlCommand("UPDATE game_user_states SET status=$2,favorite=$3,version=$4,data=$5,updated_at=$6 WHERE game_id=$1", connection, transaction)) + { + Add(save, gameId, (int)after.Status, after.Favorite, after.Version, Json(after), now); + await save.ExecuteNonQueryAsync(ct); + } + var audit = Audit(actor, "GamePlayed", "Game", gameId, before, after, now); + await using var auditCommand = new NpgsqlCommand("INSERT INTO audit_events(id,data,created_at) VALUES($1,$2,$3)", connection, transaction); + Add(auditCommand, audit.Id, Json(audit), now); + await auditCommand.ExecuteNonQueryAsync(ct); + await transaction.CommitAsync(ct); + return after; + } + + public Task<IReadOnlyList<RecentlyPlayedGame>> ListRecentlyPlayedGamesAsync(int limit, CancellationToken ct) + { + limit = Math.Clamp(limit, 1, 12); + return ListJsonAsync<RecentlyPlayedGame>($""" + SELECT jsonb_build_object('game',g.data,'state',gus.data) + FROM game_user_states gus + JOIN games g ON g.id=gus.game_id + WHERE gus.data->>'lastPlayedAt' IS NOT NULL AND {CatalogVisibility.SqlPredicate()} + ORDER BY (gus.data->>'lastPlayedAt')::timestamptz DESC,g.title + LIMIT {limit} + """, ct); + } + + public Task<IReadOnlyList<GameRelation>> ListGameRelationsAsync(Guid gameId, CancellationToken ct) => + ListJsonAsync<GameRelation>("SELECT data FROM game_relations WHERE game_id=$1 ORDER BY kind,created_at", ct, gameId); + + public async Task<GameRelation> AddGameRelationAsync(Guid gameId, GameRelationInput input, string actor, CancellationToken ct) + { + await RequireGameAsync(gameId, ct); input = LibraryExperiencePolicy.Normalize(input); + if (input.RelatedGameId is not null) await RequireGameAsync(input.RelatedGameId.Value, ct); + if (input.RelatedGameId == gameId) throw new ArgumentException("A game cannot relate to itself."); + if (input.ArtifactId is not null && await SingleJsonAsync<Artifact>("SELECT data FROM artifacts WHERE id=$1", input.ArtifactId.Value, ct) is null) throw new KeyNotFoundException("Artifact not found."); + var relation = new GameRelation(Guid.NewGuid(), gameId, input.Kind, input.Title, input.RelatedGameId, + input.ArtifactId, "Manual", actor, Confidence.Deterministic, true, DateTimeOffset.UtcNow); + var audit = Audit(actor, "GameRelationAdded", "Game", gameId, null, relation, relation.CreatedAt); + await ExecuteTransactionAsync(ct, + ("INSERT INTO game_relations(id,game_id,kind,related_game_id,artifact_id,data,created_at) VALUES($1,$2,$3,$4,$5,$6,$7)", [relation.Id, gameId, (int)relation.Kind, DbValue(relation.RelatedGameId), DbValue(relation.ArtifactId), Json(relation), relation.CreatedAt]), + ("INSERT INTO audit_events(id,data,created_at) VALUES($1,$2,$3)", [audit.Id, Json(audit), audit.CreatedAt])); + return relation; + } + + public async Task DeleteGameRelationAsync(Guid gameId, Guid relationId, string actor, CancellationToken ct) + { + var before = await SingleJsonAsync<GameRelation>("SELECT data FROM game_relations WHERE id=$1 AND game_id=$2", ct, relationId, gameId) ?? throw new KeyNotFoundException("Relationship not found."); + if (!before.ManualLock) throw new ResourceConflictException("Provider relationships remain as provenance and cannot be deleted manually."); + var audit = Audit(actor, "GameRelationDeleted", "Game", gameId, before, null, DateTimeOffset.UtcNow); + await ExecuteTransactionAsync(ct, + ("DELETE FROM game_relations WHERE id=$1 AND game_id=$2", [relationId, gameId]), + ("INSERT INTO audit_events(id,data,created_at) VALUES($1,$2,$3)", [audit.Id, Json(audit), audit.CreatedAt])); + } + + public Task<IReadOnlyList<GameMedia>> ListGameMediaAsync(Guid gameId, CancellationToken ct) => + ListJsonAsync<GameMedia>("SELECT data FROM game_media WHERE game_id=$1 ORDER BY kind,selected DESC,created_at", ct, gameId); + + public async Task<GameMedia> AddExternalGameMediaAsync(Guid gameId, GameMediaInput input, string actor, CancellationToken ct) + { + await RequireGameAsync(gameId, ct); input = LibraryExperiencePolicy.Normalize(input); + if (input.Location is null) throw new ArgumentException("An external media location is required."); + var selected = await SingleJsonAsync<GameMedia>("SELECT data FROM game_media WHERE game_id=$1 AND kind=$2 AND selected LIMIT 1", ct, gameId, (int)input.Kind) is null; + var media = new GameMedia(Guid.NewGuid(), gameId, input.Kind, input.Title, "External", + input.Provider, input.ExternalId, input.Location, null, null, null, false, selected, DateTimeOffset.UtcNow); + await UpsertGameMediaAsync(media, ct); + var audit = Audit(actor, "ExternalGameMediaAdded", "Game", gameId, null, media, media.CreatedAt); + await ExecuteAsync("INSERT INTO audit_events(id,data,created_at) VALUES($1,$2,$3)", ct, audit.Id, Json(audit), audit.CreatedAt); + return media; + } + + public async Task UpsertGameMediaAsync(GameMedia media, CancellationToken ct) + { + await RequireGameAsync(media.GameId, ct); + await using var connection = await dataSource.OpenConnectionAsync(ct); await using var transaction = await connection.BeginTransactionAsync(ct); + if (media.Selected) + { + await using var deselect = new NpgsqlCommand("UPDATE game_media SET selected=false,data=jsonb_set(data,'{selected}','false'::jsonb),updated_at=now() WHERE game_id=$1 AND kind=$2 AND id<>$3 AND selected", connection, transaction); + Add(deselect, media.GameId, (int)media.Kind, media.Id); await deselect.ExecuteNonQueryAsync(ct); + } + await using var upsert = new NpgsqlCommand("INSERT INTO game_media(id,game_id,kind,app_owned,selected,data,created_at) VALUES($1,$2,$3,$4,$5,$6,$7) ON CONFLICT(id) DO UPDATE SET selected=excluded.selected,data=excluded.data,updated_at=now()", connection, transaction); + Add(upsert, media.Id, media.GameId, (int)media.Kind, media.AppOwned, media.Selected, Json(media), media.CreatedAt); + await upsert.ExecuteNonQueryAsync(ct); await transaction.CommitAsync(ct); + } + + public Task<GameMedia?> GetGameMediaAsync(Guid mediaId, CancellationToken ct) => + SingleJsonAsync<GameMedia>("SELECT data FROM game_media WHERE id=$1", mediaId, ct); + + public async Task DeleteGameMediaAsync(Guid gameId, Guid mediaId, string actor, CancellationToken ct) + { + var before = await SingleJsonAsync<GameMedia>("SELECT data FROM game_media WHERE id=$1 AND game_id=$2", ct, mediaId, gameId) ?? throw new KeyNotFoundException("Media not found."); + var audit = Audit(actor, "GameMediaDeleted", "Game", gameId, before, null, DateTimeOffset.UtcNow); + await ExecuteTransactionAsync(ct, + ("DELETE FROM game_media WHERE id=$1 AND game_id=$2", [mediaId, gameId]), + ("INSERT INTO audit_events(id,data,created_at) VALUES($1,$2,$3)", [audit.Id, Json(audit), audit.CreatedAt])); + } + + public Task<GameAchievementProgress?> GetAchievementProgressAsync(Guid gameId, CancellationToken ct) => + SingleJsonAsync<GameAchievementProgress>("SELECT data FROM game_achievement_progress WHERE game_id=$1", gameId, ct); + + public async Task UpsertAchievementProgressAsync(GameAchievementProgress progress, CancellationToken ct) => + await ExecuteAsync("INSERT INTO game_achievement_progress(game_id,provider,data,updated_at) VALUES($1,$2,$3,$4) ON CONFLICT(game_id) DO UPDATE SET provider=excluded.provider,data=excluded.data,updated_at=excluded.updated_at", ct, + progress.GameId, progress.Provider, Json(progress), progress.UpdatedAt); + + public Task<IReadOnlyList<PlatformDefinition>> ListPlatformDefinitionsAsync(CancellationToken ct) => + ListJsonAsync<PlatformDefinition>("SELECT data FROM platform_definitions ORDER BY custom DESC,lower(data->>'category'),lower(data->>'name')", ct); + + public async Task<PlatformDefinition> CreatePlatformDefinitionAsync(PlatformDefinitionInput input, string actor, CancellationToken ct) + { + input = LibraryExperiencePolicy.Normalize(input); + var definition = new PlatformDefinition(input.Id, input.Name, input.Category, input.Aliases, + true, true, input.Enabled, DateTimeOffset.UtcNow); + var audit = Audit(actor, "CustomPlatformCreated", "PlatformDefinition", Guid.Empty, null, definition, definition.UpdatedAt); + try + { + await ExecuteTransactionAsync(ct, + ("INSERT INTO platform_definitions(id,custom,enabled,version,data,updated_at) VALUES($1,true,$2,1,$3,$4)", [definition.Id, definition.Enabled, Json(definition), definition.UpdatedAt]), + ("INSERT INTO audit_events(id,data,created_at) VALUES($1,$2,$3)", [audit.Id, Json(audit), audit.CreatedAt])); + } + catch (PostgresException exception) when (exception.SqlState == PostgresErrorCodes.UniqueViolation) + { + throw new ResourceConflictException("A platform with this id already exists."); + } + return definition; + } + + public async Task<PlatformDefinition> UpdatePlatformDefinitionAsync(string id, PlatformDefinitionInput input, long expectedVersion, string actor, CancellationToken ct) + { + input = LibraryExperiencePolicy.Normalize(input); + if (!string.Equals(id, input.Id, StringComparison.Ordinal)) throw new ArgumentException("A platform id cannot be changed."); + var before = await SinglePlatformAsync(id, ct) ?? throw new KeyNotFoundException("Platform not found."); + if (!before.Custom) throw new ResourceConflictException("Built-in platform definitions are immutable; create a custom alias instead."); + if (before.Version != expectedVersion) throw new ConcurrencyException("The platform changed after it was loaded."); + var after = before with { Name = input.Name, Category = input.Category, Aliases = input.Aliases, Enabled = input.Enabled, UpdatedAt = DateTimeOffset.UtcNow, Version = before.Version + 1 }; + var audit = Audit(actor, "CustomPlatformUpdated", "PlatformDefinition", Guid.Empty, before, after, after.UpdatedAt); + await using var connection = await dataSource.OpenConnectionAsync(ct); + await using var transaction = await connection.BeginTransactionAsync(ct); + await using var command = new NpgsqlCommand("UPDATE platform_definitions SET enabled=$2,version=$3,data=$4,updated_at=$5 WHERE id=$1 AND version=$6", connection, transaction); + Add(command, id, after.Enabled, after.Version, Json(after), after.UpdatedAt, expectedVersion); + if (await command.ExecuteNonQueryAsync(ct) != 1) throw new ConcurrencyException("The platform changed while it was being saved."); + await using var auditCommand = new NpgsqlCommand("INSERT INTO audit_events(id,data,created_at) VALUES($1,$2,$3)", connection, transaction); + Add(auditCommand, audit.Id, Json(audit), audit.CreatedAt); + await auditCommand.ExecuteNonQueryAsync(ct); + await transaction.CommitAsync(ct); + return after; + } + + private async Task<PlatformDefinition?> SinglePlatformAsync(string id, CancellationToken ct) + { + await using var command = dataSource.CreateCommand("SELECT data FROM platform_definitions WHERE id=$1"); Add(command, id); + var value = await command.ExecuteScalarAsync(ct); + return value is null or DBNull ? null : Deserialize<PlatformDefinition>((string)value); + } + + private async Task RequireGameAsync(Guid gameId, CancellationToken ct) + { + if (await SingleJsonAsync<Game>("SELECT data FROM games WHERE id=$1", gameId, ct) is null) + throw new KeyNotFoundException("Game not found."); + } + + private static AuditEvent Audit(string actor, string action, string entityType, + Guid entityId, object? before, object? after, DateTimeOffset at) => + new(Guid.NewGuid(), actor, action, entityType, entityId, + before is null ? null : System.Text.Json.JsonSerializer.Serialize(before, before.GetType(), JsonOptions), + after is null ? null : System.Text.Json.JsonSerializer.Serialize(after, after.GetType(), JsonOptions), + Guid.NewGuid(), at); +} diff --git a/src/Ludarium.Infrastructure/PostgresStore.cs b/src/Ludarium.Infrastructure/PostgresStore.cs new file mode 100644 index 0000000..f15175e --- /dev/null +++ b/src/Ludarium.Infrastructure/PostgresStore.cs @@ -0,0 +1,2085 @@ +using System.Collections.ObjectModel; +using System.Globalization; +using System.Text.Json; +using Ludarium.Application; +using Ludarium.Domain; +using Npgsql; +using NpgsqlTypes; + +namespace Ludarium.Infrastructure; + +public sealed partial class PostgresStore(NpgsqlDataSource dataSource) : ILudariumStore, ICatalogStore, + ILibraryExperienceStore, IGameDataVaultStore, IBrowserPlayStore, IFirmwareStore +{ + public const int CurrentSchemaVersion = 27; + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + + public async Task InitializeAsync(CancellationToken ct) + { + const string sql = """ + CREATE TABLE IF NOT EXISTS schema_versions(version int PRIMARY KEY, applied_at timestamptz NOT NULL); + INSERT INTO schema_versions VALUES (1, now()) ON CONFLICT DO NOTHING; + CREATE TABLE IF NOT EXISTS library_roots(id uuid PRIMARY KEY, data jsonb NOT NULL, name text NOT NULL, path text NOT NULL UNIQUE, updated_at timestamptz NOT NULL DEFAULT now()); + CREATE TABLE IF NOT EXISTS scan_runs(id uuid PRIMARY KEY, library_id uuid NOT NULL, idempotency_key text NULL, data jsonb NOT NULL, created_at timestamptz NOT NULL); + CREATE UNIQUE INDEX IF NOT EXISTS ux_scan_idempotency ON scan_runs(idempotency_key) WHERE idempotency_key IS NOT NULL; + CREATE TABLE IF NOT EXISTS content_blobs(id uuid PRIMARY KEY, size bigint NOT NULL, sha256 char(64) NOT NULL, data jsonb NOT NULL, UNIQUE(size, sha256)); + CREATE TABLE IF NOT EXISTS artifacts(id uuid PRIMARY KEY, library_id uuid NOT NULL, relative_path text NOT NULL, size bigint NOT NULL, sha256 char(64), data jsonb NOT NULL, updated_at timestamptz NOT NULL, UNIQUE(library_id, relative_path)); + ALTER TABLE artifacts ADD COLUMN IF NOT EXISTS sha1 char(40); + ALTER TABLE artifacts ADD COLUMN IF NOT EXISTS crc32 char(8); + CREATE INDEX IF NOT EXISTS ix_artifacts_search ON artifacts(library_id, relative_path); + CREATE INDEX IF NOT EXISTS ix_artifacts_hash ON artifacts(size, sha256) WHERE sha256 IS NOT NULL; + CREATE TABLE IF NOT EXISTS artifact_evidence(id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, artifact_id uuid NOT NULL, data jsonb NOT NULL, created_at timestamptz NOT NULL DEFAULT now()); + CREATE TABLE IF NOT EXISTS archive_members(id uuid PRIMARY KEY, artifact_id uuid NOT NULL, sequence int NOT NULL DEFAULT 0, path text NOT NULL, data jsonb NOT NULL); + ALTER TABLE archive_members ADD COLUMN IF NOT EXISTS sequence int NOT NULL DEFAULT 0; + ALTER TABLE archive_members DROP CONSTRAINT IF EXISTS archive_members_artifact_id_path_key; + CREATE TABLE IF NOT EXISTS bundles(id uuid PRIMARY KEY, data jsonb NOT NULL, updated_at timestamptz NOT NULL DEFAULT now()); + CREATE TABLE IF NOT EXISTS review_items(id uuid PRIMARY KEY, state text NOT NULL, data jsonb NOT NULL, updated_at timestamptz NOT NULL DEFAULT now()); + CREATE INDEX IF NOT EXISTS ix_review_open ON review_items(state) WHERE state <> 'Resolved'; + CREATE TABLE IF NOT EXISTS findings(id uuid PRIMARY KEY, data jsonb NOT NULL, created_at timestamptz NOT NULL); + CREATE TABLE IF NOT EXISTS integrity_snapshots(id uuid PRIMARY KEY, name text NOT NULL, data jsonb NOT NULL, created_at timestamptz NOT NULL); + CREATE TABLE IF NOT EXISTS audit_events(id uuid PRIMARY KEY, data jsonb NOT NULL, created_at timestamptz NOT NULL); + CREATE TABLE IF NOT EXISTS metadata_claims(id uuid PRIMARY KEY, entity_type text NOT NULL, entity_id uuid NOT NULL, field text NOT NULL, manual_lock boolean NOT NULL, data jsonb NOT NULL, created_at timestamptz NOT NULL); + CREATE INDEX IF NOT EXISTS ix_claim_target ON metadata_claims(entity_type,entity_id,field,created_at DESC); + CREATE TABLE IF NOT EXISTS games(id uuid PRIMARY KEY, title text NOT NULL, version bigint NOT NULL, data jsonb NOT NULL, updated_at timestamptz NOT NULL DEFAULT now()); + CREATE TABLE IF NOT EXISTS releases(id uuid PRIMARY KEY, game_id uuid NOT NULL, title text NOT NULL, version bigint NOT NULL, data jsonb NOT NULL, updated_at timestamptz NOT NULL DEFAULT now()); + CREATE INDEX IF NOT EXISTS ix_releases_game ON releases(game_id); + CREATE TABLE IF NOT EXISTS resolutions(id uuid PRIMARY KEY, review_item_id uuid NOT NULL, data jsonb NOT NULL, undone_at timestamptz, created_at timestamptz NOT NULL); + CREATE TABLE IF NOT EXISTS catalog_sources(id uuid PRIMARY KEY, name text NOT NULL, format text NOT NULL, version text NOT NULL, data jsonb NOT NULL, updated_at timestamptz NOT NULL DEFAULT now()); + CREATE TABLE IF NOT EXISTS catalog_entries(id uuid PRIMARY KEY, source_id uuid NOT NULL, identity text NOT NULL, size bigint, sha256 char(64), sha1 char(40), crc32 char(8), data jsonb NOT NULL, UNIQUE(source_id, identity)); + CREATE INDEX IF NOT EXISTS ix_catalog_hashes ON catalog_entries(sha256,sha1,crc32); + CREATE TABLE IF NOT EXISTS match_candidates(id uuid PRIMARY KEY, artifact_id uuid NOT NULL, catalog_entry_id uuid NOT NULL, state text NOT NULL, data jsonb NOT NULL, created_at timestamptz NOT NULL, UNIQUE(artifact_id,catalog_entry_id)); + CREATE TABLE IF NOT EXISTS jobs(id uuid PRIMARY KEY, kind text NOT NULL, state text NOT NULL, payload jsonb NOT NULL, priority int NOT NULL DEFAULT 0, lease_owner text, lease_until timestamptz, attempts int NOT NULL DEFAULT 0, scheduled_at timestamptz NOT NULL DEFAULT now()); + CREATE INDEX IF NOT EXISTS ix_jobs_runnable ON jobs(priority DESC, scheduled_at) WHERE state IN ('Queued','Leased'); + INSERT INTO schema_versions VALUES (2, now()) ON CONFLICT DO NOTHING; + WITH ranked AS ( + SELECT id,row_number() OVER (PARTITION BY coalesce((data->>'payloadJson')::jsonb->>'id',(data->>'payloadJson')::jsonb->>'Id',id::text) ORDER BY updated_at DESC,id) AS position + FROM review_items WHERE data->>'reason'='Unknown content' + ) DELETE FROM review_items WHERE id IN (SELECT id FROM ranked WHERE position>1); + INSERT INTO schema_versions VALUES (3, now()) ON CONFLICT DO NOTHING; + WITH ranked AS ( + SELECT id,row_number() OVER (PARTITION BY artifact_id,data->>'kind',data->>'value',data->>'source',data->>'sourceVersion' ORDER BY created_at,id) AS position + FROM artifact_evidence + ) DELETE FROM artifact_evidence WHERE id IN (SELECT id FROM ranked WHERE position>1); + INSERT INTO schema_versions VALUES (4, now()) ON CONFLICT DO NOTHING; + CREATE EXTENSION IF NOT EXISTS pg_trgm; + CREATE INDEX IF NOT EXISTS ix_artifacts_path_trgm ON artifacts USING gin(relative_path gin_trgm_ops); + INSERT INTO schema_versions VALUES (5, now()) ON CONFLICT DO NOTHING; + CREATE TABLE IF NOT EXISTS operational_settings(id smallint PRIMARY KEY CHECK(id=1), version bigint NOT NULL, data jsonb NOT NULL, updated_at timestamptz NOT NULL DEFAULT now()); + INSERT INTO schema_versions VALUES (6, now()) ON CONFLICT DO NOTHING; + CREATE TABLE IF NOT EXISTS review_operations(id uuid PRIMARY KEY, data jsonb NOT NULL, undone_at timestamptz, created_at timestamptz NOT NULL); + CREATE INDEX IF NOT EXISTS ix_review_operations_active ON review_operations(created_at DESC) WHERE undone_at IS NULL; + INSERT INTO schema_versions VALUES (7, now()) ON CONFLICT DO NOTHING; + CREATE TABLE IF NOT EXISTS game_artwork(id uuid PRIMARY KEY, game_id uuid NOT NULL, selected boolean NOT NULL, quality_score numeric(5,2) NOT NULL, data jsonb NOT NULL, updated_at timestamptz NOT NULL DEFAULT now()); + CREATE UNIQUE INDEX IF NOT EXISTS ux_game_artwork_selected ON game_artwork(game_id) WHERE selected; + CREATE INDEX IF NOT EXISTS ix_game_artwork_quality ON game_artwork(quality_score,game_id); + INSERT INTO schema_versions VALUES (8, now()) ON CONFLICT DO NOTHING; + ALTER TABLE jobs ADD COLUMN IF NOT EXISTS data jsonb; + ALTER TABLE jobs ADD COLUMN IF NOT EXISTS idempotency_key text; + CREATE UNIQUE INDEX IF NOT EXISTS ux_jobs_idempotency ON jobs(idempotency_key) WHERE idempotency_key IS NOT NULL; + INSERT INTO schema_versions VALUES (9, now()) ON CONFLICT DO NOTHING; + CREATE TABLE IF NOT EXISTS provider_snapshots(id uuid PRIMARY KEY, provider text NOT NULL UNIQUE, data jsonb NOT NULL, updated_at timestamptz NOT NULL DEFAULT now()); + INSERT INTO schema_versions VALUES (10, now()) ON CONFLICT DO NOTHING; + CREATE TABLE IF NOT EXISTS game_library_links(library_id uuid NOT NULL, game_id uuid NOT NULL, PRIMARY KEY(library_id,game_id)); + CREATE INDEX IF NOT EXISTS ix_game_library_game ON game_library_links(game_id); + INSERT INTO schema_versions VALUES (11, now()) ON CONFLICT DO NOTHING; + CREATE TABLE IF NOT EXISTS artifact_overrides(artifact_id uuid PRIMARY KEY, data jsonb NOT NULL, updated_at timestamptz NOT NULL DEFAULT now()); + INSERT INTO schema_versions VALUES (12, now()) ON CONFLICT DO NOTHING; + DO $migration$ + BEGIN + IF NOT EXISTS (SELECT 1 FROM schema_versions WHERE version=13) THEN + WITH support AS ( + SELECT id FROM artifacts + WHERE relative_path ~* '(^|/)(tools?|emulators?|retroarch|bios|firmware|steam|steamapps|depotcache|trainers?|cheats?|crack|redist|redistributables|vcredist|directx|dotnet|support|manuals?|documentation|patches?|updates?)(/|$)' + OR relative_path ~* '^PC/(FMT-v[0-9]|Steam(/|$)|[^/]*(DLC[[:space:]._-]*Unlocker|Mod[[:space:]._-]*Manager|Save[[:space:]._-]*Editor)[^/]*/?)' + ), ignored AS ( + UPDATE artifacts a SET data=jsonb_set(jsonb_set(jsonb_set(jsonb_set(a.data, + '{state}','3'::jsonb),'{mediaType}','6'::jsonb),'{confidence}','4'::jsonb),'{platform}','null'::jsonb),updated_at=now() + FROM support s WHERE a.id=s.id RETURNING a.id + ) + UPDATE review_items r SET state='Resolved', + data=jsonb_set(jsonb_set(r.data,'{state}',to_jsonb(2)),'{resolution}','"Ignored non-game content"'::jsonb),updated_at=now() + WHERE r.state<>'Resolved' AND r.data->>'reason'='Unknown content' + AND EXISTS (SELECT 1 FROM ignored i WHERE r.data->>'payloadJson' ILIKE '%' || i.id::text || '%'); + INSERT INTO schema_versions VALUES (13, now()); + END IF; + END + $migration$; + DO $migration$ + BEGIN + IF NOT EXISTS (SELECT 1 FROM schema_versions WHERE version=14) THEN + UPDATE games + SET title='Burnout Legends', + data=jsonb_set(data,'{title}','"Burnout Legends"'::jsonb), + updated_at=now() + WHERE title='Burnout Lege'; + UPDATE releases + SET title='Burnout Legends', + data=jsonb_set(data,'{title}','"Burnout Legends"'::jsonb), + updated_at=now() + WHERE title='Burnout Lege'; + INSERT INTO schema_versions VALUES (14, now()); + END IF; + END + $migration$; + CREATE TABLE IF NOT EXISTS wishlist_items( + id uuid PRIMARY KEY, + game_id uuid NULL, + title text NOT NULL, + platform text NULL, + priority int NOT NULL, + version bigint NOT NULL, + data jsonb NOT NULL, + created_at timestamptz NOT NULL, + updated_at timestamptz NOT NULL); + CREATE UNIQUE INDEX IF NOT EXISTS ux_wishlist_game ON wishlist_items(game_id) WHERE game_id IS NOT NULL; + CREATE INDEX IF NOT EXISTS ix_wishlist_search ON wishlist_items USING gin(title gin_trgm_ops); + CREATE INDEX IF NOT EXISTS ix_wishlist_order ON wishlist_items(priority DESC, updated_at DESC); + INSERT INTO schema_versions VALUES (15, now()) ON CONFLICT DO NOTHING; + ALTER TABLE wishlist_items ADD COLUMN IF NOT EXISTS status int NOT NULL DEFAULT 0; + ALTER TABLE wishlist_items ADD COLUMN IF NOT EXISTS release_date timestamptz NULL; + CREATE INDEX IF NOT EXISTS ix_wishlist_status ON wishlist_items(status, priority DESC, updated_at DESC); + INSERT INTO schema_versions VALUES (16, now()) ON CONFLICT DO NOTHING; + DO $migration$ + BEGIN + IF NOT EXISTS (SELECT 1 FROM schema_versions WHERE version=17) THEN + WITH ranked AS ( + SELECT id,row_number() OVER ( + PARTITION BY game_id,coalesce(data->>'sha256',''),coalesce(data->>'source',''),coalesce(data->>'provider','') + ORDER BY selected DESC,updated_at DESC,id DESC) AS position + FROM game_artwork + ) DELETE FROM game_artwork WHERE id IN (SELECT id FROM ranked WHERE position>1); + INSERT INTO schema_versions VALUES (17, now()); + END IF; + END + $migration$; + CREATE TABLE IF NOT EXISTS game_collections( + id uuid PRIMARY KEY, + name text NOT NULL, + kind int NOT NULL, + version bigint NOT NULL, + data jsonb NOT NULL, + created_at timestamptz NOT NULL, + updated_at timestamptz NOT NULL); + CREATE UNIQUE INDEX IF NOT EXISTS ux_game_collections_name ON game_collections(lower(name)); + CREATE INDEX IF NOT EXISTS ix_game_collections_order ON game_collections(kind,updated_at DESC); + CREATE TABLE IF NOT EXISTS game_collection_members( + collection_id uuid NOT NULL, + game_id uuid NOT NULL, + added_at timestamptz NOT NULL, + PRIMARY KEY(collection_id,game_id)); + CREATE INDEX IF NOT EXISTS ix_game_collection_members_game ON game_collection_members(game_id); + CREATE TABLE IF NOT EXISTS game_tags( + id uuid PRIMARY KEY, + game_id uuid NOT NULL, + normalized_name text NOT NULL, + data jsonb NOT NULL, + created_at timestamptz NOT NULL); + CREATE UNIQUE INDEX IF NOT EXISTS ux_game_tags_identity ON game_tags(game_id,normalized_name,coalesce(data->>'sourceType',''),coalesce(data->>'sourceId','')); + CREATE INDEX IF NOT EXISTS ix_game_tags_name ON game_tags(normalized_name,game_id); + CREATE TABLE IF NOT EXISTS game_user_states( + game_id uuid PRIMARY KEY, + status int NOT NULL, + favorite boolean NOT NULL, + version bigint NOT NULL, + data jsonb NOT NULL, + updated_at timestamptz NOT NULL); + CREATE INDEX IF NOT EXISTS ix_game_user_states_filter ON game_user_states(favorite,status,updated_at DESC); + CREATE TABLE IF NOT EXISTS game_relations( + id uuid PRIMARY KEY, + game_id uuid NOT NULL, + kind int NOT NULL, + related_game_id uuid NULL, + artifact_id uuid NULL, + data jsonb NOT NULL, + created_at timestamptz NOT NULL); + CREATE INDEX IF NOT EXISTS ix_game_relations_game ON game_relations(game_id,kind); + CREATE TABLE IF NOT EXISTS game_media( + id uuid PRIMARY KEY, + game_id uuid NOT NULL, + kind int NOT NULL, + app_owned boolean NOT NULL, + selected boolean NOT NULL, + data jsonb NOT NULL, + created_at timestamptz NOT NULL, + updated_at timestamptz NOT NULL DEFAULT now()); + CREATE INDEX IF NOT EXISTS ix_game_media_game ON game_media(game_id,kind,selected DESC,created_at); + CREATE UNIQUE INDEX IF NOT EXISTS ux_game_media_selected ON game_media(game_id,kind) WHERE selected; + CREATE TABLE IF NOT EXISTS game_achievement_progress( + game_id uuid PRIMARY KEY, + provider text NOT NULL, + data jsonb NOT NULL, + updated_at timestamptz NOT NULL); + CREATE TABLE IF NOT EXISTS platform_definitions( + id text PRIMARY KEY, + custom boolean NOT NULL, + enabled boolean NOT NULL, + version bigint NOT NULL, + data jsonb NOT NULL, + updated_at timestamptz NOT NULL); + CREATE INDEX IF NOT EXISTS ix_platform_definitions_order ON platform_definitions(custom,enabled,id); + INSERT INTO schema_versions VALUES (18, now()) ON CONFLICT DO NOTHING; + CREATE TABLE IF NOT EXISTS game_artifact_links( + game_id uuid NOT NULL, + artifact_id uuid NOT NULL, + source text NOT NULL, + created_at timestamptz NOT NULL, + PRIMARY KEY(game_id,artifact_id)); + CREATE INDEX IF NOT EXISTS ix_game_artifact_links_artifact ON game_artifact_links(artifact_id,game_id); + INSERT INTO game_artifact_links(game_id,artifact_id,source,created_at) + SELECT m.entity_id,a.id,'directory-evidence',now() + FROM metadata_claims m + JOIN artifacts a ON a.library_id=(m.data->>'sourceId')::uuid + AND a.relative_path=m.data#>>'{evidence,0,value}' + WHERE m.entity_type='Game' AND m.field='title' + AND m.data->>'sourceType'='DirectoryInference' + ON CONFLICT DO NOTHING; + CREATE TABLE IF NOT EXISTS browser_play_sessions( + id uuid PRIMARY KEY, + game_id uuid NOT NULL, + artifact_id uuid NOT NULL, + state int NOT NULL, + expires_at timestamptz NOT NULL, + data jsonb NOT NULL, + created_at timestamptz NOT NULL, + updated_at timestamptz NOT NULL); + CREATE INDEX IF NOT EXISTS ix_browser_play_sessions_active ON browser_play_sessions(state,expires_at); + INSERT INTO schema_versions VALUES (19, now()) ON CONFLICT DO NOTHING; + CREATE TABLE IF NOT EXISTS game_data_entries( + id uuid PRIMARY KEY, + game_id uuid NOT NULL, + kind int NOT NULL, + current_revision_id uuid NOT NULL, + version bigint NOT NULL, + data jsonb NOT NULL, + created_at timestamptz NOT NULL, + updated_at timestamptz NOT NULL); + CREATE INDEX IF NOT EXISTS ix_game_data_entries_game ON game_data_entries(game_id,kind,updated_at DESC,id); + CREATE TABLE IF NOT EXISTS game_data_revisions( + id uuid PRIMARY KEY, + entry_id uuid NOT NULL REFERENCES game_data_entries(id) ON DELETE CASCADE, + sequence int NOT NULL, + sha256 text NOT NULL, + length bigint NOT NULL, + data jsonb NOT NULL, + created_at timestamptz NOT NULL, + UNIQUE(entry_id,sequence)); + CREATE INDEX IF NOT EXISTS ix_game_data_revisions_entry ON game_data_revisions(entry_id,sequence DESC); + INSERT INTO schema_versions VALUES (20, now()) ON CONFLICT DO NOTHING; + ALTER TABLE releases ADD COLUMN IF NOT EXISTS origin int NOT NULL DEFAULT 0; + DO $migration$ + BEGIN + IF NOT EXISTS (SELECT 1 FROM schema_versions WHERE version=21) THEN + UPDATE releases r SET origin=1 + WHERE EXISTS ( + SELECT 1 FROM audit_events a + WHERE a.data->>'action'='ReleaseCreated' AND a.data->>'targetId'=r.id::text); + UPDATE releases SET data=jsonb_set(data,'{origin}',to_jsonb(origin),true); + INSERT INTO schema_versions VALUES (21, now()); + END IF; + END + $migration$; + ALTER TABLE games ADD COLUMN IF NOT EXISTS origin int NOT NULL DEFAULT 0; + DO $migration$ + BEGIN + IF NOT EXISTS (SELECT 1 FROM schema_versions WHERE version=22) THEN + UPDATE games g SET origin=1 + WHERE NOT EXISTS (SELECT 1 FROM game_library_links l WHERE l.game_id=g.id) + AND NOT EXISTS (SELECT 1 FROM game_artifact_links l WHERE l.game_id=g.id) + AND EXISTS ( + SELECT 1 FROM audit_events a + WHERE a.data->>'action'='GameCreated' AND a.data->>'targetId'=g.id::text); + UPDATE games SET data=jsonb_set(data,'{origin}',to_jsonb(origin),true); + INSERT INTO schema_versions VALUES (22, now()); + END IF; + END + $migration$; + CREATE TABLE IF NOT EXISTS firmware_assets( + id uuid PRIMARY KEY, platform text NOT NULL, slot text NOT NULL, selected boolean NOT NULL, + version bigint NOT NULL, data jsonb NOT NULL, created_at timestamptz NOT NULL, updated_at timestamptz NOT NULL); + CREATE UNIQUE INDEX IF NOT EXISTS ux_firmware_selected ON firmware_assets(platform,slot) WHERE selected; + INSERT INTO schema_versions VALUES (23, now()) ON CONFLICT DO NOTHING; + DO $migration$ + BEGIN + IF NOT EXISTS (SELECT 1 FROM schema_versions WHERE version=24) THEN + -- Resolving a review wrote the state's name into the record while the record stores + -- the enum's ordinal, so every row it touched became unreadable and one of them was + -- enough to fail the whole review list. The names are mapped back here. + UPDATE review_items SET data=jsonb_set(data,'{state}',to_jsonb( + CASE data->>'state' WHEN 'Open' THEN 0 WHEN 'Deferred' THEN 1 ELSE 2 END)) + WHERE jsonb_typeof(data->'state')='string'; + INSERT INTO schema_versions VALUES (24, now()); + END IF; + END + $migration$; + DO $migration$ + BEGIN + IF NOT EXISTS (SELECT 1 FROM schema_versions WHERE version=25) THEN + -- A title may have releases on several platforms. Keep one selected cover per + -- game/platform pair instead of forcing a Windows and PS5 release to share art. + DROP INDEX IF EXISTS ux_game_artwork_selected; + CREATE UNIQUE INDEX ux_game_artwork_selected + ON game_artwork(game_id,coalesce(data->>'platform','')) WHERE selected; + INSERT INTO schema_versions VALUES (25, now()); + END IF; + END + $migration$; + DO $migration$ + BEGIN + IF NOT EXISTS (SELECT 1 FROM schema_versions WHERE version=26) THEN + -- A downloaded HTML landing page is evidence about the source archive, not game + -- media. Keep the artifact and its evidence, but remove catalog materialisations + -- that were derived exclusively from such pages. + CREATE TEMP TABLE migration_26_web_only_games(id uuid PRIMARY KEY) ON COMMIT DROP; + INSERT INTO migration_26_web_only_games + SELECT g.id FROM games g + WHERE g.origin=0 + AND EXISTS (SELECT 1 FROM game_artifact_links l WHERE l.game_id=g.id) + AND NOT EXISTS ( + SELECT 1 FROM game_artifact_links l JOIN artifacts a ON a.id=l.artifact_id + WHERE l.game_id=g.id AND a.relative_path !~* '\.html?$'); + + UPDATE artifacts SET + data=jsonb_set(jsonb_set(jsonb_set(jsonb_set(data, + '{state}','3'::jsonb),'{mediaType}','6'::jsonb),'{confidence}','4'::jsonb),'{platform}','null'::jsonb), + updated_at=now() + WHERE relative_path ~* '\.html?$'; + + -- Blast Corps had one real N64 artifact and one 3DS download page. The latter + -- incorrectly created a second platform release for the same game. + DELETE FROM releases r + WHERE r.game_id IN (SELECT game_id FROM game_artifact_links l JOIN artifacts a ON a.id=l.artifact_id + WHERE a.relative_path ~* '(^|/)3ds/Blast Corps.*/BLUEROMS\.WS\.html$') + AND lower(coalesce(r.data->>'platform',''))='3ds'; + + DELETE FROM releases WHERE game_id IN (SELECT id FROM migration_26_web_only_games); + DELETE FROM game_artwork WHERE game_id IN (SELECT id FROM migration_26_web_only_games); + DELETE FROM game_collection_members WHERE game_id IN (SELECT id FROM migration_26_web_only_games); + DELETE FROM game_tags WHERE game_id IN (SELECT id FROM migration_26_web_only_games); + DELETE FROM game_user_states WHERE game_id IN (SELECT id FROM migration_26_web_only_games); + DELETE FROM game_relations WHERE game_id IN (SELECT id FROM migration_26_web_only_games); + DELETE FROM game_media WHERE game_id IN (SELECT id FROM migration_26_web_only_games); + DELETE FROM game_achievement_progress WHERE game_id IN (SELECT id FROM migration_26_web_only_games); + DELETE FROM game_library_links WHERE game_id IN (SELECT id FROM migration_26_web_only_games); + DELETE FROM game_artifact_links WHERE game_id IN (SELECT id FROM migration_26_web_only_games); + DELETE FROM games WHERE id IN (SELECT id FROM migration_26_web_only_games); + + -- Repair known legacy truncations only when the operator has not locked the title. + CREATE TEMP TABLE migration_26_titles(old_title text PRIMARY KEY,new_title text NOT NULL) ON COMMIT DROP; + INSERT INTO migration_26_titles VALUES + ('Burnout Lege','Burnout Legends'), + ('Cities - Skyli','Cities - Skylines'), + ('Crash Bandicoot™ N','Crash Bandicoot N. Sane Trilogy'), + ('Mario Kart','Mario Kart Wii'), + ('Super Mario Bros','Super Mario Bros. Wonder'); + UPDATE games g SET title=t.new_title,data=jsonb_set(g.data,'{title}',to_jsonb(t.new_title)),updated_at=now() + FROM migration_26_titles t + WHERE g.title=t.old_title AND NOT EXISTS ( + SELECT 1 FROM metadata_claims m WHERE m.entity_type='Game' AND m.entity_id=g.id + AND m.field='title' AND m.manual_lock); + UPDATE releases r SET title=g.title,data=jsonb_set(r.data,'{title}',to_jsonb(g.title)),updated_at=now() + FROM games g,migration_26_titles t + WHERE r.game_id=g.id AND g.title=t.new_title AND r.title=t.old_title; + + INSERT INTO schema_versions VALUES (26, now()); + END IF; + END + $migration$; + DO $migration$ + BEGIN + IF NOT EXISTS (SELECT 1 FROM schema_versions WHERE version=27) THEN + -- One legacy Blast Corps identity points only at an N64 artifact but retained a + -- stale 3DS release. Remove only that impossible derived release; the N64 record, + -- source link and all evidence remain intact. + DELETE FROM releases r USING games g + WHERE r.game_id=g.id AND g.origin=0 AND g.title='Blast Corps' + AND lower(coalesce(r.data->>'platform',''))='3ds' + AND EXISTS (SELECT 1 FROM game_artifact_links l JOIN artifacts a ON a.id=l.artifact_id + WHERE l.game_id=g.id AND lower(coalesce(a.data->>'platform',''))='n64') + AND NOT EXISTS (SELECT 1 FROM game_artifact_links l JOIN artifacts a ON a.id=l.artifact_id + WHERE l.game_id=g.id AND lower(coalesce(a.data->>'platform',''))='3ds' AND a.data->>'state'='0'); + INSERT INTO schema_versions VALUES (27, now()); + END IF; + END + $migration$; + """; + await using var command = dataSource.CreateCommand(sql); + await command.ExecuteNonQueryAsync(ct); + await SeedBuiltInPlatformsAsync(ct); + } + + public async Task<int> GetSchemaVersionAsync(CancellationToken ct) + { + await using var command = dataSource.CreateCommand("SELECT max(version) FROM schema_versions"); + var value = await command.ExecuteScalarAsync(ct); + return value is null or DBNull ? 0 : Convert.ToInt32(value, CultureInfo.InvariantCulture); + } + + public Task<IReadOnlyList<LibraryRoot>> ListLibrariesAsync(CancellationToken ct) => ListJsonAsync<LibraryRoot>("SELECT data FROM library_roots ORDER BY name", ct); + public Task<LibraryRoot?> GetLibraryAsync(Guid id, CancellationToken ct) => SingleJsonAsync<LibraryRoot>("SELECT data FROM library_roots WHERE id=$1", id, ct); + public async Task<IReadOnlyList<LibraryHealthSummary>> ListLibraryHealthAsync(CancellationToken ct) + { + const string sql = """ + SELECT l.id,coalesce(a.artifacts,0),coalesce(g.games,0),coalesce(a.bytes,0),coalesce(a.platforms,ARRAY[]::text[]), + s.last_full,s.last_incremental + FROM library_roots l + LEFT JOIN LATERAL ( + SELECT count(*)::bigint artifacts,coalesce(sum(size),0)::bigint bytes, + array_agg(DISTINCT data->>'platform') FILTER (WHERE data->>'platform' IS NOT NULL) platforms + FROM artifacts WHERE library_id=l.id AND data->>'state'='0' + ) a ON true + LEFT JOIN LATERAL (SELECT count(*)::bigint games FROM game_library_links WHERE library_id=l.id) g ON true + LEFT JOIN LATERAL ( + SELECT max((data->>'finishedAt')::timestamptz) FILTER (WHERE data->>'mode' IN ('1','2') AND data->>'state'='6') last_full, + max((data->>'finishedAt')::timestamptz) FILTER (WHERE data->>'mode'='0' AND data->>'state'='6') last_incremental + FROM scan_runs WHERE library_id=l.id + ) s ON true + ORDER BY l.name + """; + await using var command = dataSource.CreateCommand(sql); + var result = new List<LibraryHealthSummary>(); + await using var reader = await command.ExecuteReaderAsync(ct); + while (await reader.ReadAsync(ct)) + result.Add(new(reader.GetGuid(0), reader.GetInt64(1), reader.GetInt64(2), reader.GetInt64(3), + reader.GetFieldValue<string[]>(4), reader.IsDBNull(5) ? null : reader.GetFieldValue<DateTimeOffset>(5), + reader.IsDBNull(6) ? null : reader.GetFieldValue<DateTimeOffset>(6))); + return result; + } + public async Task UpsertLibraryAsync(LibraryRoot root, CancellationToken ct) => await ExecuteAsync("INSERT INTO library_roots(id,data,name,path) VALUES($1,$2,$3,$4) ON CONFLICT(id) DO UPDATE SET data=excluded.data,name=excluded.name,path=excluded.path,updated_at=now()", ct, root.Id, Json(root), root.Name, root.Path); + + public async Task<ScanRun> CreateScanAsync(Guid libraryId, ScanMode mode, string? key, CancellationToken ct) + { + if (!string.IsNullOrWhiteSpace(key)) + { + await using var existing = dataSource.CreateCommand("SELECT data FROM scan_runs WHERE idempotency_key=$1"); + existing.Parameters.AddWithValue(key); + var found = await existing.ExecuteScalarAsync(ct) as string; + if (found is not null) return Deserialize<ScanRun>(found); + } + var scan = new ScanRun(Guid.NewGuid(), libraryId, mode, ScanState.Queued, "Queued", 0, null, 0, null, null, false, DateTimeOffset.UtcNow); + await ExecuteAsync("INSERT INTO scan_runs(id,library_id,idempotency_key,data,created_at) VALUES($1,$2,$3,$4,$5)", ct, scan.Id, libraryId, DbValue(key), Json(scan), scan.CreatedAt); + return scan; + } + public Task<IReadOnlyList<ScanRun>> ListScansAsync(CancellationToken ct) => ListJsonAsync<ScanRun>("SELECT data FROM scan_runs ORDER BY created_at DESC LIMIT 100", ct); + public Task<ScanRun?> GetScanAsync(Guid id, CancellationToken ct) => SingleJsonAsync<ScanRun>("SELECT data FROM scan_runs WHERE id=$1", id, ct); + public async Task UpdateScanAsync(ScanRun scan, CancellationToken ct) => await ExecuteAsync("UPDATE scan_runs SET data=$2 WHERE id=$1", ct, scan.Id, Json(scan)); + + public async Task UpsertArtifactAsync(Artifact artifact, ContentBlob? content, IReadOnlyList<Evidence> evidence, CancellationToken ct) + { + await using var connection = await dataSource.OpenConnectionAsync(ct); + await using var transaction = await connection.BeginTransactionAsync(ct); + string? hash = null; + if (content is not null) + { + hash = content.Sha256; + await using var blob = new NpgsqlCommand("INSERT INTO content_blobs(id,size,sha256,data) VALUES($1,$2,$3,$4) ON CONFLICT(size,sha256) DO UPDATE SET data=excluded.data RETURNING id", connection, transaction); + Add(blob, content.Id, content.Size, content.Sha256, Json(content)); + var canonicalContentId = (Guid)(await blob.ExecuteScalarAsync(ct) ?? throw new InvalidOperationException("Content identity could not be persisted.")); + artifact = artifact with { ContentBlobId = canonicalContentId }; + } + await using var command = new NpgsqlCommand("INSERT INTO artifacts(id,library_id,relative_path,size,sha256,sha1,crc32,data,updated_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8,now()) ON CONFLICT(id) DO UPDATE SET relative_path=excluded.relative_path,size=excluded.size,sha256=COALESCE(excluded.sha256,artifacts.sha256),sha1=COALESCE(excluded.sha1,artifacts.sha1),crc32=COALESCE(excluded.crc32,artifacts.crc32),data=excluded.data,updated_at=now()", connection, transaction); + Add(command, artifact.Id, artifact.LibraryId, artifact.RelativePath, artifact.Size, DbValue(hash), DbValue(content?.Sha1), DbValue(content?.Crc32), Json(artifact)); + await command.ExecuteNonQueryAsync(ct); + foreach (var item in evidence) + { + await using var ev = new NpgsqlCommand("INSERT INTO artifact_evidence(artifact_id,data) SELECT $1,$2 WHERE NOT EXISTS (SELECT 1 FROM artifact_evidence WHERE artifact_id=$1 AND data->>'kind'=$3 AND data->>'value'=$4 AND data->>'source'=$5 AND data->>'sourceVersion'=$6)", connection, transaction); + Add(ev, artifact.Id, Json(item), item.Kind, item.Value, item.Source, item.SourceVersion); await ev.ExecuteNonQueryAsync(ct); + } + if (artifact.State == ArtifactState.Ignored) + { + await using var resolve = new NpgsqlCommand(""" + UPDATE review_items SET state='Resolved', + data=jsonb_set(jsonb_set(data,'{state}',to_jsonb(2)),'{resolution}','"Ignored non-game content"'::jsonb), + updated_at=now() + WHERE state<>'Resolved' AND data->>'reason'='Unknown content' + AND data->>'payloadJson' ILIKE '%' || $1::text || '%' + """, connection, transaction); + Add(resolve, artifact.Id); + await resolve.ExecuteNonQueryAsync(ct); + } + await transaction.CommitAsync(ct); + } + + public Task<Artifact?> FindByPathAsync(Guid libraryId, string relativePath, CancellationToken ct) => SingleJsonAsync<Artifact>("SELECT data FROM artifacts WHERE library_id=$1 AND relative_path=$2", ct, libraryId, relativePath); + public Task<ArtifactOverride?> GetArtifactOverrideAsync(Guid artifactId, CancellationToken ct) => + SingleJsonAsync<ArtifactOverride>("SELECT data FROM artifact_overrides WHERE artifact_id=$1", ct, artifactId); + public async Task MarkArtifactIgnoredAsync(Guid artifactId, long nextVersion, Evidence evidence, CancellationToken ct) + { + await using var connection = await dataSource.OpenConnectionAsync(ct); + await using var transaction = await connection.BeginTransactionAsync(ct); + await using (var update = new NpgsqlCommand(""" + UPDATE artifacts SET data=jsonb_set(jsonb_set(jsonb_set(jsonb_set(jsonb_set(data, + '{state}','3'::jsonb),'{mediaType}','6'::jsonb),'{confidence}','4'::jsonb),'{platform}','null'::jsonb), + '{version}',to_jsonb($2::bigint)),updated_at=now() WHERE id=$1 + """, connection, transaction)) + { Add(update, artifactId, nextVersion); await update.ExecuteNonQueryAsync(ct); } + await using (var ev = new NpgsqlCommand("INSERT INTO artifact_evidence(artifact_id,data) SELECT $1,$2 WHERE NOT EXISTS (SELECT 1 FROM artifact_evidence WHERE artifact_id=$1 AND data->>'kind'=$3 AND data->>'value'=$4 AND data->>'source'=$5 AND data->>'sourceVersion'=$6)", connection, transaction)) + { Add(ev, artifactId, Json(evidence), evidence.Kind, evidence.Value, evidence.Source, evidence.SourceVersion); await ev.ExecuteNonQueryAsync(ct); } + await using (var resolve = new NpgsqlCommand(""" + UPDATE review_items SET state='Resolved', + data=jsonb_set(jsonb_set(data,'{state}',to_jsonb(2)),'{resolution}','"Ignored non-game content"'::jsonb),updated_at=now() + WHERE state<>'Resolved' AND data->>'reason'='Unknown content' AND data->>'payloadJson' ILIKE '%' || $1::text || '%' + """, connection, transaction)) + { Add(resolve, artifactId); await resolve.ExecuteNonQueryAsync(ct); } + await transaction.CommitAsync(ct); + } + public Task<Artifact?> FindByHashAsync(Guid libraryId, long size, string sha256, CancellationToken ct) => SingleJsonAsync<Artifact>("SELECT data FROM artifacts WHERE library_id=$1 AND size=$2 AND sha256=$3 LIMIT 1", ct, libraryId, size, sha256); + public async Task<long> MarkMissingExceptAsync(Guid libraryId, IReadOnlySet<string> paths, DateTimeOffset at, CancellationToken ct) + { + await using var command = dataSource.CreateCommand(""" + UPDATE artifacts SET + data=jsonb_set(jsonb_set(jsonb_set(data,'{state}',to_jsonb(1)),'{lastSeenAt}',to_jsonb($3::timestamptz)),'{version}',to_jsonb(coalesce((data->>'version')::bigint,0)+1)), + updated_at=now() + -- The state is stored as the enum's ordinal, as every other query here reads it: + -- 0 is Present and 1 is Missing. Comparing against the name matched no row, so a file + -- deleted from the archive stayed Present and kept its Play control. + WHERE library_id=$1 AND data->>'state'='0' AND NOT(relative_path=ANY($2)) + """); + Add(command, libraryId, paths.ToArray(), at); + return await command.ExecuteNonQueryAsync(ct); + } + + public async Task<Page<Artifact>> SearchArtifactsAsync(string? query, int page, int pageSize, CancellationToken ct) + { + page = Math.Max(1, page); pageSize = Math.Clamp(pageSize, 1, 200); + var where = string.IsNullOrWhiteSpace(query) ? "" : " WHERE relative_path ILIKE $1"; + await using var count = dataSource.CreateCommand("SELECT count(*) FROM artifacts" + where); + if (where.Length > 0) count.Parameters.AddWithValue($"%{query}%"); + var total = Convert.ToInt64(await count.ExecuteScalarAsync(ct), CultureInfo.InvariantCulture); + await using var cmd = dataSource.CreateCommand("SELECT data FROM artifacts" + where + $" ORDER BY relative_path LIMIT {pageSize} OFFSET {(page - 1) * pageSize}"); + if (where.Length > 0) cmd.Parameters.AddWithValue($"%{query}%"); + var items = await ReadAsync<Artifact>(cmd, ct); + return new(items, page, pageSize, total); + } + + public async Task<ArtifactDetails?> GetArtifactDetailsAsync(Guid id, CancellationToken ct) + { + var artifact = await SingleJsonAsync<Artifact>("SELECT data FROM artifacts WHERE id=$1", id, ct); + if (artifact is null) return null; + var library = await GetLibraryAsync(artifact.LibraryId, ct) ?? throw new InvalidDataException("Artifact library is missing."); + var evidence = await ListJsonAsync<Evidence>("SELECT data FROM artifact_evidence WHERE artifact_id=$1 ORDER BY created_at", ct, id); + var members = await ListArchiveMembersAsync(id, ct); + var bundles = await ListJsonAsync<Bundle>("SELECT data FROM bundles WHERE data->'members' @> $1::jsonb ORDER BY updated_at DESC", ct, Json(new[] { new { artifactId = id } })); + var findings = await ListJsonAsync<Finding>("SELECT data FROM findings WHERE data->>'artifactId'=$1 ORDER BY created_at DESC", ct, id.ToString()); + var duplicates = artifact.Sha256 is null ? [] : await ListJsonAsync<Artifact>("SELECT data FROM artifacts WHERE size=$1 AND sha256=$2 AND id<>$3 ORDER BY relative_path", ct, artifact.Size, artifact.Sha256, id); + return new(artifact, library, evidence, members, bundles, findings, duplicates); + } + + public async Task<DashboardSummary> GetDashboardSummaryAsync(CancellationToken ct) + { + var visible = CatalogVisibility.SqlPredicate("g"); + var sql = $$$""" + SELECT + (SELECT count(*) FROM library_roots), (SELECT count(*) FROM artifacts), + (SELECT count(*) FROM artifacts WHERE data->>'state'='0'), + (SELECT count(DISTINCT lower(g.title)) FROM games g WHERE {{{visible}}}), + (SELECT count(DISTINCT lower(g.data->>'title') || ':' || coalesce(r.data->>'platform','')) + FROM releases r JOIN games g ON g.id=(r.data->>'gameId')::uuid WHERE {{{visible}}}), + (SELECT count(*) FROM bundles), + (SELECT coalesce(sum(size),0) FROM artifacts WHERE data->>'state'='0'), + (SELECT count(*) FROM artifacts WHERE data->>'state'='0' AND sha256 IS NOT NULL), + (SELECT count(*) FROM artifacts WHERE data->>'state'='0' AND data->>'mediaType'<>'7'), + (SELECT count(*) FROM artifacts WHERE data->>'state'='0' AND data->>'mediaType'='7'), + (SELECT count(*) FROM artifacts WHERE data->>'state'='1'), + (SELECT coalesce(sum(c-1),0) FROM (SELECT count(*) c FROM artifacts WHERE sha256 IS NOT NULL AND data->>'state'='0' GROUP BY size,sha256 HAVING count(*)>1) d), + (SELECT coalesce(sum((c-1)*size),0) FROM (SELECT size,count(*) c FROM artifacts WHERE sha256 IS NOT NULL AND data->>'state'='0' GROUP BY size,sha256 HAVING count(*)>1) d), + (SELECT count(*) FROM review_items WHERE state='Open' AND data->>'reason'<>'Unknown content'), + (SELECT count(*) FROM findings WHERE data->>'severity'='3'), + (SELECT count(*) FROM scan_runs WHERE data->>'state' NOT IN ('6','7','8')), + (SELECT count(*) FROM library_roots l WHERE coalesce((l.data->>'enabled')::boolean,true)=true AND + (coalesce((l.data->>'isAvailable')::boolean,false)=false OR coalesce((l.data->>'isReadOnly')::boolean,false)=false OR + (coalesce((l.data->>'hasEntries')::boolean,true)=false AND EXISTS (SELECT 1 FROM artifacts a WHERE a.library_id=l.id AND a.data->>'state'='0')))), + (SELECT count(DISTINCT lower(g.title)) FROM games g WHERE NOT ({{{visible}}})), + (SELECT count(*) FROM match_candidates WHERE state NOT IN ('Accepted','Rejected')), + (SELECT max((data->>'finishedAt')::timestamptz) FROM scan_runs WHERE data->>'state'='6' AND data->>'mode' IN ('1','2')), + (SELECT max((data->>'finishedAt')::timestamptz) FROM scan_runs WHERE data->>'state'='6' AND data->>'mode'='0'), + ((SELECT count(*) FROM catalog_sources) > 0 AND (SELECT count(*) FROM scan_runs WHERE data->>'state'='6') = 0), + (SELECT count(*) FROM review_items WHERE state='Open'), + (SELECT count(*) FROM ( + SELECT DISTINCT game_id,coalesce(data->>'platform','') + FROM game_artwork + WHERE quality_score<80 OR data->>'verificationStatus' IN ('0','3') + ) artwork_review) + """; + await using var command = dataSource.CreateCommand(sql); + await using var reader = await command.ExecuteReaderAsync(ct); await reader.ReadAsync(ct); + return new(Enumerable.Range(0, 19).Select(reader.GetInt64).ToArray() is var v ? v[0] : 0, + reader.GetInt64(1), reader.GetInt64(2), reader.GetInt64(3), reader.GetInt64(4), reader.GetInt64(5), + reader.GetInt64(6), reader.GetInt64(7), reader.GetInt64(8), reader.GetInt64(9), reader.GetInt64(10), + reader.GetInt64(11), reader.GetInt64(12), reader.GetInt64(13), reader.GetInt64(14), reader.GetInt64(15), reader.GetInt64(16), + reader.GetInt64(17), reader.GetInt64(18), reader.IsDBNull(19) ? null : reader.GetFieldValue<DateTimeOffset>(19), + reader.IsDBNull(20) ? null : reader.GetFieldValue<DateTimeOffset>(20), reader.GetBoolean(21), + reader.GetInt64(22), reader.GetInt64(23)); + } + + public async Task<PlayabilitySummary> GetPlayabilitySummaryAsync(CancellationToken ct) + { + var supportedPlatforms = PlayableFormats.Pairs.Select(pair => pair.Split('|', 2)[0]) + .Distinct(StringComparer.Ordinal).Order().ToArray(); + var playablePairs = PlayableFormats.Pairs.ToArray(); + var visible = CatalogVisibility.SqlPredicate("g"); + var common = $$$""" + WITH visible_games AS ( + SELECT DISTINCT ON (lower(g.title)) g.id + FROM games g + WHERE {{{visible}}} + ORDER BY lower(g.title),g.updated_at DESC + ), game_platforms AS ( + SELECT DISTINCT vg.id,lower(coalesce(r.data->>'platform','')) AS platform + FROM visible_games vg + LEFT JOIN releases r ON r.game_id=vg.id + ), game_states AS ( + SELECT vg.id, + coalesce(bool_or(gp.platform=ANY($1)),false) AS supported, + EXISTS ( + SELECT 1 FROM game_artifact_links x + JOIN artifacts a ON a.id=x.artifact_id + WHERE x.game_id=vg.id AND a.data->>'state'='0' + AND lower(coalesce(a.data->>'platform','')) || '|' || + lower(coalesce(substring(a.relative_path from '\.[^./\\]+$'),'')) = ANY($2) + ) AS ready + FROM visible_games vg LEFT JOIN game_platforms gp ON gp.id=vg.id + GROUP BY vg.id + ) + """; + await using var totalCommand = dataSource.CreateCommand(common + """ + SELECT count(*) FILTER (WHERE supported), + count(*) FILTER (WHERE supported AND ready), + count(*) FILTER (WHERE NOT supported) + FROM game_states + """); + Add(totalCommand, supportedPlatforms, playablePairs); + long supported; long ready; long unsupported; + await using (var reader = await totalCommand.ExecuteReaderAsync(ct)) + { + await reader.ReadAsync(ct); + supported = reader.GetInt64(0); + ready = reader.GetInt64(1); + unsupported = reader.GetInt64(2); + } + + await using var platformCommand = dataSource.CreateCommand(common + """ + SELECT gp.platform,count(DISTINCT gp.id), + count(DISTINCT gp.id) FILTER (WHERE gs.ready) + FROM game_platforms gp JOIN game_states gs ON gs.id=gp.id + WHERE gp.platform=ANY($1) + GROUP BY gp.platform ORDER BY gp.platform + """); + Add(platformCommand, supportedPlatforms, playablePairs); + var byPlatform = new List<PlatformPlayabilitySummary>(); + await using (var reader = await platformCommand.ExecuteReaderAsync(ct)) + while (await reader.ReadAsync(ct)) + { + var platformSupported = reader.GetInt64(1); + var platformReady = reader.GetInt64(2); + byPlatform.Add(new(reader.GetString(0), platformSupported, platformReady, + platformSupported - platformReady)); + } + return new(supported, ready, supported - ready, unsupported, byPlatform); + } + + public async Task<StorageSummary> GetStorageSummaryAsync(CancellationToken ct) + { + var dashboard = await GetDashboardSummaryAsync(ct); var platforms = await ListPlatformsAsync(ct); + const string sql = "SELECT l.id,l.name,count(a.id)::bigint,coalesce(sum(a.size) FILTER (WHERE a.data->>'state'='0'),0)::bigint,count(a.id) FILTER (WHERE a.data->>'state'='1')::bigint FROM library_roots l LEFT JOIN artifacts a ON a.library_id=l.id GROUP BY l.id,l.name ORDER BY l.name"; + await using var command = dataSource.CreateCommand(sql); var libraries = new List<LibraryStorageSummary>(); + await using var reader = await command.ExecuteReaderAsync(ct); + while (await reader.ReadAsync(ct)) libraries.Add(new(reader.GetGuid(0), reader.GetString(1), reader.GetInt64(2), reader.GetInt64(3), reader.GetInt64(4))); + return new(dashboard.TotalBytes, dashboard.DuplicateBytes, platforms, libraries); + } + + public async Task<HealthSummary> GetHealthSummaryAsync(CancellationToken ct) + { + const string sql = """ + SELECT + (SELECT count(*) FROM findings WHERE data->>'severity'='3'), + (SELECT count(*) FROM findings WHERE data->>'severity'='2'), + (SELECT count(*) FROM findings WHERE data->>'severity' IN ('0','1')), + (SELECT count(*) FROM artifacts WHERE data->>'state'='0' AND data->>'mediaType'='7'), + (SELECT count(*) FROM artifacts WHERE data->>'state'='1'), + (SELECT count(*) FROM bundles WHERE data->>'state' IN ('1','2')) + """; + await using var command = dataSource.CreateCommand(sql); + long critical; long warning; long notice; long unknown; long missing; long incomplete; + await using (var reader = await command.ExecuteReaderAsync(ct)) + { + await reader.ReadAsync(ct); + critical = reader.GetInt64(0); warning = reader.GetInt64(1); notice = reader.GetInt64(2); + unknown = reader.GetInt64(3); missing = reader.GetInt64(4); incomplete = reader.GetInt64(5); + } + var recent = await ListJsonAsync<Finding>( + "SELECT data FROM findings ORDER BY created_at DESC LIMIT 100", ct); + return new(critical, warning, notice, unknown, missing, incomplete, recent); + } + + public Task<IReadOnlyList<Artifact>> GetDuplicatesAsync(CancellationToken ct) => ListJsonAsync<Artifact>("SELECT data FROM artifacts WHERE (size,sha256) IN (SELECT size,sha256 FROM artifacts WHERE sha256 IS NOT NULL GROUP BY size,sha256 HAVING count(*)>1) ORDER BY sha256,relative_path", ct); + + public async Task ReplaceArchiveMembersAsync(Guid artifactId, IReadOnlyList<ArchiveMember> members, CancellationToken ct) + { + await using var connection = await dataSource.OpenConnectionAsync(ct); + await using var transaction = await connection.BeginTransactionAsync(ct); + await using (var delete = new NpgsqlCommand("DELETE FROM archive_members WHERE artifact_id=$1", connection, transaction)) + { + Add(delete, artifactId); + await delete.ExecuteNonQueryAsync(ct); + } + foreach (var member in members) + { + await using var insert = new NpgsqlCommand("INSERT INTO archive_members(id,artifact_id,sequence,path,data) VALUES($1,$2,$3,$4,$5)", connection, transaction); + Add(insert, member.Id, artifactId, member.Sequence, member.Path, Json(member)); + await insert.ExecuteNonQueryAsync(ct); + } + await transaction.CommitAsync(ct); + } + + public Task<IReadOnlyList<ArchiveMember>> ListArchiveMembersAsync(Guid artifactId, CancellationToken ct) => + ListJsonAsync<ArchiveMember>("SELECT data FROM archive_members WHERE artifact_id=$1 ORDER BY sequence", ct, artifactId); + public async Task AddBundleAsync(Bundle bundle, CancellationToken ct) => await ExecuteAsync("INSERT INTO bundles(id,data) VALUES($1,$2) ON CONFLICT(id) DO UPDATE SET data=excluded.data,updated_at=now()", ct, bundle.Id, Json(bundle)); + public Task<IReadOnlyList<Bundle>> ListBundlesAsync(CancellationToken ct) => ListJsonAsync<Bundle>("SELECT data FROM bundles ORDER BY updated_at DESC", ct); + public async Task AddReviewAsync(ReviewItem item, CancellationToken ct) => await ExecuteAsync("INSERT INTO review_items(id,state,data) VALUES($1,$2,$3) ON CONFLICT(id) DO NOTHING", ct, item.Id, item.State.ToString(), Json(item)); + public Task<IReadOnlyList<ReviewItem>> ListReviewsAsync(CancellationToken ct) => ListJsonAsync<ReviewItem>("SELECT data FROM review_items ORDER BY updated_at DESC", ct); + public async Task<Page<ReviewItem>> SearchReviewsAsync(ReviewState? state, string? reason, int page, int pageSize, CancellationToken ct) + { + page = Math.Max(1, page); pageSize = Math.Clamp(pageSize, 1, 200); var clauses = new List<string>(); var values = new List<object>(); + if (state is not null) { clauses.Add($"state=${values.Count + 1}"); values.Add(state.ToString()!); } + if (!string.IsNullOrWhiteSpace(reason)) { clauses.Add($"data->>'reason'=${values.Count + 1}"); values.Add(reason); } + var where = clauses.Count == 0 ? "" : " WHERE " + string.Join(" AND ", clauses); + await using var count = dataSource.CreateCommand("SELECT count(*) FROM review_items" + where); Add(count, values.ToArray()); + var total = Convert.ToInt64(await count.ExecuteScalarAsync(ct), CultureInfo.InvariantCulture); + await using var command = dataSource.CreateCommand("SELECT data FROM review_items" + where + $" ORDER BY updated_at DESC LIMIT {pageSize} OFFSET {(page - 1) * pageSize}"); Add(command, values.ToArray()); + return new(await ReadAsync<ReviewItem>(command, ct), page, pageSize, total); + } + public async Task<IReadOnlyList<ReviewGroup>> ListReviewGroupsAsync(ReviewState state, CancellationToken ct) => + (await SearchReviewGroupsAsync(new ReviewGroupQuery { State = state, Page = 1, PageSize = 500 }, ct)).Items; + + public async Task<Page<ReviewGroup>> SearchReviewGroupsAsync(ReviewGroupQuery query, + CancellationToken ct) + { + var page = Math.Max(1, query.Page); + var pageSize = Math.Clamp(query.PageSize, 1, 500); + var filters = new List<string>(); + var values = new List<object> { query.State.ToString(), pageSize, (page - 1) * pageSize }; + void Filter(string clause, object value) + { + values.Add(value); + filters.Add(string.Format(CultureInfo.InvariantCulture, clause, values.Count)); + } + if (query.Category?.Equals("actionable", StringComparison.OrdinalIgnoreCase) == true) + filters.Add("category NOT IN ('unknown','duplicates')"); + else if (!string.IsNullOrWhiteSpace(query.Category)) + Filter("category=${0}", query.Category.Trim().ToLowerInvariant()); + if (!string.IsNullOrWhiteSpace(query.Search)) Filter("concat_ws(' ',reason,path_pattern,recommended_action,library,platform) ILIKE ${0}", $"%{query.Search.Trim()}%"); + if (!string.IsNullOrWhiteSpace(query.Library)) Filter("library=${0}", query.Library.Trim()); + if (!string.IsNullOrWhiteSpace(query.Platform)) Filter("platform=${0}", query.Platform.Trim()); + if (query.Severity is not null) Filter("severity_rank=${0}", (int)query.Severity.Value); + if (query.Since is not null) Filter("newest_at>=${0}", query.Since.Value); + var where = filters.Count == 0 ? "" : "WHERE " + string.Join(" AND ", filters); + var order = query.Order switch + { + ReviewGroupOrder.Impact => "estimated_bytes DESC, scoped_count DESC, latest_updated_at DESC", + ReviewGroupOrder.Newest => "newest_at DESC, severity_rank DESC, scoped_count DESC", + _ => "severity_rank DESC, estimated_bytes DESC, scoped_count DESC, latest_updated_at DESC" + }; + var sql = """ + WITH source AS ( + SELECT ri.id, ri.data, ri.updated_at, a.size, l.name AS library, a.data->>'platform' AS platform, + coalesce(a.relative_path, '') AS relative_path, + CASE + WHEN ri.data->>'reason' ILIKE '%unknown%' THEN 'unknown' + WHEN ri.data->>'reason' ILIKE '%incomplete%' THEN 'incomplete' + WHEN ri.data->>'reason' ILIKE '%integrity%' OR ri.data->>'reason' ILIKE '%changed%' THEN 'changed' + WHEN ri.data->>'reason' ILIKE '%duplicate%' THEN 'duplicates' + ELSE 'decisions' + END AS category, + CASE + WHEN a.relative_path IS NULL THEN '(no file path)' + WHEN array_length(string_to_array(a.relative_path, '/'), 1) >= 3 + THEN split_part(a.relative_path, '/', 1) || '/' || split_part(a.relative_path, '/', 2) || '/*' + WHEN position('/' in a.relative_path) > 0 THEN split_part(a.relative_path, '/', 1) || '/*' + ELSE a.relative_path + END AS path_pattern + FROM review_items ri + LEFT JOIN artifacts a ON a.id=coalesce( + nullif((ri.data->>'payloadJson')::jsonb->>'id',''), + nullif((ri.data->>'payloadJson')::jsonb->>'Id',''))::uuid + LEFT JOIN library_roots l ON l.id=a.library_id + WHERE ri.state=$1 + ), ranked AS ( + SELECT source.*, + row_number() OVER ( + PARTITION BY category,data->>'reason',library,platform,path_pattern + ORDER BY updated_at DESC,id + ) AS scope_rank + FROM source + ), grouped AS ( + SELECT md5(coalesce(data->>'reason','') || '|' || coalesce(library,'') || '|' || coalesce(platform,'') || '|' || path_pattern) AS group_key, + category, data->>'reason' AS reason, max((data->>'severity')::int) AS severity_rank, + least(count(*),10000)::bigint AS scoped_count, + coalesce(sum(size) FILTER (WHERE scope_rank <= 10000),0)::bigint AS estimated_bytes, library, platform, path_pattern, + min((data->>'createdAt')::timestamptz) FILTER (WHERE scope_rank <= 10000) AS oldest_at, + max((data->>'createdAt')::timestamptz) FILTER (WHERE scope_rank <= 10000) AS newest_at, + CASE + WHEN category='unknown' THEN 'Classify this path group or add a scoped exclusion rule.' + WHEN category='incomplete' THEN 'Inspect missing bundle members before resolving.' + WHEN category='changed' THEN 'Verify integrity evidence before accepting the change.' + ELSE 'Review the evidence and accept, defer or resolve the exact group.' + END AS recommended_action, + array_agg(id ORDER BY updated_at DESC) FILTER (WHERE scope_rank <= 10000), + CASE WHEN count(nullif(data->>'resolution','')) = count(*) + AND count(DISTINCT nullif(data->>'resolution','')) = 1 + THEN max(nullif(data->>'resolution','')) ELSE NULL END, + CASE WHEN count(nullif(data->>'resolvedAt','')) = count(*) + THEN max(nullif(data->>'resolvedAt','')::timestamptz) ELSE NULL END, + count(*)::bigint AS total_count, + max(updated_at) AS latest_updated_at + FROM ranked + GROUP BY category,data->>'reason',library,platform,path_pattern + ) + SELECT grouped.*, count(*) OVER() AS total_groups + FROM grouped + __WHERE__ + ORDER BY __ORDER__ + LIMIT $2 OFFSET $3 + """.Replace("__WHERE__", where, StringComparison.Ordinal) + .Replace("__ORDER__", order, StringComparison.Ordinal); + await using var command = dataSource.CreateCommand(sql); + Add(command, values.ToArray()); + var result = new List<ReviewGroup>(); + long total = 0; + await using var reader = await command.ExecuteReaderAsync(ct); + while (await reader.ReadAsync(ct)) + { + result.Add(new(reader.GetString(0), reader.GetString(1), reader.GetString(2), (Severity)reader.GetInt32(3), + reader.GetInt64(4), reader.GetInt64(5), reader.IsDBNull(6) ? null : reader.GetString(6), + reader.IsDBNull(7) ? null : reader.GetString(7), reader.GetString(8), + reader.GetFieldValue<DateTimeOffset>(9), reader.GetFieldValue<DateTimeOffset>(10), reader.GetString(11), + reader.GetFieldValue<Guid[]>(12), reader.IsDBNull(13) ? null : reader.GetString(13), + reader.IsDBNull(14) ? null : reader.GetFieldValue<DateTimeOffset>(14), reader.GetInt64(15))); + total = reader.GetInt64(17); + } + return new(result, page, pageSize, total); + } + + public Task<ReviewOperation> ResolveReviewGroupAsync(IReadOnlyList<Guid> ids, int expectedCount, string resolution, string actor, CancellationToken ct) => + ChangeReviewGroupStateAsync(ids, expectedCount, resolution, actor, ReviewState.Open, ReviewState.Resolved, "ReviewGroupResolved", ct); + public Task<ReviewOperation> DeferReviewGroupAsync(IReadOnlyList<Guid> ids, int expectedCount, string reason, string actor, CancellationToken ct) => + ChangeReviewGroupStateAsync(ids, expectedCount, reason, actor, ReviewState.Open, ReviewState.Deferred, "ReviewGroupDeferred", ct); + public Task<ReviewOperation> ReopenReviewGroupAsync(IReadOnlyList<Guid> ids, int expectedCount, ReviewState sourceState, string reason, string actor, CancellationToken ct) + { + if (sourceState is not (ReviewState.Deferred or ReviewState.Resolved)) + throw new ArgumentException("Only deferred or resolved review groups can be reopened.", nameof(sourceState)); + return ChangeReviewGroupStateAsync(ids, expectedCount, reason, actor, sourceState, ReviewState.Open, "ReviewGroupReopened", ct); + } + + public async Task<ReviewOperation> ApplyReviewGroupActionAsync(IReadOnlyList<Guid> ids, int expectedCount, + ReviewBulkAction action, string? platform, string? pathPattern, string actor, CancellationToken ct) + { + var exactIds = ids.Distinct().ToArray(); + if (exactIds.Length == 0 || exactIds.Length > 10_000 || exactIds.Length != expectedCount) + throw new ConcurrencyException("The review group scope changed; reload the preview before applying it."); + var normalizedPlatform = string.IsNullOrWhiteSpace(platform) ? null : platform.Trim().ToLowerInvariant(); + if (action == ReviewBulkAction.AcceptPlatform && normalizedPlatform is null) + throw new ArgumentException("A platform is required when accepting a platform classification.", nameof(platform)); + var normalizedPattern = string.IsNullOrWhiteSpace(pathPattern) ? null : pathPattern.Trim().Replace('\\', '/'); + if (action == ReviewBulkAction.ExcludeFolderPattern && + (normalizedPattern is null || !normalizedPattern.EndsWith("/*", StringComparison.Ordinal) || normalizedPattern.StartsWith('/') || normalizedPattern.Contains("..", StringComparison.Ordinal))) + throw new ArgumentException("A bounded relative folder pattern ending in /* is required.", nameof(pathPattern)); + + await using var connection = await dataSource.OpenConnectionAsync(ct); + await using var transaction = await connection.BeginTransactionAsync(ct); + var beforeReviews = new List<ReviewItem>(); + await using (var select = new NpgsqlCommand("SELECT data FROM review_items WHERE id=ANY($1) AND state='Open' ORDER BY id FOR UPDATE", connection, transaction)) + { + select.Parameters.AddWithValue(exactIds); + await using var reader = await select.ExecuteReaderAsync(ct); + while (await reader.ReadAsync(ct)) beforeReviews.Add(Deserialize<ReviewItem>(reader.GetString(0))); + } + if (beforeReviews.Count != expectedCount) + throw new ConcurrencyException("One or more review items changed after the group preview was loaded."); + + var artifactIds = beforeReviews.Select(ReviewArtifactId).Where(id => id is not null).Select(id => id!.Value).Distinct().ToArray(); + if (artifactIds.Length != expectedCount) + throw new ConcurrencyException("This action requires one unchanged artifact for every previewed review item."); + var beforeArtifacts = new List<Artifact>(); + await using (var select = new NpgsqlCommand("SELECT data FROM artifacts WHERE id=ANY($1) ORDER BY id FOR UPDATE", connection, transaction)) + { + select.Parameters.AddWithValue(artifactIds); + await using var reader = await select.ExecuteReaderAsync(ct); + while (await reader.ReadAsync(ct)) beforeArtifacts.Add(Deserialize<Artifact>(reader.GetString(0))); + } + if (beforeArtifacts.Count != artifactIds.Length) + throw new ConcurrencyException("One or more artifacts changed after the group preview was loaded."); + + var beforeOverrides = new List<ArtifactOverride>(); + await using (var select = new NpgsqlCommand("SELECT data FROM artifact_overrides WHERE artifact_id=ANY($1) FOR UPDATE", connection, transaction)) + { + select.Parameters.AddWithValue(artifactIds); + await using var reader = await select.ExecuteReaderAsync(ct); + while (await reader.ReadAsync(ct)) beforeOverrides.Add(Deserialize<ArtifactOverride>(reader.GetString(0))); + } + + var beforeLibraries = new List<LibraryRoot>(); + var afterLibraries = new List<LibraryRoot>(); + if (action == ReviewBulkAction.ExcludeFolderPattern) + { + var libraryIds = beforeArtifacts.Select(item => item.LibraryId).Distinct().ToArray(); + if (libraryIds.Length != 1) throw new ConcurrencyException("A folder exclusion must remain within one library."); + var prefix = normalizedPattern![..^1]; + if (beforeArtifacts.Any(item => !item.RelativePath.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))) + throw new ConcurrencyException("The proposed folder pattern no longer contains the exact previewed artifacts."); + await using var selectLibrary = new NpgsqlCommand("SELECT data FROM library_roots WHERE id=$1 FOR UPDATE", connection, transaction); + Add(selectLibrary, libraryIds[0]); + var libraryJson = await selectLibrary.ExecuteScalarAsync(ct) as string ?? throw new ConcurrencyException("The previewed library no longer exists."); + var library = Deserialize<LibraryRoot>(libraryJson); + beforeLibraries.Add(library); + var durablePattern = normalizedPattern[..^1] + "**"; + var exclusions = (library.Exclusions ?? []).Append(durablePattern).Distinct(StringComparer.OrdinalIgnoreCase).Order().ToArray(); + var updatedLibrary = library with { Exclusions = exclusions, Version = library.Version + 1 }; + afterLibraries.Add(updatedLibrary); + await using var updateLibrary = new NpgsqlCommand("UPDATE library_roots SET data=$2,updated_at=now() WHERE id=$1", connection, transaction); + Add(updateLibrary, library.Id, Json(updatedLibrary)); + await updateLibrary.ExecuteNonQueryAsync(ct); + normalizedPattern = durablePattern; + } + + var now = DateTimeOffset.UtcNow; + var afterArtifacts = new List<Artifact>(); + var afterOverrides = new List<ArtifactOverride>(); + var addedEvidenceIds = new List<long>(); + foreach (var artifact in beforeArtifacts) + { + var updated = action switch + { + ReviewBulkAction.ClassifySupportContent => artifact with + { + State = ArtifactState.Ignored, + MediaType = MediaType.SupportFile, + Confidence = Confidence.Deterministic, + Platform = null, + Version = artifact.Version + 1 + }, + ReviewBulkAction.AcceptPlatform => artifact with + { + Platform = normalizedPlatform, + Confidence = Confidence.Deterministic, + Version = artifact.Version + 1 + }, + ReviewBulkAction.ExcludeFolderPattern => artifact with + { + State = ArtifactState.Ignored, + MediaType = MediaType.SupportFile, + Confidence = Confidence.Deterministic, + Platform = null, + Version = artifact.Version + 1 + }, + _ => throw new ArgumentOutOfRangeException(nameof(action)) + }; + afterArtifacts.Add(updated); + await using var update = new NpgsqlCommand("UPDATE artifacts SET data=$2,updated_at=now() WHERE id=$1", connection, transaction); + Add(update, artifact.Id, Json(updated)); + await update.ExecuteNonQueryAsync(ct); + var evidenceValue = action switch + { + ReviewBulkAction.AcceptPlatform => $"platform:{normalizedPlatform}", + ReviewBulkAction.ExcludeFolderPattern => $"exclude:{normalizedPattern}", + _ => "support-content" + }; + var evidence = new Evidence("review.bulk-action", evidenceValue, + "operator", "1", Confidence.Deterministic, now); + await using var insertEvidence = new NpgsqlCommand("INSERT INTO artifact_evidence(artifact_id,data) VALUES($1,$2) RETURNING id", connection, transaction); + Add(insertEvidence, artifact.Id, Json(evidence)); + addedEvidenceIds.Add(Convert.ToInt64(await insertEvidence.ExecuteScalarAsync(ct), CultureInfo.InvariantCulture)); + if (action != ReviewBulkAction.ExcludeFolderPattern) + { + var manual = action == ReviewBulkAction.AcceptPlatform + ? new ArtifactOverride(artifact.Id, null, normalizedPlatform, false, actor, now) + : new ArtifactOverride(artifact.Id, MediaType.SupportFile, null, true, actor, now); + afterOverrides.Add(manual); + await using var upsertOverride = new NpgsqlCommand("INSERT INTO artifact_overrides(artifact_id,data) VALUES($1,$2) ON CONFLICT(artifact_id) DO UPDATE SET data=excluded.data,updated_at=now()", connection, transaction); + Add(upsertOverride, artifact.Id, Json(manual)); + await upsertOverride.ExecuteNonQueryAsync(ct); + } + } + + var resolution = action switch + { + ReviewBulkAction.AcceptPlatform => $"Accepted platform {normalizedPlatform}", + ReviewBulkAction.ExcludeFolderPattern => $"Excluded folder pattern {normalizedPattern}", + _ => "Classified as support content" + }; + var afterReviews = new List<ReviewItem>(); + foreach (var item in beforeReviews) + { + var after = item with { State = ReviewState.Resolved, Resolution = resolution, ResolvedAt = now }; + afterReviews.Add(after); + await using var update = new NpgsqlCommand("UPDATE review_items SET state='Resolved',data=$2,updated_at=now() WHERE id=$1", connection, transaction); + Add(update, item.Id, Json(after)); + await update.ExecuteNonQueryAsync(ct); + } + var changesOverrides = action != ReviewBulkAction.ExcludeFolderPattern; + var operation = new ReviewOperation(Guid.NewGuid(), beforeReviews, resolution, actor, now, BeforeArtifacts: beforeArtifacts, + BulkAction: action, BeforeLibraries: beforeLibraries, ScopePattern: normalizedPattern, + BeforeOverrides: changesOverrides ? beforeOverrides : null, After: afterReviews, + AfterArtifacts: afterArtifacts, AfterLibraries: afterLibraries, + AfterOverrides: changesOverrides ? afterOverrides : null, AddedEvidenceIds: addedEvidenceIds); + await using (var insert = new NpgsqlCommand("INSERT INTO review_operations(id,data,created_at) VALUES($1,$2,$3)", connection, transaction)) + { Add(insert, operation.Id, Json(operation), now); await insert.ExecuteNonQueryAsync(ct); } + var audit = new AuditEvent(Guid.NewGuid(), actor, $"ReviewBulk{action}", "ReviewOperation", operation.Id, + Json(new { Reviews = beforeReviews, Artifacts = beforeArtifacts }), Json(operation), Guid.NewGuid(), now); + await using (var insert = new NpgsqlCommand("INSERT INTO audit_events(id,data,created_at) VALUES($1,$2,$3)", connection, transaction)) + { Add(insert, audit.Id, Json(audit), now); await insert.ExecuteNonQueryAsync(ct); } + await transaction.CommitAsync(ct); + foreach (var libraryId in beforeArtifacts.Select(item => item.LibraryId).Distinct()) + await SynchronizeDiscoveredGamesAsync(libraryId, ct); + return operation; + } + + private static Guid? ReviewArtifactId(ReviewItem item) + { + try + { + using var payload = JsonDocument.Parse(item.PayloadJson); + if ((payload.RootElement.TryGetProperty("id", out var id) || payload.RootElement.TryGetProperty("Id", out id)) && + id.ValueKind == JsonValueKind.String && Guid.TryParse(id.GetString(), out var parsed)) return parsed; + } + catch (JsonException) { } + return null; + } + + private async Task<ReviewOperation> ChangeReviewGroupStateAsync(IReadOnlyList<Guid> ids, int expectedCount, string resolution, string actor, + ReviewState sourceState, ReviewState targetState, string auditAction, CancellationToken ct) + { + var exactIds = ids.Distinct().ToArray(); + if (exactIds.Length == 0 || exactIds.Length > 10_000 || exactIds.Length != expectedCount) + throw new ConcurrencyException("The review group scope changed; reload the preview before applying it."); + if (string.IsNullOrWhiteSpace(resolution)) throw new ArgumentException("A resolution is required.", nameof(resolution)); + await using var connection = await dataSource.OpenConnectionAsync(ct); + await using var transaction = await connection.BeginTransactionAsync(ct); + var before = new List<ReviewItem>(); + await using (var select = new NpgsqlCommand("SELECT data FROM review_items WHERE id=ANY($1) AND state=$2 ORDER BY id FOR UPDATE", connection, transaction)) + { + select.Parameters.AddWithValue(exactIds); + select.Parameters.AddWithValue(sourceState.ToString()); + await using var reader = await select.ExecuteReaderAsync(ct); + while (await reader.ReadAsync(ct)) before.Add(Deserialize<ReviewItem>(reader.GetString(0))); + } + if (before.Count != expectedCount) + throw new ConcurrencyException("One or more review items changed after the group preview was loaded."); + var resolvedAt = DateTimeOffset.UtcNow; + var afterReviews = new List<ReviewItem>(before.Count); + foreach (var item in before) + { + var after = item with + { + State = targetState, + Resolution = targetState == ReviewState.Open ? null : resolution.Trim(), + ResolvedAt = targetState == ReviewState.Resolved ? resolvedAt : null + }; + afterReviews.Add(after); + await using var update = new NpgsqlCommand("UPDATE review_items SET state=$2,data=$3,updated_at=now() WHERE id=$1", connection, transaction); + Add(update, item.Id, targetState.ToString(), Json(after)); + await update.ExecuteNonQueryAsync(ct); + } + var operation = new ReviewOperation(Guid.NewGuid(), before, resolution.Trim(), actor, resolvedAt, After: afterReviews); + await using (var insert = new NpgsqlCommand("INSERT INTO review_operations(id,data,created_at) VALUES($1,$2,$3)", connection, transaction)) + { + Add(insert, operation.Id, Json(operation), operation.CreatedAt); + await insert.ExecuteNonQueryAsync(ct); + } + var audit = new AuditEvent(Guid.NewGuid(), actor, auditAction, "ReviewOperation", operation.Id, + Json(before), Json(operation), Guid.NewGuid(), resolvedAt); + await using (var insertAudit = new NpgsqlCommand("INSERT INTO audit_events(id,data,created_at) VALUES($1,$2,$3)", connection, transaction)) + { + Add(insertAudit, audit.Id, Json(audit), audit.CreatedAt); + await insertAudit.ExecuteNonQueryAsync(ct); + } + await transaction.CommitAsync(ct); + return operation; + } + + public async Task UndoReviewOperationAsync(Guid id, string actor, CancellationToken ct) + { + await using var connection = await dataSource.OpenConnectionAsync(ct); + await using var transaction = await connection.BeginTransactionAsync(ct); + ReviewOperation operation; + await using (var select = new NpgsqlCommand("SELECT data FROM review_operations WHERE id=$1 AND undone_at IS NULL FOR UPDATE", connection, transaction)) + { + Add(select, id); + var json = await select.ExecuteScalarAsync(ct) as string ?? throw new KeyNotFoundException("Active review operation not found."); + operation = Deserialize<ReviewOperation>(json); + } + if (operation.After is null || operation.After.Count != operation.Before.Count) + throw new ConcurrencyException("This legacy operation has no exact after-state and cannot be undone safely."); + + var afterReviews = operation.After.ToDictionary(item => item.Id); + foreach (var item in operation.Before) + { + if (!afterReviews.TryGetValue(item.Id, out var expected)) + throw new ConcurrencyException("The review operation snapshot is incomplete and cannot be undone safely."); + await using var restore = new NpgsqlCommand("UPDATE review_items SET state=$2,data=$3,updated_at=now() WHERE id=$1 AND state=$4 AND data=$5::jsonb", connection, transaction); + Add(restore, item.Id, item.State.ToString(), Json(item), expected.State.ToString(), Json(expected)); + if (await restore.ExecuteNonQueryAsync(ct) != 1) + throw new ConcurrencyException("A review item changed after this operation; reload before undoing it."); + } + + var afterArtifacts = (operation.AfterArtifacts ?? []).ToDictionary(item => item.Id); + if ((operation.BeforeArtifacts?.Count ?? 0) != afterArtifacts.Count) + throw new ConcurrencyException("The artifact operation snapshot is incomplete and cannot be undone safely."); + foreach (var artifact in operation.BeforeArtifacts ?? []) + { + if (!afterArtifacts.TryGetValue(artifact.Id, out var expected)) + throw new ConcurrencyException("The artifact operation snapshot is incomplete and cannot be undone safely."); + await using var restore = new NpgsqlCommand("UPDATE artifacts SET data=$2,updated_at=now() WHERE id=$1 AND data=$3::jsonb", connection, transaction); + Add(restore, artifact.Id, Json(artifact), Json(expected)); + if (await restore.ExecuteNonQueryAsync(ct) != 1) + throw new ConcurrencyException("An artifact changed after this operation; reload before undoing it."); + } + + if (operation.AfterOverrides is { Count: > 0 }) + { + foreach (var expected in operation.AfterOverrides) + { + await using var removeOverride = new NpgsqlCommand("DELETE FROM artifact_overrides WHERE artifact_id=$1 AND data=$2::jsonb", connection, transaction); + Add(removeOverride, expected.ArtifactId, Json(expected)); + if (await removeOverride.ExecuteNonQueryAsync(ct) != 1) + throw new ConcurrencyException("A manual artifact override changed after this operation; reload before undoing it."); + } + foreach (var manual in operation.BeforeOverrides ?? []) + { + await using var restoreOverride = new NpgsqlCommand("INSERT INTO artifact_overrides(artifact_id,data) VALUES($1,$2)", connection, transaction); + Add(restoreOverride, manual.ArtifactId, Json(manual)); + await restoreOverride.ExecuteNonQueryAsync(ct); + } + } + + var afterLibraries = (operation.AfterLibraries ?? []).ToDictionary(item => item.Id); + if ((operation.BeforeLibraries?.Count ?? 0) != afterLibraries.Count) + throw new ConcurrencyException("The library operation snapshot is incomplete and cannot be undone safely."); + foreach (var library in operation.BeforeLibraries ?? []) + { + if (!afterLibraries.TryGetValue(library.Id, out var expected)) + throw new ConcurrencyException("The library operation snapshot is incomplete and cannot be undone safely."); + await using var restore = new NpgsqlCommand("UPDATE library_roots SET data=$2,updated_at=now() WHERE id=$1 AND data=$3::jsonb", connection, transaction); + Add(restore, library.Id, Json(library), Json(expected)); + if (await restore.ExecuteNonQueryAsync(ct) != 1) + throw new ConcurrencyException("A library changed after this operation; reload before undoing it."); + } + + if (operation.AddedEvidenceIds is { Count: > 0 }) + { + await using var removeEvidence = new NpgsqlCommand("DELETE FROM artifact_evidence WHERE id=ANY($1)", connection, transaction); + removeEvidence.Parameters.AddWithValue(operation.AddedEvidenceIds.ToArray()); + if (await removeEvidence.ExecuteNonQueryAsync(ct) != operation.AddedEvidenceIds.Count) + throw new ConcurrencyException("Operation evidence changed after this action; reload before undoing it."); + } + + var undoneAt = DateTimeOffset.UtcNow; + var undone = operation with { UndoneAt = undoneAt }; + await using (var update = new NpgsqlCommand("UPDATE review_operations SET data=$2,undone_at=$3 WHERE id=$1", connection, transaction)) + { + Add(update, id, Json(undone), undoneAt); + await update.ExecuteNonQueryAsync(ct); + } + var auditAction = operation.BulkAction is { } bulkAction ? $"ReviewBulk{bulkAction}Undone" : "ReviewOperationUndone"; + var audit = new AuditEvent(Guid.NewGuid(), actor, auditAction, "ReviewOperation", id, + Json(operation), Json(undone), Guid.NewGuid(), undoneAt); + await using (var insertAudit = new NpgsqlCommand("INSERT INTO audit_events(id,data,created_at) VALUES($1,$2,$3)", connection, transaction)) + { + Add(insertAudit, audit.Id, Json(audit), audit.CreatedAt); + await insertAudit.ExecuteNonQueryAsync(ct); + } + await transaction.CommitAsync(ct); + foreach (var libraryId in (operation.BeforeArtifacts ?? []).Select(item => item.LibraryId).Distinct()) + await SynchronizeDiscoveredGamesAsync(libraryId, ct); + } + public async Task<ReviewItem> SetReviewStateAsync(Guid id, ReviewState state, string actor, CancellationToken ct) + { + await using var connection = await dataSource.OpenConnectionAsync(ct); + await using var transaction = await connection.BeginTransactionAsync(ct); + ReviewItem before; + string indexedState; + await using (var select = new NpgsqlCommand("SELECT state,data FROM review_items WHERE id=$1 FOR UPDATE", connection, transaction)) + { + Add(select, id); + await using var reader = await select.ExecuteReaderAsync(ct); + if (!await reader.ReadAsync(ct)) throw new KeyNotFoundException(); + indexedState = reader.GetString(0); + before = Deserialize<ReviewItem>(reader.GetString(1)); + } + if (!indexedState.Equals(before.State.ToString(), StringComparison.Ordinal)) + throw new ConcurrencyException("The review state and its provenance disagree; reload after repairing the review record."); + var validTransition = state switch + { + ReviewState.Deferred => before.State == ReviewState.Open, + ReviewState.Open => before.State is ReviewState.Deferred or ReviewState.Resolved, + ReviewState.Resolved => before.State == ReviewState.Open, + _ => false + }; + if (!validTransition) throw new ConcurrencyException("The review state changed before this action was applied."); + var after = before with + { + State = state, + Resolution = state == ReviewState.Resolved ? before.Resolution : null, + ResolvedAt = state == ReviewState.Resolved ? DateTimeOffset.UtcNow : null + }; + var audit = new AuditEvent(Guid.NewGuid(), actor, "ReviewStateChanged", "ReviewItem", id, Json(before), Json(after), Guid.NewGuid(), DateTimeOffset.UtcNow); + await using (var update = new NpgsqlCommand("UPDATE review_items SET state=$2,data=$3,updated_at=now() WHERE id=$1", connection, transaction)) + { Add(update, id, state.ToString(), Json(after)); await update.ExecuteNonQueryAsync(ct); } + await using (var insertAudit = new NpgsqlCommand("INSERT INTO audit_events(id,data,created_at) VALUES($1,$2,$3)", connection, transaction)) + { Add(insertAudit, audit.Id, Json(audit), audit.CreatedAt); await insertAudit.ExecuteNonQueryAsync(ct); } + await transaction.CommitAsync(ct); + return after; + } + public async Task<ManualResolution> ResolveReviewAsync(Guid id, string resolution, string actor, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(resolution)) throw new ArgumentException("A resolution is required.", nameof(resolution)); + await using var connection = await dataSource.OpenConnectionAsync(ct); + await using var transaction = await connection.BeginTransactionAsync(ct); + ReviewItem before; + string indexedState; + await using (var select = new NpgsqlCommand("SELECT state,data FROM review_items WHERE id=$1 FOR UPDATE", connection, transaction)) + { + Add(select, id); + await using var reader = await select.ExecuteReaderAsync(ct); + if (!await reader.ReadAsync(ct)) throw new KeyNotFoundException(); + indexedState = reader.GetString(0); + before = Deserialize<ReviewItem>(reader.GetString(1)); + } + if (before.State != ReviewState.Open || indexedState != ReviewState.Open.ToString()) + throw new ConcurrencyException("The review is no longer open; reload before resolving it."); + var normalizedResolution = resolution.Trim(); + var after = before with { State = ReviewState.Resolved, Resolution = normalizedResolution, ResolvedAt = DateTimeOffset.UtcNow }; + var audit = new AuditEvent(Guid.NewGuid(), actor, "ReviewResolved", "ReviewItem", id, Json(before), Json(after), Guid.NewGuid(), DateTimeOffset.UtcNow); + var manualResolution = new ManualResolution(Guid.NewGuid(), id, normalizedResolution, actor, Json(before), Json(after), DateTimeOffset.UtcNow); + await using (var update = new NpgsqlCommand("UPDATE review_items SET state='Resolved',data=$2,updated_at=now() WHERE id=$1", connection, transaction)) + { Add(update, id, Json(after)); await update.ExecuteNonQueryAsync(ct); } + await using (var insertAudit = new NpgsqlCommand("INSERT INTO audit_events(id,data,created_at) VALUES($1,$2,$3)", connection, transaction)) + { Add(insertAudit, audit.Id, Json(audit), audit.CreatedAt); await insertAudit.ExecuteNonQueryAsync(ct); } + await using (var insertResolution = new NpgsqlCommand("INSERT INTO resolutions(id,review_item_id,data,created_at) VALUES($1,$2,$3,$4)", connection, transaction)) + { Add(insertResolution, manualResolution.Id, id, Json(manualResolution), manualResolution.CreatedAt); await insertResolution.ExecuteNonQueryAsync(ct); } + await transaction.CommitAsync(ct); + return manualResolution; + } + public async Task UndoResolutionAsync(Guid id, string actor, CancellationToken ct) + { + await using var connection = await dataSource.OpenConnectionAsync(ct); + await using var transaction = await connection.BeginTransactionAsync(ct); + ManualResolution resolution; + await using (var select = new NpgsqlCommand("SELECT data FROM resolutions WHERE id=$1 AND undone_at IS NULL FOR UPDATE", connection, transaction)) + { + Add(select, id); + var json = await select.ExecuteScalarAsync(ct) as string ?? throw new KeyNotFoundException("Active resolution not found."); + resolution = Deserialize<ManualResolution>(json); + } + var before = Deserialize<ReviewItem>(resolution.BeforeJson); + var after = Deserialize<ReviewItem>(resolution.AfterJson); + var undone = resolution with { UndoneAt = DateTimeOffset.UtcNow }; + var audit = new AuditEvent(Guid.NewGuid(), actor, "ReviewResolutionUndone", "ReviewItem", resolution.ReviewItemId, resolution.AfterJson, resolution.BeforeJson, Guid.NewGuid(), DateTimeOffset.UtcNow); + await using (var updateReview = new NpgsqlCommand("UPDATE review_items SET state=$2,data=$3,updated_at=now() WHERE id=$1 AND state=$4 AND data=$5::jsonb", connection, transaction)) + { + Add(updateReview, resolution.ReviewItemId, before.State.ToString(), Json(before), after.State.ToString(), resolution.AfterJson); + if (await updateReview.ExecuteNonQueryAsync(ct) != 1) + throw new ConcurrencyException("The review changed after this resolution; reload before undoing it."); + } + await using (var updateResolution = new NpgsqlCommand("UPDATE resolutions SET data=$2,undone_at=$3 WHERE id=$1", connection, transaction)) + { Add(updateResolution, resolution.Id, Json(undone), undone.UndoneAt!); await updateResolution.ExecuteNonQueryAsync(ct); } + await using (var insertAudit = new NpgsqlCommand("INSERT INTO audit_events(id,data,created_at) VALUES($1,$2,$3)", connection, transaction)) + { Add(insertAudit, audit.Id, Json(audit), audit.CreatedAt); await insertAudit.ExecuteNonQueryAsync(ct); } + await transaction.CommitAsync(ct); + } + public async Task AddClaimAsync(MetadataClaim claim, CancellationToken ct) => await ExecuteAsync("INSERT INTO metadata_claims(id,entity_type,entity_id,field,manual_lock,data,created_at) VALUES($1,$2,$3,$4,$5,$6,$7) ON CONFLICT(id) DO NOTHING", ct, + claim.Id, claim.EntityType, claim.EntityId, claim.Field, claim.ManualLock, Json(claim), claim.CreatedAt); + public Task<IReadOnlyList<MetadataClaim>> ListClaimsAsync(string entityType, Guid entityId, CancellationToken ct) => ListJsonAsync<MetadataClaim>("SELECT data FROM metadata_claims WHERE entity_type=$1 AND entity_id=$2 ORDER BY created_at DESC", ct, entityType, entityId); + public async Task<Game> CreateGameAsync(string title, string actor, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(title)) throw new ArgumentException("Game title is required.", nameof(title)); + var game = new Game(Guid.NewGuid(), title.Trim(), DateTimeOffset.UtcNow, Origin: GameOrigin.Manual); + var claim = ManualClaim("Game", game.Id, "title", game.Title, actor); + var audit = new AuditEvent(Guid.NewGuid(), actor, "GameCreated", "Game", game.Id, null, Json(game), Guid.NewGuid(), DateTimeOffset.UtcNow); + await ExecuteTransactionAsync(ct, + ("INSERT INTO games(id,title,version,data,origin) VALUES($1,$2,$3,$4,$5)", [game.Id, game.Title, game.Version, Json(game), (int)game.Origin]), + ("INSERT INTO metadata_claims(id,entity_type,entity_id,field,manual_lock,data,created_at) VALUES($1,$2,$3,$4,true,$5,$6)", [claim.Id, claim.EntityType, game.Id, claim.Field, Json(claim), claim.CreatedAt]), + ("INSERT INTO audit_events(id,data,created_at) VALUES($1,$2,$3)", [audit.Id, Json(audit), audit.CreatedAt])); + return game; + } + public async Task<Game> UpdateGameAsync(Guid id, string title, long expectedVersion, string actor, CancellationToken ct) + { + var before = await SingleJsonAsync<Game>("SELECT data FROM games WHERE id=$1", id, ct) ?? throw new KeyNotFoundException("Game not found."); + if (string.IsNullOrWhiteSpace(title)) throw new ArgumentException("Game title is required.", nameof(title)); + var after = before with { Title = title.Trim(), Version = before.Version + 1 }; + await using var connection = await dataSource.OpenConnectionAsync(ct); await using var transaction = await connection.BeginTransactionAsync(ct); + await using var update = new NpgsqlCommand("UPDATE games SET title=$2,version=$3,data=$4,updated_at=now() WHERE id=$1 AND version=$5", connection, transaction); + Add(update, id, after.Title, after.Version, Json(after), expectedVersion); + if (await update.ExecuteNonQueryAsync(ct) != 1) throw new ConcurrencyException("The game changed after it was loaded."); + var priorClaims = await ListClaimsAsync("Game", id, ct); var prior = ClaimPolicy.SelectEffective(priorClaims); + var claim = ManualClaim("Game", id, "title", after.Title, actor, prior?.Id); + await using var claimCommand = new NpgsqlCommand("INSERT INTO metadata_claims(id,entity_type,entity_id,field,manual_lock,data,created_at) VALUES($1,$2,$3,$4,true,$5,$6)", connection, transaction); + Add(claimCommand, claim.Id, claim.EntityType, claim.EntityId, claim.Field, Json(claim), claim.CreatedAt); await claimCommand.ExecuteNonQueryAsync(ct); + var audit = new AuditEvent(Guid.NewGuid(), actor, "GameUpdated", "Game", id, Json(before), Json(after), Guid.NewGuid(), DateTimeOffset.UtcNow); + await using var auditCommand = new NpgsqlCommand("INSERT INTO audit_events(id,data,created_at) VALUES($1,$2,$3)", connection, transaction); Add(auditCommand, audit.Id, Json(audit), audit.CreatedAt); await auditCommand.ExecuteNonQueryAsync(ct); + await transaction.CommitAsync(ct); return after; + } + public async Task<GameDeletionResult> DeleteGameAsync(Guid id, long expectedVersion, string actor, CancellationToken ct) + { + await using var connection = await dataSource.OpenConnectionAsync(ct); + await using var transaction = await connection.BeginTransactionAsync(ct); + Game before; + await using (var read = new NpgsqlCommand("SELECT data FROM games WHERE id=$1 FOR UPDATE", connection, transaction)) + { + Add(read, id); + var value = await read.ExecuteScalarAsync(ct); + before = value is string json ? Deserialize<Game>(json) : throw new KeyNotFoundException("Game not found."); + } + if (before.Origin != GameOrigin.Manual) + throw new ResourceConflictException("Scan-derived games cannot be deleted; remove or reclassify their source evidence instead."); + if (before.Version != expectedVersion) throw new ConcurrencyException("The game changed after it was loaded."); + await using (var links = new NpgsqlCommand("SELECT EXISTS(SELECT 1 FROM game_library_links WHERE game_id=$1) OR EXISTS(SELECT 1 FROM game_artifact_links WHERE game_id=$1)", connection, transaction)) + { + Add(links, id); + if (Convert.ToBoolean(await links.ExecuteScalarAsync(ct), CultureInfo.InvariantCulture)) + throw new ResourceConflictException("This game is linked to scanned source evidence and cannot be deleted."); + } + await using var mediaCommand = new NpgsqlCommand("SELECT data FROM game_media WHERE game_id=$1 ORDER BY id", connection, transaction); + Add(mediaCommand, id); + var media = await ReadAsync<GameMedia>(mediaCommand, ct); + await using var revisionsCommand = new NpgsqlCommand("SELECT r.data FROM game_data_revisions r JOIN game_data_entries e ON e.id=r.entry_id WHERE e.game_id=$1 ORDER BY r.id", connection, transaction); + Add(revisionsCommand, id); + var revisions = await ReadAsync<GameDataRevision>(revisionsCommand, ct); + var statements = new[] + { + "UPDATE wishlist_items SET game_id=NULL,version=version+1,data=jsonb_set(jsonb_set(data,'{gameId}','null'::jsonb),'{version}',to_jsonb(version+1)),updated_at=now() WHERE game_id=$1", + "DELETE FROM game_collection_members WHERE game_id=$1", + "DELETE FROM game_tags WHERE game_id=$1", + "DELETE FROM game_user_states WHERE game_id=$1", + "DELETE FROM game_relations WHERE game_id=$1 OR related_game_id=$1", + "DELETE FROM game_media WHERE game_id=$1", + "DELETE FROM game_achievement_progress WHERE game_id=$1", + "DELETE FROM browser_play_sessions WHERE game_id=$1", + "DELETE FROM game_data_entries WHERE game_id=$1", + "DELETE FROM game_artwork WHERE game_id=$1", + "DELETE FROM releases WHERE game_id=$1", + "DELETE FROM metadata_claims WHERE entity_type='Game' AND entity_id=$1", + "DELETE FROM games WHERE id=$1 AND version=$2 AND origin=1" + }; + for (var index = 0; index < statements.Length; index++) + { + await using var command = new NpgsqlCommand(statements[index], connection, transaction); + if (index == statements.Length - 1) Add(command, id, expectedVersion); else Add(command, id); + var affected = await command.ExecuteNonQueryAsync(ct); + if (index == statements.Length - 1 && affected != 1) + throw new ConcurrencyException("The game changed while it was being deleted."); + } + var audit = new AuditEvent(Guid.NewGuid(), actor, "GameDeleted", "Game", id, Json(before), + Json(new { Media = media.Count, DataRevisions = revisions.Count }), Guid.NewGuid(), DateTimeOffset.UtcNow); + await using var auditCommand = new NpgsqlCommand("INSERT INTO audit_events(id,data,created_at) VALUES($1,$2,$3)", connection, transaction); + Add(auditCommand, audit.Id, Json(audit), audit.CreatedAt); await auditCommand.ExecuteNonQueryAsync(ct); + await transaction.CommitAsync(ct); + return new(before, media, revisions); + } + public Task<Game?> GetGameAsync(Guid id, CancellationToken ct) => + SingleJsonAsync<Game>($"SELECT data FROM games g WHERE id=$1 AND {CatalogVisibility.SqlPredicate()}", id, ct); + public Task<IReadOnlyList<Game>> ListGamesAsync(CancellationToken ct) => ListJsonAsync<Game>($"SELECT data FROM games g WHERE {CatalogVisibility.SqlPredicate()} ORDER BY title", ct); + public async Task<Page<Game>> SearchGamesAsync(GameQuery query, CancellationToken ct) + { + var page = Math.Max(1, query.Page); + var pageSize = Math.Clamp(query.PageSize, 1, 200); + var (where, values) = GameFilter(query); + + await using var count = dataSource.CreateCommand("SELECT count(DISTINCT lower(g.title)) FROM games g" + where); + Add(count, [.. values]); + var total = Convert.ToInt64(await count.ExecuteScalarAsync(ct), CultureInfo.InvariantCulture); + + // Deduplication keeps one row per title, so ordering by anything else has to happen outside + // the DISTINCT ON. Sorting inside the page would only reorder what is already on screen. + var sql = $""" + SELECT t.data FROM ( + SELECT DISTINCT ON (lower(g.title)) + g.data, g.title, g.updated_at, + -- The creation date lives in the record, not in a column of its own. + (g.data->>'createdAt')::timestamptz AS created_at, + (s.data->>'lastPlayedAt')::timestamptz AS last_played_at, + (s.data->>'rating')::int AS rating + FROM games g + LEFT JOIN game_user_states s ON s.game_id=g.id + {where} + ORDER BY lower(g.title),g.updated_at + ) t + ORDER BY {GameOrder(query.Sort)} + LIMIT {pageSize} OFFSET {(page - 1) * pageSize} + """; + await using var command = dataSource.CreateCommand(sql); + Add(command, [.. values]); + return new(await ReadAsync<Game>(command, ct), page, pageSize, total); + } + + private static string GameOrder(string? sort) => sort?.Trim().ToLowerInvariant() switch + { + "recent" => "t.created_at DESC,lower(t.title)", + "updated" => "t.updated_at DESC,lower(t.title)", + "played" => "t.last_played_at DESC NULLS LAST,lower(t.title)", + "rating" => "t.rating DESC NULLS LAST,lower(t.title)", + _ => "lower(t.title)" + }; + + private static (string Where, List<object> Values) GameFilter(GameQuery query) + { + var predicates = new List<string> { CatalogVisibility.SqlPredicate() }; + var values = new List<object>(); + void Add(object value, string predicate) + { + values.Add(value); + predicates.Add(string.Format(CultureInfo.InvariantCulture, predicate, values.Count)); + } + + if (!string.IsNullOrWhiteSpace(query.Search)) Add($"%{query.Search.Trim()}%", "g.title ILIKE ${0}"); + if (!string.IsNullOrWhiteSpace(query.Platform)) + Add(query.Platform.Trim(), "EXISTS (SELECT 1 FROM releases r WHERE r.game_id=g.id AND r.data->>'platform'=${0})"); + if (query.Favorite is not null) + Add(query.Favorite.Value, "coalesce((SELECT gus.favorite FROM game_user_states gus WHERE gus.game_id=g.id),false)=${0}"); + if (query.Status is not null) + Add((int)query.Status.Value, "coalesce((SELECT gus.status FROM game_user_states gus WHERE gus.game_id=g.id),0)=${0}"); + if (query.CollectionId is not null) + Add(query.CollectionId.Value, "EXISTS (SELECT 1 FROM game_collection_members cm WHERE cm.collection_id=${0} AND cm.game_id=g.id)"); + if (!string.IsNullOrWhiteSpace(query.Tag)) + Add(LibraryExperiencePolicy.NormalizeTag(query.Tag), "EXISTS (SELECT 1 FROM game_tags gt WHERE gt.game_id=g.id AND gt.normalized_name=${0})"); + if (query.MinimumRating is not null) + Add(Math.Clamp(query.MinimumRating.Value, 1, 5), "coalesce((SELECT (gus.data->>'rating')::int FROM game_user_states gus WHERE gus.game_id=g.id),0)>=${0}"); + if (query.PlayableOnly) + // One array containment over exact platform-and-container pairs, so the filter can never + // claim a format no player accepts. + Add(PlayableFormats.Pairs.ToArray(), """ + EXISTS (SELECT 1 FROM game_artifact_links x + JOIN artifacts a ON a.id=x.artifact_id + WHERE x.game_id=g.id AND a.data->>'state'='0' + AND lower(coalesce(a.data->>'platform','')) || '|' || + lower(coalesce(substring(a.relative_path from '\.[^./\\]+$'),'')) = ANY(${0})) + """); + + return (" WHERE " + string.Join(" AND ", predicates), values); + } + public async Task<Page<WishlistItem>> SearchWishlistAsync(string? query, string? platform, WishlistPriority? priority, WishlistStatus? status, string? sort, int page, int pageSize, CancellationToken ct) + { + page = Math.Max(1, page); pageSize = Math.Clamp(pageSize, 1, 10_000); + var predicates = new List<string>(); var values = new List<object>(); + if (!string.IsNullOrWhiteSpace(query)) { values.Add($"%{query.Trim()}%"); predicates.Add($"(title ILIKE ${values.Count} OR coalesce(data->>'notes','') ILIKE ${values.Count} OR coalesce(data->>'storeName','') ILIKE ${values.Count})"); } + if (!string.IsNullOrWhiteSpace(platform)) { values.Add(platform.Trim()); predicates.Add($"platform=${values.Count}"); } + if (priority is not null) { values.Add((int)priority.Value); predicates.Add($"priority=${values.Count}"); } + if (status is not null) { values.Add((int)status.Value); predicates.Add($"status=${values.Count}"); } + var where = predicates.Count == 0 ? "" : " WHERE " + string.Join(" AND ", predicates); + await using var count = dataSource.CreateCommand("SELECT count(*) FROM wishlist_items" + where); Add(count, [.. values]); + var total = Convert.ToInt64(await count.ExecuteScalarAsync(ct), CultureInfo.InvariantCulture); + var order = sort?.ToLowerInvariant() switch { "title" => "lower(title)", "release" => "release_date NULLS LAST, lower(title)", "price" => "((data->>'currentPrice')::numeric) NULLS LAST, lower(title)", "added" => "created_at DESC", _ => "priority DESC, updated_at DESC, lower(title)" }; + await using var command = dataSource.CreateCommand("SELECT data FROM wishlist_items" + where + $" ORDER BY {order} LIMIT {pageSize} OFFSET {(page - 1) * pageSize}"); Add(command, [.. values]); + return new(await ReadAsync<WishlistItem>(command, ct), page, pageSize, total); + } + public async Task<WishlistSummary> GetWishlistSummaryAsync(CancellationToken ct) + { + await using var command = dataSource.CreateCommand("SELECT count(*) FILTER(WHERE status<>4),count(*) FILTER(WHERE priority=2 AND status<>4),count(*) FILTER(WHERE status<>4 AND release_date BETWEEN now() AND now()+interval '90 days'),count(*) FILTER(WHERE status<>4 AND (data->>'currentPrice')::numeric <= (data->>'desiredPrice')::numeric),count(*) FILTER(WHERE status=4) FROM wishlist_items"); + await using var reader = await command.ExecuteReaderAsync(ct); await reader.ReadAsync(ct); + return new(reader.GetInt64(0), reader.GetInt64(1), reader.GetInt64(2), reader.GetInt64(3), reader.GetInt64(4)); + } + public async Task<WishlistItem> CreateWishlistItemAsync(WishlistInput input, string actor, CancellationToken ct) + { + ValidateWishlist(input); + if (input.GameId is not null && await SingleJsonAsync<Game>("SELECT data FROM games WHERE id=$1", input.GameId.Value, ct) is null) throw new KeyNotFoundException("Game not found."); + if (input.GameId is not null && await SingleJsonAsync<WishlistItem>("SELECT data FROM wishlist_items WHERE game_id=$1", input.GameId.Value, ct) is not null) throw new ResourceConflictException("This game is already on the wishlist."); + if (input.GameId is null && await SingleJsonAsync<WishlistItem>("SELECT data FROM wishlist_items WHERE game_id IS NULL AND lower(title)=lower($1) AND coalesce(platform,'')=coalesce($2,'') AND coalesce(data->>'edition','')=coalesce($3,'') LIMIT 1", ct, input.Title.Trim(), Clean(input.Platform, 80) ?? "", Clean(input.Edition, 120) ?? "") is not null) + throw new ResourceConflictException("This title and edition are already on the wishlist."); + var now = DateTimeOffset.UtcNow; + var item = new WishlistItem(Guid.NewGuid(), input.GameId, input.Title.Trim(), Clean(input.Platform, 80), input.Priority, Clean(input.Notes, 2000), now, now, Status: input.Status, + DesiredPrice: input.DesiredPrice, CurrentPrice: input.CurrentPrice, Currency: Clean(input.Currency, 3)?.ToUpperInvariant(), StoreName: Clean(input.StoreName, 100), StoreUrl: SafeUrl(input.StoreUrl), + Edition: Clean(input.Edition, 120), Region: Clean(input.Region, 80), ReleaseDate: input.ReleaseDate, ExternalProvider: Clean(input.ExternalProvider, 40), ExternalId: Clean(input.ExternalId, 100), ArtworkUrl: SafeUrl(input.ArtworkUrl)); + var audit = new AuditEvent(Guid.NewGuid(), actor, "WishlistItemCreated", "WishlistItem", item.Id, null, Json(item), Guid.NewGuid(), now); + await ExecuteTransactionAsync(ct, + ("INSERT INTO wishlist_items(id,game_id,title,platform,priority,status,release_date,version,data,created_at,updated_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)", [item.Id, DbValue(item.GameId), item.Title, DbValue(item.Platform), (int)item.Priority, (int)item.Status, DbValue(item.ReleaseDate), item.Version, Json(item), now, now]), + ("INSERT INTO audit_events(id,data,created_at) VALUES($1,$2,$3)", [audit.Id, Json(audit), audit.CreatedAt])); + return item; + } + public async Task<WishlistItem> UpdateWishlistItemAsync(Guid id, WishlistInput input, long expectedVersion, string actor, CancellationToken ct) + { + var before = await SingleJsonAsync<WishlistItem>("SELECT data FROM wishlist_items WHERE id=$1", id, ct) ?? throw new KeyNotFoundException("Wishlist item not found."); + ValidateWishlist(input); + if (input.GameId is not null && await SingleJsonAsync<Game>("SELECT data FROM games WHERE id=$1", input.GameId.Value, ct) is null) throw new KeyNotFoundException("Game not found."); + if (input.GameId is not null && await SingleJsonAsync<WishlistItem>("SELECT data FROM wishlist_items WHERE game_id=$1 AND id<>$2", ct, input.GameId.Value, id) is not null) throw new ResourceConflictException("This game is already on the wishlist."); + var after = before with + { + GameId = input.GameId, + Title = input.Title.Trim(), + Platform = Clean(input.Platform, 80), + Priority = input.Priority, + Notes = Clean(input.Notes, 2000), + Status = input.Status, + DesiredPrice = input.DesiredPrice, + CurrentPrice = input.CurrentPrice, + Currency = Clean(input.Currency, 3)?.ToUpperInvariant(), + StoreName = Clean(input.StoreName, 100), + StoreUrl = SafeUrl(input.StoreUrl), + Edition = Clean(input.Edition, 120), + Region = Clean(input.Region, 80), + ReleaseDate = input.ReleaseDate, + ExternalProvider = Clean(input.ExternalProvider, 40), + ExternalId = Clean(input.ExternalId, 100), + ArtworkUrl = SafeUrl(input.ArtworkUrl), + UpdatedAt = DateTimeOffset.UtcNow, + Version = before.Version + 1 + }; + await using var connection = await dataSource.OpenConnectionAsync(ct); await using var transaction = await connection.BeginTransactionAsync(ct); + await using var update = new NpgsqlCommand("UPDATE wishlist_items SET game_id=$2,title=$3,platform=$4,priority=$5,status=$6,release_date=$7,version=$8,data=$9,updated_at=$10 WHERE id=$1 AND version=$11", connection, transaction); + Add(update, id, DbValue(after.GameId), after.Title, DbValue(after.Platform), (int)after.Priority, (int)after.Status, DbValue(after.ReleaseDate), after.Version, Json(after), after.UpdatedAt, expectedVersion); + if (await update.ExecuteNonQueryAsync(ct) != 1) throw new ConcurrencyException("The wishlist item changed after it was loaded."); + var audit = new AuditEvent(Guid.NewGuid(), actor, "WishlistItemUpdated", "WishlistItem", id, Json(before), Json(after), Guid.NewGuid(), after.UpdatedAt); + await using var auditCommand = new NpgsqlCommand("INSERT INTO audit_events(id,data,created_at) VALUES($1,$2,$3)", connection, transaction); Add(auditCommand, audit.Id, Json(audit), audit.CreatedAt); await auditCommand.ExecuteNonQueryAsync(ct); + await transaction.CommitAsync(ct); return after; + } + public async Task DeleteWishlistItemAsync(Guid id, long expectedVersion, string actor, CancellationToken ct) + { + var before = await SingleJsonAsync<WishlistItem>("SELECT data FROM wishlist_items WHERE id=$1", id, ct) ?? throw new KeyNotFoundException("Wishlist item not found."); + if (before.Version != expectedVersion) throw new ConcurrencyException("The wishlist item changed after it was loaded."); + var audit = new AuditEvent(Guid.NewGuid(), actor, "WishlistItemDeleted", "WishlistItem", id, Json(before), null, Guid.NewGuid(), DateTimeOffset.UtcNow); + await ExecuteTransactionAsync(ct, + ("DELETE FROM wishlist_items WHERE id=$1 AND version=$2", [id, expectedVersion]), + ("INSERT INTO audit_events(id,data,created_at) VALUES($1,$2,$3)", [audit.Id, Json(audit), audit.CreatedAt])); + } + public async Task<int> ReconcileWishlistAsync(string actor, CancellationToken ct) + { + const string sql = """ + WITH unique_games AS (SELECT lower(title) title_key,(array_agg(id ORDER BY id))[1] game_id FROM games GROUP BY lower(title) HAVING count(*)=1), + matches AS (SELECT jsonb_set(w.data,'{gameId}',to_jsonb(g.game_id)) data FROM wishlist_items w JOIN unique_games g ON g.title_key=lower(w.title) WHERE w.game_id IS NULL) + SELECT data FROM matches; + """; + var candidates = await ListJsonAsync<WishlistItem>(sql, ct); + var changed = 0; + foreach (var candidate in candidates) + { + var input = new WishlistInput(candidate.GameId, candidate.Title, candidate.Platform, candidate.Priority, candidate.Notes, + WishlistStatus.Acquired, candidate.DesiredPrice, candidate.CurrentPrice, candidate.Currency, candidate.StoreName, + candidate.StoreUrl, candidate.Edition, candidate.Region, candidate.ReleaseDate, candidate.ExternalProvider, + candidate.ExternalId, candidate.ArtworkUrl); + try + { + await UpdateWishlistItemAsync(candidate.Id, input, candidate.Version, actor, ct); + changed++; + } + catch (ConcurrencyException) { } + catch (ResourceConflictException) { } + } + return changed; + } + public async Task<int> SynchronizeDiscoveredGamesAsync(Guid libraryId, CancellationToken ct) + { + var sql = $$$""" + CREATE TEMP TABLE discovered_games ON COMMIT DROP AS + WITH raw AS ( + SELECT id AS artifact_id, data->>'platform' AS platform, relative_path, + CASE + WHEN relative_path LIKE 'PC/%' THEN split_part(relative_path, '/', 2) + WHEN relative_path LIKE 'roms/%/%' THEN split_part(relative_path, '/', 3) + ELSE split_part(relative_path, '/', 1) + END AS raw_title + FROM artifacts + WHERE library_id=$1 AND data->>'state'='0' AND data->>'platform' IS NOT NULL + AND relative_path NOT LIKE 'roms/Tools/%' AND relative_path NOT LIKE 'PC/.gameyfin/%' + ), normalized AS ( + SELECT platform, raw_title, min(relative_path) AS evidence_path, + (array_agg(artifact_id ORDER BY relative_path))[1] AS artifact_id, + btrim(regexp_replace(regexp_replace( + regexp_replace( + regexp_replace( + regexp_replace(translate(regexp_replace(raw_title, '\.({{{LibraryExperiencePolicy.TitleExtensionPattern}}})$', '', 'i'), '._', ' '), + '\s*\[(fitgirl( monkey)? repack|dodi repack|gog|portable|repack|update[^]]*|[a-z0-9]{6})\]\s*$', '', 'i'), + '\s*\((usa|europe|japan|world|australia|korea|asia)[^)]*\)(\s*\([^)]*\))*\s*$', '', 'i'), + '\s*[-–]\s*(duplex|codex|flt|reloaded|skidrow|gog|razor1911|venom|psypsp)\s*$', '', 'i'), + '\s+', ' ', 'g'), '^\d{3,5}\s*-\s*', ''), ' -.') AS title + FROM raw + WHERE lower(raw_title) NOT IN ('covers','imgs','none','$recycle.bin','system volume information') + GROUP BY platform, raw_title + ) + SELECT c.platform, c.title, c.evidence_path, c.artifact_id, + -- Both sides are normalised, so a game keeps its identity when the stripped vocabulary + -- grows: "Sonic md" and "Sonic" are the same game, and no duplicate is created for it. + COALESCE((SELECT g.id FROM games g + WHERE lower(regexp_replace(g.title, '(\.|\s+)({{{LibraryExperiencePolicy.TitleExtensionPattern}}})$', '', 'i')) + IN (lower(c.title), lower(regexp_replace(c.raw_title, '\.({{{LibraryExperiencePolicy.TitleExtensionPattern}}})$', '', 'i'))) + ORDER BY g.updated_at LIMIT 1), + md5('ludarium:game:' || lower(regexp_replace(c.raw_title, '\.({{{LibraryExperiencePolicy.TitleExtensionPattern}}})$', '', 'i')))::uuid) AS game_id + FROM normalized c WHERE btrim(c.title)<>''; + + INSERT INTO games(id,title,version,data,origin) + SELECT DISTINCT ON (game_id) game_id,title,1, + jsonb_build_object('id',game_id,'title',title,'createdAt',now(),'version',1,'origin',0),0 + FROM discovered_games + ON CONFLICT(id) DO UPDATE SET + title=CASE WHEN EXISTS (SELECT 1 FROM metadata_claims m + WHERE m.entity_type='Game' AND m.entity_id=games.id AND m.field='title' AND m.manual_lock) + THEN games.title ELSE excluded.title END, + origin=0, + data=jsonb_set(CASE WHEN EXISTS (SELECT 1 FROM metadata_claims m + WHERE m.entity_type='Game' AND m.entity_id=games.id AND m.field='title' AND m.manual_lock) + THEN games.data ELSE jsonb_set(games.data,'{title}',to_jsonb(excluded.title)) END, + '{origin}','0'::jsonb),updated_at=now(); + + WITH normalized_titles AS ( + SELECT g.id, btrim(regexp_replace(regexp_replace( + regexp_replace( + regexp_replace( + regexp_replace(translate(regexp_replace(g.title, + '(\.|\s+)({{{LibraryExperiencePolicy.TitleExtensionPattern}}})$', '', 'i'), '._', ' '), + '\s*\[(fitgirl( monkey)? repack|dodi repack|gog|portable|repack|update[^]]*|[a-z0-9]{6})\]\s*$', '', 'i'), + '\s*\((usa|europe|japan|world|australia|korea|asia)[^)]*\)(\s*\([^)]*\))*\s*$', '', 'i'), + '\s*[-–]\s*(duplex|codex|flt|reloaded|skidrow|gog|razor1911|venom|psypsp)\s*$', '', 'i'), + '\s+', ' ', 'g'), '^\d{3,5}\s*-\s*', ''), ' -.') AS title + FROM games g + WHERE NOT EXISTS (SELECT 1 FROM metadata_claims m + WHERE m.entity_type='Game' AND m.entity_id=g.id AND m.field='title' AND m.manual_lock) + ) + UPDATE games g SET title=n.title, data=jsonb_set(g.data,'{title}',to_jsonb(n.title)), updated_at=now() + FROM normalized_titles n WHERE n.id=g.id AND n.title<>'' AND n.title<>g.title; + + INSERT INTO metadata_claims(id,entity_type,entity_id,field,manual_lock,data,created_at) + SELECT DISTINCT ON (game_id) md5('ludarium:claim:title:' || game_id::text)::uuid,'Game',game_id,'title',false, + jsonb_build_object( + 'id',md5('ludarium:claim:title:' || game_id::text)::uuid,'entityType','Game','entityId',game_id, + 'field','title','value',title,'sourceType','DirectoryInference','sourceId',$1::text, + 'confidence',3,'evidence',jsonb_build_array(jsonb_build_object( + 'kind','directory.game','value',evidence_path,'source','scan-grouping','sourceVersion','1', + 'confidence',3,'observedAt',now())), + 'manualLock',false,'createdAt',now(),'supersedesId',null),now() + FROM discovered_games + ON CONFLICT(id) DO UPDATE SET data=jsonb_set(metadata_claims.data,'{value}',to_jsonb(excluded.data->>'value')), + created_at=excluded.created_at + WHERE metadata_claims.manual_lock=false; + + INSERT INTO releases(id,game_id,title,version,data,origin) + SELECT DISTINCT ON (game_id,platform) md5('ludarium:release:' || game_id::text || ':' || platform)::uuid,game_id,title,1, + jsonb_build_object('id',md5('ludarium:release:' || game_id::text || ':' || platform)::uuid, + 'gameId',game_id,'title',title,'platform',platform,'region',null,'revision',null,'createdAt',now(),'version',1,'origin',0),0 + FROM discovered_games ORDER BY game_id,platform,title + ON CONFLICT(id) DO UPDATE SET title=excluded.title, + data=jsonb_set(releases.data,'{title}',to_jsonb(excluded.title)),updated_at=now(); + + WITH cleaned AS ( + SELECT g.id,btrim(regexp_replace(regexp_replace(regexp_replace(regexp_replace( + g.title, + '\s*(-\s*)?(duplex|codex|flt|reloaded|skidrow|gog|razor1911|venom|psypsp|internal-ps5b)\s*$', '', 'i'), + '\s*(\[(u|e|j|ntsc(-[uj])?|pal|scus-[0-9]+|sles-[0-9]+|slus-[0-9]+|ul[eus]-[0-9]+)\]\s*)+$', '', 'i'), + '\s*[\[(](usa|eur(ope)?|japan|world|australia|korea|asia|v(er(sion)?)?\s*[0-9]+)[^\])]*$', '', 'i'), + '\s*(-?\s*)?(ps[1-5]|playstation\s*[1-5]?|psp|ps\s*vita|switch|wiiu?|gamecube|n64|snes|nes|gba|gbc|nds|3ds)\s*$', '', 'i'), ' -.') AS title + FROM games g + WHERE NOT EXISTS (SELECT 1 FROM metadata_claims m + WHERE m.entity_type='Game' AND m.entity_id=g.id AND m.field='title' AND m.manual_lock) + ) + UPDATE games g SET title=c.title,data=jsonb_set(g.data,'{title}',to_jsonb(c.title)),updated_at=now() + FROM cleaned c WHERE c.id=g.id AND c.title<>'' AND c.title<>g.title; + + UPDATE releases r SET title=g.title, + data=jsonb_set(r.data,'{title}',to_jsonb(g.title)),updated_at=now() + FROM games g WHERE g.id=r.game_id AND r.title<>g.title; + + DELETE FROM game_library_links WHERE library_id=$1; + + INSERT INTO game_library_links(library_id,game_id) + SELECT DISTINCT $1,game_id FROM discovered_games ON CONFLICT DO NOTHING; + + DELETE FROM game_artifact_links l + USING artifacts a WHERE l.artifact_id=a.id AND a.library_id=$1 AND l.source='scan-grouping'; + + INSERT INTO game_artifact_links(game_id,artifact_id,source,created_at) + SELECT DISTINCT game_id,artifact_id,'scan-grouping',now() FROM discovered_games + ON CONFLICT DO NOTHING; + + SELECT count(DISTINCT game_id)::int FROM discovered_games; + """; + await using var connection = await dataSource.OpenConnectionAsync(ct); + await using var transaction = await connection.BeginTransactionAsync(ct); + var statements = sql.Replace("\r\n", "\n", StringComparison.Ordinal) + .Split(";\n\n", StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + for (var index = 0; index < statements.Length - 1; index++) + { + await using var step = new NpgsqlCommand(statements[index], connection, transaction); + Add(step, libraryId); + await step.ExecuteNonQueryAsync(ct); + } + await using var countCommand = new NpgsqlCommand(statements[^1], connection, transaction); + Add(countCommand, libraryId); + var count = Convert.ToInt32(await countCommand.ExecuteScalarAsync(ct), System.Globalization.CultureInfo.InvariantCulture); + await transaction.CommitAsync(ct); + return count; + } + + public async Task<IReadOnlyList<PlatformSummary>> ListPlatformsAsync(CancellationToken ct) + { + var visible = CatalogVisibility.SqlPredicate("g"); + var sql = $$$""" + WITH artifact_summary AS ( + SELECT data->>'platform' AS platform, count(*)::bigint AS artifacts, coalesce(sum(size),0)::bigint AS bytes + FROM artifacts WHERE data->>'state'='0' AND data->>'platform' IS NOT NULL GROUP BY data->>'platform' + ), release_summary AS ( + SELECT r.data->>'platform' AS platform, count(DISTINCT lower(g.title))::bigint AS games + FROM releases r JOIN games g ON g.id=r.game_id + WHERE {{{visible}}} + GROUP BY r.data->>'platform' + ) + SELECT a.platform,a.artifacts,a.bytes,coalesce(r.games,0)::bigint + FROM artifact_summary a LEFT JOIN release_summary r USING(platform) ORDER BY a.artifacts DESC + """; + await using var command = dataSource.CreateCommand(sql); + var result = new List<PlatformSummary>(); + await using var reader = await command.ExecuteReaderAsync(ct); + while (await reader.ReadAsync(ct)) result.Add(new(reader.GetString(0), reader.GetInt64(1), reader.GetInt64(2), reader.GetInt64(3))); + return result; + } + public async Task<Release> CreateReleaseAsync(Guid gameId, string title, string? platform, string? region, string? revision, string actor, CancellationToken ct) + { + _ = await SingleJsonAsync<Game>("SELECT data FROM games WHERE id=$1", gameId, ct) ?? throw new KeyNotFoundException("Game not found."); + if (string.IsNullOrWhiteSpace(title)) throw new ArgumentException("Release title is required.", nameof(title)); + var release = new Release(Guid.NewGuid(), gameId, title.Trim(), Clean(platform, 80), Clean(region, 80), + Clean(revision, 80), DateTimeOffset.UtcNow, Origin: ReleaseOrigin.Manual); + var audit = new AuditEvent(Guid.NewGuid(), actor, "ReleaseCreated", "Release", release.Id, null, Json(release), Guid.NewGuid(), DateTimeOffset.UtcNow); + await ExecuteTransactionAsync(ct, + ("INSERT INTO releases(id,game_id,title,version,data,origin) VALUES($1,$2,$3,$4,$5,$6)", [release.Id, gameId, release.Title, release.Version, Json(release), (int)release.Origin]), + ("INSERT INTO audit_events(id,data,created_at) VALUES($1,$2,$3)", [audit.Id, Json(audit), audit.CreatedAt])); + return release; + } + public async Task<Release> UpdateReleaseAsync(Guid id, string title, string? platform, string? region, + string? revision, long expectedVersion, string actor, CancellationToken ct) + { + var before = await SingleJsonAsync<Release>("SELECT data FROM releases WHERE id=$1", id, ct) + ?? throw new KeyNotFoundException("Release not found."); + if (before.Origin != ReleaseOrigin.Manual) + throw new ResourceConflictException("Scan-derived releases cannot be edited; change the source classification or metadata instead."); + if (string.IsNullOrWhiteSpace(title)) throw new ArgumentException("Release title is required.", nameof(title)); + var after = before with + { + Title = title.Trim(), + Platform = Clean(platform, 80), + Region = Clean(region, 80), + Revision = Clean(revision, 80), + Version = before.Version + 1 + }; + var audit = new AuditEvent(Guid.NewGuid(), actor, "ReleaseUpdated", "Release", id, Json(before), Json(after), + Guid.NewGuid(), DateTimeOffset.UtcNow); + await using var connection = await dataSource.OpenConnectionAsync(ct); + await using var transaction = await connection.BeginTransactionAsync(ct); + await using var update = new NpgsqlCommand("UPDATE releases SET title=$2,version=$3,data=$4,updated_at=now() WHERE id=$1 AND version=$5 AND origin=1", connection, transaction); + Add(update, id, after.Title, after.Version, Json(after), expectedVersion); + if (await update.ExecuteNonQueryAsync(ct) != 1) throw new ConcurrencyException("The release changed after it was loaded."); + await using var auditCommand = new NpgsqlCommand("INSERT INTO audit_events(id,data,created_at) VALUES($1,$2,$3)", connection, transaction); + Add(auditCommand, audit.Id, Json(audit), audit.CreatedAt); await auditCommand.ExecuteNonQueryAsync(ct); + await transaction.CommitAsync(ct); + return after; + } + public async Task DeleteReleaseAsync(Guid id, long expectedVersion, string actor, CancellationToken ct) + { + var before = await SingleJsonAsync<Release>("SELECT data FROM releases WHERE id=$1", id, ct) + ?? throw new KeyNotFoundException("Release not found."); + if (before.Origin != ReleaseOrigin.Manual) + throw new ResourceConflictException("Scan-derived releases cannot be deleted; they are reconciled from read-only source evidence."); + var audit = new AuditEvent(Guid.NewGuid(), actor, "ReleaseDeleted", "Release", id, Json(before), null, + Guid.NewGuid(), DateTimeOffset.UtcNow); + await using var connection = await dataSource.OpenConnectionAsync(ct); + await using var transaction = await connection.BeginTransactionAsync(ct); + await using var delete = new NpgsqlCommand("DELETE FROM releases WHERE id=$1 AND version=$2 AND origin=1", connection, transaction); + Add(delete, id, expectedVersion); + if (await delete.ExecuteNonQueryAsync(ct) != 1) throw new ConcurrencyException("The release changed after it was loaded."); + await using var auditCommand = new NpgsqlCommand("INSERT INTO audit_events(id,data,created_at) VALUES($1,$2,$3)", connection, transaction); + Add(auditCommand, audit.Id, Json(audit), audit.CreatedAt); await auditCommand.ExecuteNonQueryAsync(ct); + await transaction.CommitAsync(ct); + } + public Task<IReadOnlyList<Release>> ListReleasesAsync(Guid? gameId, CancellationToken ct) => gameId is null + ? ListJsonAsync<Release>("SELECT data FROM releases ORDER BY title", ct) + : ListJsonAsync<Release>("SELECT data FROM releases WHERE game_id=$1 ORDER BY title", ct, gameId.Value); + + public Task<IReadOnlyList<Release>> ListReleasesForGamesAsync(IReadOnlyList<Guid> gameIds, CancellationToken ct) => + gameIds.Count == 0 + ? Task.FromResult<IReadOnlyList<Release>>([]) + : ListJsonAsync<Release>("SELECT data FROM releases WHERE game_id=ANY($1) ORDER BY title", ct, + gameIds.ToArray()); + public async Task<ArtifactDetails?> GetGameArtworkAsync(Guid gameId, CancellationToken ct) + { + const string sql = """ + SELECT a.id FROM artifacts a + JOIN releases r ON r.game_id=$1 + WHERE a.data->>'state' IN ('0','3') + AND lower(a.relative_path) ~ '\.(jpg|jpeg|png|webp)$' + AND (a.relative_path ILIKE '%' || r.title || '%' OR a.relative_path ILIKE '%' || replace(r.title,' ','%') || '%') + AND lower(a.relative_path) ~ '(^|/)(imgs|covers|artwork)(/|$)' + ORDER BY CASE + WHEN lower(a.relative_path) ~ '/covers?/cover1\.' THEN 0 + WHEN lower(a.relative_path) ~ '/covers?/' THEN 1 + ELSE 2 END, a.size + LIMIT 1 + """; + await using var command = dataSource.CreateCommand(sql); + command.Parameters.AddWithValue(gameId); + var id = await command.ExecuteScalarAsync(ct); + return id is Guid artifactId ? await GetArtifactDetailsAsync(artifactId, ct) : null; + } + public async Task UpsertGameArtworkAsync(GameArtwork artwork, CancellationToken ct) + { + await using var connection = await dataSource.OpenConnectionAsync(ct); + await using var transaction = await connection.BeginTransactionAsync(ct); + if (artwork.Selected) + { + await using var deselect = new NpgsqlCommand("UPDATE game_artwork SET selected=false,data=jsonb_set(data,'{selected}','false'::jsonb),updated_at=now() WHERE game_id=$1 AND selected AND id<>$2 AND coalesce(data->>'platform','')=coalesce($3,'')", connection, transaction); + Add(deselect, artwork.GameId, artwork.Id, DbValue(artwork.Platform)); + await deselect.ExecuteNonQueryAsync(ct); + } + await using var upsert = new NpgsqlCommand("INSERT INTO game_artwork(id,game_id,selected,quality_score,data) VALUES($1,$2,$3,$4,$5) ON CONFLICT(id) DO UPDATE SET selected=excluded.selected,quality_score=excluded.quality_score,data=excluded.data,updated_at=now()", connection, transaction); + Add(upsert, artwork.Id, artwork.GameId, artwork.Selected, artwork.QualityScore, Json(artwork)); + await upsert.ExecuteNonQueryAsync(ct); + await transaction.CommitAsync(ct); + } + public Task<GameArtwork?> GetSelectedGameArtworkAsync(Guid gameId, CancellationToken ct, string? platform = null) => + SingleJsonAsync<GameArtwork>(""" + SELECT data FROM game_artwork + WHERE game_id=$1 AND selected AND ( + ($2::text IS NULL AND data->>'platform' IS NULL) OR + data->>'platform'=$2 OR + ($2::text IS NOT NULL AND data->>'platform' IS NULL AND data->>'source'='Uploaded')) + ORDER BY (data->>'platform'=$2) DESC,quality_score DESC LIMIT 1 + """, ct, gameId, DbValue(platform)); + public Task<IReadOnlyList<GameArtwork>> ListGameArtworkAsync(Guid gameId, CancellationToken ct) => + ListJsonAsync<GameArtwork>("SELECT data FROM game_artwork WHERE game_id=$1 ORDER BY selected DESC,quality_score DESC,updated_at DESC", ct, gameId); + public async Task SelectGameArtworkAsync(Guid gameId, Guid artworkId, CancellationToken ct) + { + await using var connection = await dataSource.OpenConnectionAsync(ct); + await using var transaction = await connection.BeginTransactionAsync(ct); + await using var exists = new NpgsqlCommand("SELECT count(*) FROM game_artwork WHERE game_id=$1 AND id=$2", connection, transaction); + Add(exists, gameId, artworkId); + if (Convert.ToInt32(await exists.ExecuteScalarAsync(ct), System.Globalization.CultureInfo.InvariantCulture) != 1) throw new KeyNotFoundException("Artwork candidate was not found for this game."); + await using var deselect = new NpgsqlCommand("UPDATE game_artwork SET selected=false,data=jsonb_set(data,'{selected}','false'::jsonb),updated_at=now() WHERE game_id=$1 AND selected AND coalesce(data->>'platform','')=coalesce((SELECT data->>'platform' FROM game_artwork WHERE game_id=$1 AND id=$2),'')", connection, transaction); + Add(deselect, gameId, artworkId); await deselect.ExecuteNonQueryAsync(ct); + await using var select = new NpgsqlCommand("UPDATE game_artwork SET selected=true,data=jsonb_set(data,'{selected}','true'::jsonb),updated_at=now() WHERE game_id=$1 AND id=$2", connection, transaction); + Add(select, gameId, artworkId); await select.ExecuteNonQueryAsync(ct); + await transaction.CommitAsync(ct); + } + public Task<IReadOnlyList<GameArtwork>> ListArtworkNeedingReviewAsync(CancellationToken ct) => + ListJsonAsync<GameArtwork>("SELECT DISTINCT ON (game_id,coalesce(data->>'platform','')) data FROM game_artwork WHERE quality_score<80 OR data->>'verificationStatus' IN ('0','3') ORDER BY game_id,coalesce(data->>'platform',''),selected DESC,quality_score,updated_at DESC LIMIT 1000", ct); + public async Task<IReadOnlyList<ArtworkReviewItem>> ListArtworkReviewItemsAsync(CancellationToken ct) + { + const string sql = "SELECT DISTINCT ON (ga.game_id,coalesce(ga.data->>'platform','')) ga.game_id,g.title,ga.data FROM game_artwork ga JOIN games g ON g.id=ga.game_id WHERE ga.quality_score<80 OR ga.data->>'verificationStatus' IN ('0','3') ORDER BY ga.game_id,coalesce(ga.data->>'platform',''),ga.selected DESC,ga.quality_score,ga.updated_at DESC LIMIT 250"; + await using var command = dataSource.CreateCommand(sql); + var result = new List<ArtworkReviewItem>(); + await using var reader = await command.ExecuteReaderAsync(ct); + while (await reader.ReadAsync(ct)) result.Add(new(reader.GetGuid(0), reader.GetString(1), Deserialize<GameArtwork>(reader.GetString(2)))); + return result; + } + public async Task<BackgroundJob> EnqueueJobAsync(string kind, string payloadJson, string? idempotencyKey, CancellationToken ct) + { + if (!string.IsNullOrWhiteSpace(idempotencyKey)) + { + var existing = await SingleJsonAsync<BackgroundJob>("SELECT data FROM jobs WHERE idempotency_key=$1", ct, idempotencyKey); + if (existing is not null) return existing; + } + var now = DateTimeOffset.UtcNow; + var job = new BackgroundJob(Guid.NewGuid(), kind, JobState.Queued, payloadJson, 0, null, 0, 3, now, now, now); + await ExecuteAsync("INSERT INTO jobs(id,kind,state,payload,data,idempotency_key,scheduled_at) VALUES($1,$2,$3,$4::jsonb,$5,$6,$7)", ct, + job.Id, kind, job.State.ToString(), payloadJson, Json(job), DbValue(idempotencyKey), now); + return job; + } + public async Task<BackgroundJob?> LeaseJobAsync(string worker, TimeSpan lease, CancellationToken ct) + { + await using var connection = await dataSource.OpenConnectionAsync(ct); + await using var transaction = await connection.BeginTransactionAsync(ct); + const string sql = "SELECT id,data FROM jobs WHERE data IS NOT NULL AND (state='Queued' OR (state='Leased' AND lease_until<now())) AND scheduled_at<=now() ORDER BY priority DESC,scheduled_at FOR UPDATE SKIP LOCKED LIMIT 1"; + Guid id; BackgroundJob job; + await using (var select = new NpgsqlCommand(sql, connection, transaction)) + await using (var reader = await select.ExecuteReaderAsync(ct)) + { + if (!await reader.ReadAsync(ct)) return null; + id = reader.GetGuid(0); job = Deserialize<BackgroundJob>(reader.GetString(1)); + } + var leased = job with { State = JobState.Leased, LeaseOwner = worker, LeaseUntil = DateTimeOffset.UtcNow.Add(lease), Attempts = job.Attempts + 1, UpdatedAt = DateTimeOffset.UtcNow }; + await using var update = new NpgsqlCommand("UPDATE jobs SET state='Leased',lease_owner=$2,lease_until=$3,attempts=$4,data=$5 WHERE id=$1", connection, transaction); + Add(update, id, worker, leased.LeaseUntil!, leased.Attempts, Json(leased)); + await update.ExecuteNonQueryAsync(ct); await transaction.CommitAsync(ct); return leased; + } + public Task UpdateJobAsync(BackgroundJob job, CancellationToken ct) => ExecuteAsync( + "UPDATE jobs SET state=$2,lease_owner=$3,lease_until=$4,attempts=$5,data=$6,scheduled_at=$7 WHERE id=$1", ct, + job.Id, job.State.ToString(), DbValue(job.LeaseOwner), DbValue(job.LeaseUntil), job.Attempts, Json(job), job.ScheduledAt); + public Task<IReadOnlyList<BackgroundJob>> ListJobsAsync(CancellationToken ct) => + ListJsonAsync<BackgroundJob>("SELECT data FROM jobs WHERE data IS NOT NULL ORDER BY scheduled_at DESC LIMIT 200", ct); + public Task<BackgroundJob?> GetJobAsync(Guid id, CancellationToken ct) => + SingleJsonAsync<BackgroundJob>("SELECT data FROM jobs WHERE id=$1", id, ct); + public async Task RequestJobCancellationAsync(Guid id, CancellationToken ct) + { + var job = await GetJobAsync(id, ct) ?? throw new KeyNotFoundException("Job not found."); + if (job.State is JobState.Completed or JobState.Cancelled or JobState.DeadLetter) return; + var cancelled = job with + { + CancellationRequested = true, + State = job.State == JobState.Queued ? JobState.Cancelled : job.State, + UpdatedAt = DateTimeOffset.UtcNow + }; + await UpdateJobAsync(cancelled, ct); + } + public async Task<BackgroundJob> RetryJobAsync(Guid id, CancellationToken ct) + { + var job = await GetJobAsync(id, ct) ?? throw new KeyNotFoundException("Job not found."); + if (job.State is not (JobState.Failed or JobState.DeadLetter or JobState.Cancelled)) + throw new ConcurrencyException("Only failed, dead-lettered or cancelled jobs can be retried."); + var retry = job with { State = JobState.Queued, CancellationRequested = false, Error = null, Attempts = 0, ScheduledAt = DateTimeOffset.UtcNow, UpdatedAt = DateTimeOffset.UtcNow }; + await UpdateJobAsync(retry, ct); return retry; + } + public Task UpsertProviderSnapshotAsync(ProviderSnapshot snapshot, CancellationToken ct) => ExecuteAsync( + "INSERT INTO provider_snapshots(id,provider,data) VALUES($1,$2,$3) ON CONFLICT(provider) DO UPDATE SET data=excluded.data,updated_at=now()", ct, + snapshot.Id, snapshot.Provider, Json(snapshot)); + public async Task<IReadOnlyList<ProviderSnapshot>> ListProviderSnapshotsAsync(CancellationToken ct) + { + var snapshots = await ListJsonAsync<ProviderSnapshot>("SELECT data FROM provider_snapshots ORDER BY provider", ct); + var now = DateTimeOffset.UtcNow; + return snapshots.Select(snapshot => snapshot.ExpiresAt <= now && snapshot.State == "Ready" + ? snapshot with { State = "Expired", Error = "Cached provider snapshot expired; refresh required." } + : snapshot).ToArray(); + } + public Task<IReadOnlyList<Finding>> ListFindingsAsync(CancellationToken ct) => ListJsonAsync<Finding>("SELECT data FROM findings ORDER BY created_at DESC", ct); + public async Task AddFindingAsync(Finding finding, CancellationToken ct) => await ExecuteAsync("INSERT INTO findings(id,data,created_at) VALUES($1,$2,$3) ON CONFLICT(id) DO NOTHING", ct, finding.Id, Json(finding), finding.CreatedAt); + public async Task<IntegritySnapshot> CreateSnapshotAsync(string name, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(name)) throw new ArgumentException("Snapshot name is required.", nameof(name)); + var artifacts = await ListJsonAsync<Artifact>("SELECT data FROM artifacts WHERE sha256 IS NOT NULL ORDER BY library_id,relative_path", ct); + var items = artifacts.Where(x => x.State == ArtifactState.Present && x.Sha256 is not null) + .Select(x => new SnapshotItem(x.LibraryId, x.RelativePath, x.Size, x.Sha256!, x.Id, null)).ToList().AsReadOnly(); + if (items.Count == 0) + throw new SnapshotPreconditionException( + "An integrity snapshot needs at least one present file with a SHA-256 hash. Run a full or integrity scan first."); + var snapshot = new IntegritySnapshot(Guid.NewGuid(), name.Trim(), "1", "0.1.0", DateTimeOffset.UtcNow, items); + await ExecuteAsync("INSERT INTO integrity_snapshots(id,name,data,created_at) VALUES($1,$2,$3,$4)", ct, snapshot.Id, snapshot.Name, Json(snapshot), snapshot.CreatedAt); + return snapshot; + } + public Task<IReadOnlyList<IntegritySnapshot>> ListSnapshotsAsync(CancellationToken ct) => ListJsonAsync<IntegritySnapshot>("SELECT data FROM integrity_snapshots ORDER BY created_at DESC", ct); + public Task<IntegritySnapshot?> GetSnapshotAsync(Guid id, CancellationToken ct) => SingleJsonAsync<IntegritySnapshot>("SELECT data FROM integrity_snapshots WHERE id=$1", id, ct); + public async Task<OperationalSettings> GetOperationalSettingsAsync(CancellationToken ct) + { + await using var command = dataSource.CreateCommand("SELECT data FROM operational_settings WHERE id=1"); + var value = await command.ExecuteScalarAsync(ct); + return value is null or DBNull ? OperationalSettings.Defaults : Deserialize<OperationalSettings>((string)value); + } + public async Task<OperationalSettings> SaveOperationalSettingsAsync(OperationalSettings settings, long expectedVersion, CancellationToken ct) + { + var current = await GetOperationalSettingsAsync(ct); + if (current.Version != expectedVersion) throw new ConcurrencyException("Operational settings changed after they were loaded."); + var updated = settings with + { + ReviewRetentionDays = Math.Clamp(settings.ReviewRetentionDays, 30, 3650), + AuditRetentionDays = Math.Clamp(settings.AuditRetentionDays, 90, 3650), + ExportRetentionDays = Math.Clamp(settings.ExportRetentionDays, 1, 365), + DefaultPageSize = Math.Clamp(settings.DefaultPageSize, 20, 200), + Version = current.Version + 1, + UpdatedAt = DateTimeOffset.UtcNow + }; + await using var command = dataSource.CreateCommand("INSERT INTO operational_settings(id,version,data) VALUES(1,$1,$2) ON CONFLICT(id) DO UPDATE SET version=excluded.version,data=excluded.data,updated_at=now() WHERE operational_settings.version=$3 RETURNING data"); + Add(command, updated.Version, Json(updated), expectedVersion); + var saved = await command.ExecuteScalarAsync(ct); + if (saved is null) throw new ConcurrencyException("Operational settings changed while they were being saved."); + return Deserialize<OperationalSettings>((string)saved); + } + public async Task UpsertCatalogSourceAsync(CatalogSource source, CancellationToken ct) => await ExecuteAsync("INSERT INTO catalog_sources(id,name,format,version,data) VALUES($1,$2,$3,$4,$5) ON CONFLICT(id) DO UPDATE SET name=excluded.name,format=excluded.format,version=excluded.version,data=excluded.data,updated_at=now()", ct, source.Id, source.Name, source.Format, source.Version, Json(source)); + public async Task UpsertCatalogEntriesAsync(IReadOnlyList<CatalogEntry> entries, CancellationToken ct) + { + if (entries.Count == 0) return; + await using var connection = await dataSource.OpenConnectionAsync(ct); + await using var transaction = await connection.BeginTransactionAsync(ct); + foreach (var entry in entries) + { + await using var command = new NpgsqlCommand("INSERT INTO catalog_entries(id,source_id,identity,size,sha256,sha1,crc32,data) VALUES($1,$2,$3,$4,$5,$6,$7,$8) ON CONFLICT(source_id,identity) DO UPDATE SET size=excluded.size,sha256=excluded.sha256,sha1=excluded.sha1,crc32=excluded.crc32,data=excluded.data", connection, transaction); + Add(command, entry.Id, entry.SourceId, entry.Identity, DbValue(entry.Size), DbValue(entry.Sha256), DbValue(entry.Sha1), DbValue(entry.Crc32), Json(entry)); + await command.ExecuteNonQueryAsync(ct); + } + await transaction.CommitAsync(ct); + } + public Task<IReadOnlyList<CatalogSource>> ListCatalogSourcesAsync(CancellationToken ct) => ListJsonAsync<CatalogSource>("SELECT data FROM catalog_sources ORDER BY updated_at DESC", ct); + public async Task<int> MatchCatalogAsync(Guid sourceId, CancellationToken ct) + { + const string sql = """ + SELECT a.id, c.id, CASE WHEN a.sha256 IS NOT NULL AND a.sha256=c.sha256 THEN 'sha256' WHEN a.sha1 IS NOT NULL AND a.sha1=c.sha1 THEN 'sha1' ELSE 'crc32' END + FROM artifacts a JOIN catalog_entries c ON c.source_id=$1 AND ((a.sha256 IS NOT NULL AND a.sha256=c.sha256) OR (a.sha1 IS NOT NULL AND a.sha1=c.sha1) OR (a.crc32 IS NOT NULL AND a.crc32=c.crc32)) + WHERE c.size IS NULL OR c.size=a.size + """; + await using var connection = await dataSource.OpenConnectionAsync(ct); + var matches = new List<(Guid Artifact, Guid Entry, string Method)>(); + await using (var query = new NpgsqlCommand(sql, connection)) + { + query.Parameters.AddWithValue(sourceId); + await using var reader = await query.ExecuteReaderAsync(ct); + while (await reader.ReadAsync(ct)) matches.Add((reader.GetGuid(0), reader.GetGuid(1), reader.GetString(2))); + } + foreach (var match in matches) + { + var decisive = match.Method is "sha256" or "sha1"; + var score = decisive ? 1m : 0.85m; + var component = new MatchScoreComponent($"exact-{match.Method}", score, $"Artifact and catalog entry share {match.Method.ToUpperInvariant()} and compatible size."); + var candidate = new MatchCandidate(DeterministicGuid(match.Artifact, match.Entry), match.Artifact, match.Entry, score, [component], decisive ? "AutoAcceptedExact" : "NeedsReview", DateTimeOffset.UtcNow); + await using var insert = new NpgsqlCommand("INSERT INTO match_candidates(id,artifact_id,catalog_entry_id,state,data,created_at) VALUES($1,$2,$3,$4,$5,$6) ON CONFLICT(artifact_id,catalog_entry_id) DO UPDATE SET state=excluded.state,data=excluded.data", connection); + Add(insert, candidate.Id, candidate.ArtifactId, candidate.CatalogEntryId, candidate.State, Json(candidate), candidate.CreatedAt); + await insert.ExecuteNonQueryAsync(ct); + if (!decisive) + { + var review = new ReviewItem(DeterministicGuid(candidate.Id, Guid.Empty), "Catalog ambiguity", Severity.Notice, ReviewState.Open, + Json(new { candidate.Id, candidate.ArtifactId, candidate.CatalogEntryId, candidate.Components }), DateTimeOffset.UtcNow); + // A catalog replay must never reset a review decision or make the indexed state column + // disagree with its immutable decision/provenance snapshot. The deterministic id means + // the existing item already represents this exact artifact/catalog candidate. + await using var reviewInsert = new NpgsqlCommand("INSERT INTO review_items(id,state,data) VALUES($1,$2,$3) ON CONFLICT(id) DO NOTHING", connection); + Add(reviewInsert, review.Id, review.State.ToString(), Json(review)); + await reviewInsert.ExecuteNonQueryAsync(ct); + } + } + return matches.Count; + } + public Task<IReadOnlyList<MatchCandidate>> ListMatchCandidatesAsync(CancellationToken ct) => ListJsonAsync<MatchCandidate>("SELECT data FROM match_candidates ORDER BY created_at DESC", ct); + + private async Task ExecuteAsync(string sql, CancellationToken ct, params object[] values) + { await using var cmd = dataSource.CreateCommand(sql); Add(cmd, values); await cmd.ExecuteNonQueryAsync(ct); } + private async Task ExecuteTransactionAsync(CancellationToken ct, params (string Sql, object[] Values)[] statements) + { + await using var connection = await dataSource.OpenConnectionAsync(ct); + await using var transaction = await connection.BeginTransactionAsync(ct); + foreach (var (sql, values) in statements) + { + await using var command = new NpgsqlCommand(sql, connection, transaction); + Add(command, values); + await command.ExecuteNonQueryAsync(ct); + } + await transaction.CommitAsync(ct); + } + private async Task<T?> SingleJsonAsync<T>(string sql, Guid id, CancellationToken ct) => await SingleJsonAsync<T>(sql, ct, id); + private async Task<T?> SingleJsonAsync<T>(string sql, CancellationToken ct, params object[] values) + { await using var cmd = dataSource.CreateCommand(sql); Add(cmd, values); var value = await cmd.ExecuteScalarAsync(ct); return value is null or DBNull ? default : Deserialize<T>((string)value); } + private async Task<IReadOnlyList<T>> ListJsonAsync<T>(string sql, CancellationToken ct, params object[] values) + { await using var cmd = dataSource.CreateCommand(sql); Add(cmd, values); return await ReadAsync<T>(cmd, ct); } + private static async Task<IReadOnlyList<T>> ReadAsync<T>(NpgsqlCommand cmd, CancellationToken ct) + { var result = new List<T>(); await using var reader = await cmd.ExecuteReaderAsync(ct); while (await reader.ReadAsync(ct)) result.Add(Deserialize<T>(reader.GetString(0))); return result; } + private static string Json<T>(T value) => JsonSerializer.Serialize(value, JsonOptions); + private static T Deserialize<T>(string json) => JsonSerializer.Deserialize<T>(json, JsonOptions) ?? throw new InvalidDataException("Stored JSON was invalid."); + private static object DbValue(object? value) => value ?? DBNull.Value; + private static string? Clean(string? value, int maximumLength) + { + if (string.IsNullOrWhiteSpace(value)) return null; + var trimmed = value.Trim(); + return trimmed.Length <= maximumLength ? trimmed : trimmed[..maximumLength]; + } + private static void ValidateWishlist(WishlistInput input) + { + if (string.IsNullOrWhiteSpace(input.Title)) throw new ArgumentException("A wishlist title is required."); + if (input.Title.Trim().Length > 200) throw new ArgumentException("A wishlist title cannot exceed 200 characters."); + if (input.DesiredPrice is < 0 || input.CurrentPrice is < 0) throw new ArgumentException("Prices cannot be negative."); + } + private static string? SafeUrl(string? value) + { + var cleaned = Clean(value, 2000); + if (cleaned is null) return null; + return Uri.TryCreate(cleaned, UriKind.Absolute, out var uri) && uri.Scheme is "https" or "http" ? uri.ToString() : null; + } + private static MetadataClaim ManualClaim(string entityType, Guid entityId, string field, string value, string actor, Guid? supersedesId = null) + { + var now = DateTimeOffset.UtcNow; + return new MetadataClaim(Guid.NewGuid(), entityType, entityId, field, value, "Manual", actor, Confidence.Deterministic, + [new Evidence("operator-decision", value, "local-user", "1", Confidence.Deterministic, now)], true, now, supersedesId); + } + private static Guid DeterministicGuid(Guid left, Guid right) + { + Span<byte> input = stackalloc byte[32]; left.TryWriteBytes(input[..16]); right.TryWriteBytes(input[16..]); + return new Guid(System.Security.Cryptography.SHA256.HashData(input)[..16]); + } + private static void Add(NpgsqlCommand cmd, params object[] values) { foreach (var value in values) { var p = cmd.Parameters.Add(new NpgsqlParameter { Value = value }); if (value is string s && (s.StartsWith('{') || s.StartsWith('['))) p.NpgsqlDbType = NpgsqlDbType.Jsonb; } } +} diff --git a/src/Ludarium.Infrastructure/RawgGameDiscoveryService.cs b/src/Ludarium.Infrastructure/RawgGameDiscoveryService.cs new file mode 100644 index 0000000..4698bd3 --- /dev/null +++ b/src/Ludarium.Infrastructure/RawgGameDiscoveryService.cs @@ -0,0 +1,64 @@ +using System.Text.Json; +using Ludarium.Application; + +namespace Ludarium.Infrastructure; + +public sealed class RawgGameDiscoveryService(HttpClient client, string? apiKey) : IGameDiscoveryService +{ + private const int MaximumResponseBytes = 2 * 1024 * 1024; + public bool Configured => !string.IsNullOrWhiteSpace(apiKey); + public async Task<GameDiscoveryResponse> SearchAsync(string query, CancellationToken ct) + { + if (!Configured) return new(false, "RAWG", [], "Set RAWG_API_KEY to enable optional online discovery."); + if (string.IsNullOrWhiteSpace(query) || query.Trim().Length < 2) return new(true, "RAWG", []); + using var response = await client.GetAsync($"https://api.rawg.io/api/games?key={Uri.EscapeDataString(apiKey!)}&search={Uri.EscapeDataString(query.Trim())}&search_exact=true&page_size=8", HttpCompletionOption.ResponseHeadersRead, ct); + response.EnsureSuccessStatusCode(); + if (response.Content.Headers.ContentLength is > MaximumResponseBytes) throw new InvalidDataException("RAWG response exceeds the 2 MiB safety limit."); + await using var stream = await response.Content.ReadAsStreamAsync(ct); + await using var bounded = new MemoryStream(); + var buffer = new byte[64 * 1024]; + while (true) + { + var read = await stream.ReadAsync(buffer, ct); + if (read == 0) break; + if (bounded.Length + read > MaximumResponseBytes) throw new InvalidDataException("RAWG response exceeds the 2 MiB safety limit."); + await bounded.WriteAsync(buffer.AsMemory(0, read), ct); + } + bounded.Position = 0; + using var json = await JsonDocument.ParseAsync(bounded, cancellationToken: ct); + var results = new List<GameDiscoveryResult>(); + foreach (var game in json.RootElement.GetProperty("results").EnumerateArray()) + { + var platform = game.TryGetProperty("platforms", out var ps) && ps.GetArrayLength() > 0 ? NormalizePlatform(ps[0].GetProperty("platform").GetProperty("slug").GetString()) : null; + DateTimeOffset? released = game.TryGetProperty("released", out var rd) && DateTimeOffset.TryParse(rd.GetString(), out var date) ? date : null; + results.Add(new("RAWG", game.GetProperty("id").ToString(), game.GetProperty("name").GetString() ?? "Unknown game", platform, released, + game.TryGetProperty("background_image", out var image) && image.ValueKind == JsonValueKind.String ? image.GetString() : null, + game.TryGetProperty("slug", out var slug) ? $"https://rawg.io/games/{slug.GetString()}" : null)); + } + return new(true, "RAWG", results); + } + + private static string? NormalizePlatform(string? value) => value switch + { + "pc" => "windows", + "playstation" => "psx", + "playstation2" => "ps2", + "playstation3" => "ps3", + "playstation4" => "ps4", + "playstation5" => "ps5", + "psp" => "psp", + "ps-vita" => "psvita", + "nes" => "nes", + "snes" => "snes", + "nintendo-64" => "n64", + "game-boy" => "gb", + "game-boy-color" => "gbc", + "game-boy-advance" => "gba", + "nintendo-ds" => "nds", + "nintendo-3ds" => "3ds", + "gamecube" => "gamecube", + "wii" => "wii", + "nintendo-switch" => "switch", + _ => null + }; +} diff --git a/src/Ludarium.Infrastructure/ReadOnlyLibraryFileSystem.cs b/src/Ludarium.Infrastructure/ReadOnlyLibraryFileSystem.cs new file mode 100644 index 0000000..7fb0e32 --- /dev/null +++ b/src/Ludarium.Infrastructure/ReadOnlyLibraryFileSystem.cs @@ -0,0 +1,130 @@ +using Ludarium.Application; +using Ludarium.Domain; + +namespace Ludarium.Infrastructure; + +public sealed partial class ReadOnlyLibraryFileSystem : IReadOnlyLibraryFileSystem +{ + public RootVerification Verify(string path) + { + try + { + var full = Path.GetFullPath(path); + if (!Directory.Exists(full)) return new(false, false, false, "The configured root does not exist."); + using var enumerator = Directory.EnumerateFileSystemEntries(full).GetEnumerator(); + var hasEntries = enumerator.MoveNext(); + // Unix mount flags are the authoritative deployment check. On Windows a non-destructive probe cannot prove mount mode. + var readOnly = !OperatingSystem.IsWindows() && IsUnixReadOnly(full); + return new(true, true, readOnly, readOnly ? "Root is readable and mounted read-only." : "Root is readable; read-only mount could not be confirmed.", hasEntries); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return new(Directory.Exists(path), false, false, $"Root is not readable: {ex.Message}"); + } + } + + public async IAsyncEnumerable<FileObservation> EnumerateAsync(LibraryRoot root, + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) + { + var options = new EnumerationOptions + { + RecurseSubdirectories = root.Recursive, + IgnoreInaccessible = false, + ReturnSpecialDirectories = false, + AttributesToSkip = FileAttributes.ReparsePoint + }; + foreach (var path in Directory.EnumerateFiles(root.Path, "*", options)) + { + cancellationToken.ThrowIfCancellationRequested(); + var relative = Path.GetRelativePath(root.Path, path).Replace('\\', '/'); + if (root.Exclusions?.Any(x => GlobMatch(relative, x)) == true) continue; + var info = new FileInfo(path); + yield return new(relative, info.Length, info.LastWriteTimeUtc); + await Task.Yield(); + } + } + + public ValueTask<Stream> OpenReadAsync(LibraryRoot root, string relativePath, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + var path = ResolveContainedPath(root, relativePath); + Stream stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete, + 1024 * 1024, FileOptions.Asynchronous | FileOptions.SequentialScan); + return ValueTask.FromResult(stream); + } + + public string ResolveContainedPath(LibraryRoot root, string relativePath) + { + if (Path.IsPathFullyQualified(relativePath)) throw new InvalidOperationException("Only relative library paths are allowed."); + var rootPath = Path.TrimEndingDirectorySeparator(Path.GetFullPath(root.Path)); + var candidate = Path.GetFullPath(Path.Combine(rootPath, relativePath)); + var comparison = OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + if (!candidate.StartsWith(rootPath + Path.DirectorySeparatorChar, comparison) && !candidate.Equals(rootPath, comparison)) + throw new InvalidOperationException("Path escapes the configured library root."); + RejectLinkedPathComponents(rootPath, candidate); + return candidate; + } + + private static void RejectLinkedPathComponents(string rootPath, string candidate) + { + var relative = Path.GetRelativePath(rootPath, candidate); + if (relative == ".") return; + + var current = rootPath; + foreach (var component in relative.Split([Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], + StringSplitOptions.RemoveEmptyEntries)) + { + current = Path.Combine(current, component); + if (!File.Exists(current) && !Directory.Exists(current)) continue; + var entry = Directory.Exists(current) ? (FileSystemInfo)new DirectoryInfo(current) : new FileInfo(current); + if (entry.LinkTarget is not null || entry.Attributes.HasFlag(FileAttributes.ReparsePoint)) + throw new InvalidOperationException("Linked paths are not allowed inside a library root."); + } + } + + private static bool GlobMatch(string value, string pattern) + { + var normalizedValue = value.Replace('\\', '/'); + var normalizedPattern = pattern.Trim().Replace('\\', '/').TrimStart('/'); + if (normalizedPattern.Length == 0) return false; + if (!normalizedPattern.Contains('/') && !normalizedPattern.Contains('*') && !normalizedPattern.Contains('?')) + return Path.GetFileName(normalizedValue).Equals(normalizedPattern, StringComparison.OrdinalIgnoreCase); + var expression = System.Text.RegularExpressions.Regex.Escape(normalizedPattern) + .Replace("\\*\\*", ".*", StringComparison.Ordinal) + .Replace("\\*", "[^/]*", StringComparison.Ordinal) + .Replace("\\?", "[^/]", StringComparison.Ordinal); + return System.Text.RegularExpressions.Regex.IsMatch(normalizedValue, $"^{expression}$", + System.Text.RegularExpressions.RegexOptions.IgnoreCase | System.Text.RegularExpressions.RegexOptions.CultureInvariant, + TimeSpan.FromMilliseconds(100)); + } + + private static bool IsUnixReadOnly(string path) + { + const string mountInfo = "/proc/self/mountinfo"; + if (!File.Exists(mountInfo)) return false; + + var fullPath = Path.TrimEndingDirectorySeparator(Path.GetFullPath(path)); + string? bestMount = null; + var bestReadOnly = false; + foreach (var line in File.ReadLines(mountInfo)) + { + var fields = line.Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (fields.Length < 6) continue; + var mountPoint = DecodeMountInfoPath(fields[4]); + if (!fullPath.Equals(mountPoint, StringComparison.Ordinal) && + !fullPath.StartsWith(Path.TrimEndingDirectorySeparator(mountPoint) + Path.DirectorySeparatorChar, StringComparison.Ordinal)) + continue; + if (bestMount is not null && mountPoint.Length <= bestMount.Length) continue; + + bestMount = mountPoint; + bestReadOnly = fields[5].Split(',').Contains("ro", StringComparer.Ordinal); + } + return bestMount is not null && bestReadOnly; + } + + private static string DecodeMountInfoPath(string value) => value + .Replace("\\040", " ", StringComparison.Ordinal) + .Replace("\\011", "\t", StringComparison.Ordinal) + .Replace("\\012", "\n", StringComparison.Ordinal) + .Replace("\\134", "\\", StringComparison.Ordinal); +} diff --git a/src/Ludarium.Infrastructure/SupportBundleService.cs b/src/Ludarium.Infrastructure/SupportBundleService.cs new file mode 100644 index 0000000..04a3d9d --- /dev/null +++ b/src/Ludarium.Infrastructure/SupportBundleService.cs @@ -0,0 +1,38 @@ +using System.IO.Compression; +using System.Text.Json; +using Ludarium.Application; + +namespace Ludarium.Infrastructure; + +public sealed class SupportBundleService(ILudariumStore store, string exportRoot) : ISupportBundleService +{ + private static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web) { WriteIndented = true }; + + public async Task<SupportBundleResult> CreateAsync(CancellationToken cancellationToken) + { + Directory.CreateDirectory(exportRoot); + var createdAt = DateTimeOffset.UtcNow; + var fileName = $"ludarium-support-{createdAt:yyyyMMdd-HHmmss}.zip"; + var target = Path.Combine(Path.GetFullPath(exportRoot), fileName); + var libraries = await store.ListLibrariesAsync(cancellationToken); + var scans = await store.ListScansAsync(cancellationToken); + var findings = await store.ListFindingsAsync(cancellationToken); + await using var output = new FileStream(target, FileMode.CreateNew, FileAccess.Write, FileShare.None, 64 * 1024, FileOptions.Asynchronous); + using var archive = new ZipArchive(output, ZipArchiveMode.Create, leaveOpen: true); + await WriteJsonAsync(archive, "system.json", new { applicationVersion = ReleaseIdentity.Version, schemaVersion = PostgresStore.CurrentSchemaVersion, createdAt, telemetry = false, externalProvidersRequired = false }, cancellationToken); + await WriteJsonAsync(archive, "libraries.json", libraries.Select(x => new { x.Id, x.Name, x.Kind, x.Enabled, x.IsAvailable, x.IsReadOnly, x.LastVerifiedAt, path = "[redacted]" }), cancellationToken); + await WriteJsonAsync(archive, "scans.json", scans.Select(x => new { x.Id, x.LibraryId, x.Mode, x.State, x.Stage, x.ItemsCompleted, x.BytesCompleted, x.CreatedAt, x.StartedAt, x.FinishedAt, error = x.Error is null ? null : "[redacted diagnostic; consult local logs]" }), cancellationToken); + // Finding text can contain source paths or analyzer/provider diagnostics. Export only the + // operational classification; the local authenticated UI remains the place for details. + await WriteJsonAsync(archive, "findings.json", findings.Select(x => new { x.Id, x.Category, x.Severity, message = "[redacted finding detail; consult the local authenticated UI]", x.CreatedAt }), cancellationToken); + await WriteJsonAsync(archive, "capabilities.json", new { peHeaders = true, peVersionResources = true, msiSummaryInformation = true, msiDatabaseProperties = true, msiMediaAndFileRelations = true, msiCabinetExtraction = false, zipCentralDirectory = true, sevenZipInspection = true, logiqx = true, cue = true, m3u = true, gameDataVault = true, sourceMutation = false, executableLaunch = false }, cancellationToken); + return new(fileName, createdAt, libraries.Count, scans.Count); + } + + private static async Task WriteJsonAsync<T>(ZipArchive archive, string name, T value, CancellationToken cancellationToken) + { + var entry = archive.CreateEntry(name, CompressionLevel.SmallestSize); + await using var stream = entry.Open(); + await JsonSerializer.SerializeAsync(stream, value, Options, cancellationToken); + } +} diff --git a/src/Ludarium.Infrastructure/SwitchRuntimeProvisioner.cs b/src/Ludarium.Infrastructure/SwitchRuntimeProvisioner.cs new file mode 100644 index 0000000..7a49c8e --- /dev/null +++ b/src/Ludarium.Infrastructure/SwitchRuntimeProvisioner.cs @@ -0,0 +1,73 @@ +using Ludarium.Application; +using Ludarium.Domain; + +namespace Ludarium.Infrastructure; + +/// <summary>Copies only verified, operator-uploaded Switch keys into app-owned Eden configuration.</summary> +public sealed class SwitchRuntimeProvisioner(string? configuredRoot) +{ + private readonly string? root = NormalizeRoot(configuredRoot); + + public bool Configured => root is not null; + + public async Task ProvisionAsync(FirmwareAsset asset, FirmwareVaultFileStore vault, CancellationToken ct) + { + var fileName = RuntimeFileName(asset.Platform, asset.Slot); + if (root is null || fileName is null) return; + var content = await vault.OpenAsync(asset, ct) + ?? throw new FirmwareIntegrityException("The selected Switch key file is missing from the firmware vault."); + await using var input = content.Content; + Directory.CreateDirectory(root); + var destination = Path.Combine(root, fileName); + var temporary = Path.Combine(root, $".{fileName}.{Guid.NewGuid():N}.uploading"); + try + { + await using (var output = new FileStream(temporary, FileMode.CreateNew, FileAccess.Write, + FileShare.None, 64 * 1024, FileOptions.Asynchronous | FileOptions.SequentialScan)) + { + RestrictToOwner(temporary); + await input.CopyToAsync(output, ct); + await output.FlushAsync(ct); + } + File.Move(temporary, destination, true); + RestrictToOwner(destination); + } + finally + { + if (File.Exists(temporary)) File.Delete(temporary); + } + } + + public void Remove(FirmwareAsset asset) + { + var fileName = RuntimeFileName(asset.Platform, asset.Slot); + if (root is null || fileName is null) return; + var path = Path.Combine(root, fileName); + if (File.Exists(path)) File.Delete(path); + } + + private static string? RuntimeFileName(string platform, string slot) + { + if (!platform.Equals("switch", StringComparison.OrdinalIgnoreCase)) return null; + return slot.ToLowerInvariant() switch + { + "prod-keys" => "prod.keys", + "title-keys" => "title.keys", + _ => null + }; + } + + private static string? NormalizeRoot(string? value) + { + if (string.IsNullOrWhiteSpace(value)) return null; + if (!Path.IsPathFullyQualified(value)) + throw new InvalidOperationException("LUDARIUM_SWITCH_KEYS_ROOT must be an absolute app-owned path."); + return Path.GetFullPath(value); + } + + private static void RestrictToOwner(string path) + { + if (OperatingSystem.IsWindows()) return; + File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite); + } +} diff --git a/src/Ludarium.Infrastructure/packages.lock.json b/src/Ludarium.Infrastructure/packages.lock.json new file mode 100644 index 0000000..b4432c6 --- /dev/null +++ b/src/Ludarium.Infrastructure/packages.lock.json @@ -0,0 +1,56 @@ +{ + "version": 1, + "dependencies": { + "net10.0": { + "Npgsql": { + "type": "Direct", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "7nb5YzXuvWWJxB0J8DiyL3we+X4FOctZrt0fIBnucOIaIevFEEwGQVZKtiu9olXdlNAK1eNgqSral6r/jlhI4w==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "10.0.0" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "L3AdmZ1WOK4XXT5YFPEwyt0ep6l8lGIPs7F5OOBZc77Zqeo01Of7XXICy47628sdVl0v/owxYJTe86DTgFwKCA==" + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "FU/IfjDfwaMuKr414SSQNTIti/69bHEMb+QKrskRb26oVqpx3lNFXMjs/RC9ZUuhBhcwDM2BwOgoMw+PZ+beqQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0" + } + }, + "OpenMcdf": { + "type": "Transitive", + "resolved": "3.2.0", + "contentHash": "n/iojS7V77YjM6IBbXaP0ZI8EELhJ2j6XRjx9NxMyUtjcM4a0yr0fWqO1y7lgd3CgYwnDugaKlO9c7Os8VxNtg==" + }, + "SharpCompress": { + "type": "Transitive", + "resolved": "0.50.4", + "contentHash": "/hxjUR7DEX6mky8/LQXyrnrKioOL6D6veAID1EZpro+q4s02x5dHEYBV3qjEN6lDYYilDoQQ76BcmQj+lRx51w==" + }, + "System.IO.Hashing": { + "type": "Transitive", + "resolved": "10.0.11", + "contentHash": "OzKDcIRkeNJeC8qAsbn8yJXnfTLP1dtkWILe+T56Gf/z+IkAASi7sMqLqJQat08j5z/mRN5xVtoAwbkMNMoBUQ==" + }, + "ludarium.application": { + "type": "Project", + "dependencies": { + "Ludarium.Domain": "[1.0.0, )", + "OpenMcdf": "[3.2.0, )", + "SharpCompress": "[0.50.4, )", + "System.IO.Hashing": "[10.0.11, )" + } + }, + "ludarium.domain": { + "type": "Project" + } + } + } +} \ No newline at end of file diff --git a/src/Ludarium.Web/.gitignore b/src/Ludarium.Web/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/src/Ludarium.Web/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/src/Ludarium.Web/e2e/accessibility.mjs b/src/Ludarium.Web/e2e/accessibility.mjs new file mode 100644 index 0000000..665dfd7 --- /dev/null +++ b/src/Ludarium.Web/e2e/accessibility.mjs @@ -0,0 +1,142 @@ +import { chromium } from "playwright"; +import AxeBuilder from "@axe-core/playwright"; +const baseUrl = process.env.PLAYWRIGHT_BASE_URL ?? "http://127.0.0.1:1230"; +const token = process.env.LUDARIUM_ADMIN_TOKEN; +if (!token) throw new Error("LUDARIUM_ADMIN_TOKEN is required."); +const routes = ["Home", "Library", "Wishlist", "Attention", "Activity", "Settings"]; +const viewports = [ + { name: "mobile-320", width: 320, height: 720 }, + { name: "mobile-360", width: 360, height: 800 }, + { name: "desktop", width: 1440, height: 900 }, + { name: "mobile", width: 390, height: 844 }, + { name: "mobile-430", width: 430, height: 932 }, + { name: "tablet-880", width: 880, height: 900 }, + { name: "desktop-1280", width: 1280, height: 800 }, + { name: "desktop-1920", width: 1920, height: 1080 }, + { name: "ultrawide-3440", width: 3440, height: 1440 }, +]; +const themes = ["dark", "light"]; +const browser = await chromium.launch({ headless: true }); +const failures = []; +const waitForApplication = async (page) => { + try { + await page.locator("main").waitFor({ state: "visible", timeout: 60_000 }); + } catch (error) { + const snapshot = await page.locator("body").innerText().catch(() => "<body unavailable>"); + throw new Error( + `Ludarium did not render its main landmark at ${page.url()}. Body: ${snapshot.slice(0, 500)}`, + { cause: error }, + ); + } +}; +const overflowNodes = (page) => page.evaluate(() => [...document.querySelectorAll("body *")] + .filter((element) => { + const rect = element.getBoundingClientRect(); + return rect.right > document.documentElement.clientWidth + 1 || rect.left < -1; + }) + .slice(0, 12) + .map((element) => { + const rect = element.getBoundingClientRect(); + return `${element.tagName.toLowerCase()}.${[...element.classList].join(".")} [${Math.round(rect.left)},${Math.round(rect.right)}] ${(element.textContent ?? "").trim().slice(0, 36)}`; + })); +try { + for (const viewport of viewports) { + for (const theme of themes) { + const context = await browser.newContext({ + viewport, + reducedMotion: "reduce", + colorScheme: theme, + }); + // The application stores an explicit preference, so the gate sets the same one a person would. + await context.addInitScript((value) => localStorage.setItem("ludarium.theme", value), theme); + const page = await context.newPage(); + await page.goto(`${baseUrl}/#Home`, { waitUntil: "networkidle" }); + await waitForApplication(page); + const login = page.getByLabel("Administrator token"); + if (await login.isVisible()) { + await login.fill(token); + await page.getByRole("button", { name: "Open Ludarium" }).click(); + await login.waitFor({ state: "hidden" }); + await page.locator("main h1").waitFor({ state: "visible", timeout: 60_000 }); + } + for (const route of routes) { + await page.goto(`${baseUrl}/#${route}`, { waitUntil: "networkidle" }); + await page.waitForSelector("h1"); + if (await page.evaluate(() => document.documentElement.scrollWidth !== document.documentElement.clientWidth)) + failures.push({ viewport: viewport.name, theme, route, id: "horizontal-overflow", nodes: await overflowNodes(page) }); + const results = await new AxeBuilder({ page }) + .withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"]) + .analyze(); + for (const violation of results.violations) + failures.push({ + viewport: viewport.name, + theme, + route, + id: violation.id, + impact: violation.impact, + nodes: violation.nodes.map((n) => ({ + target: n.target, + failure: n.failureSummary, + })), + }); + } + await page.goto(`${baseUrl}/#Library`, { waitUntil: "networkidle" }); + await page.locator(".game-card").first().click(); + for (const violation of ( + await new AxeBuilder({ page }) + .withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"]) + .analyze() + ).violations) + failures.push({ + viewport: viewport.name, + theme, + route: "Game drawer", + id: violation.id, + nodes: violation.nodes.map((n) => n.target), + }); + await page.getByRole("button", { name: "Close" }).click(); + await page.getByRole("button", { name: /search (?:games|ludarium)/i }).click(); + for (const violation of ( + await new AxeBuilder({ page }) + .withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"]) + .analyze() + ).violations) + failures.push({ + viewport: viewport.name, + theme, + route: "Search dialog", + id: violation.id, + nodes: violation.nodes.map((n) => n.target), + }); + // The theme actually applied must be the one asked for, or the run proves nothing. + const applied = await page.evaluate(() => document.documentElement.dataset.theme); + if (applied !== theme) + failures.push({ viewport: viewport.name, theme, route: "theme", id: "theme-not-applied", nodes: [applied] }); + await context.close(); + } + } + // A 640 CSS-pixel viewport represents a 1280-pixel window at 200% browser zoom. + const zoomContext = await browser.newContext({ viewport: { width: 640, height: 800 }, reducedMotion: "reduce" }); + const zoomPage = await zoomContext.newPage(); + await zoomPage.goto(`${baseUrl}/#Library`, { waitUntil: "networkidle" }); + await waitForApplication(zoomPage); + const zoomLogin = zoomPage.getByLabel("Administrator token"); + if (await zoomLogin.isVisible()) { + await zoomLogin.fill(token); + await zoomPage.getByRole("button", { name: "Open Ludarium" }).click(); + await zoomLogin.waitFor({ state: "hidden" }); + await zoomPage.locator("main h1").waitFor({ state: "visible", timeout: 60_000 }); + } + if (await zoomPage.evaluate(() => document.documentElement.scrollWidth !== document.documentElement.clientWidth)) + failures.push({ viewport: "desktop-200-percent-zoom", route: "Library", id: "horizontal-overflow", nodes: await overflowNodes(zoomPage) }); + await zoomContext.close(); +} finally { + await browser.close(); +} +if (failures.length) { + console.error(JSON.stringify(failures, null, 2)); + process.exitCode = 1; +} else + console.log( + `Accessibility gate passed: ${routes.length} routes and key overlays across ${viewports.length} viewports in ${themes.join(" and ")}.`, + ); diff --git a/src/Ludarium.Web/e2e/archive-atlas.mjs b/src/Ludarium.Web/e2e/archive-atlas.mjs new file mode 100644 index 0000000..ff71343 --- /dev/null +++ b/src/Ludarium.Web/e2e/archive-atlas.mjs @@ -0,0 +1,113 @@ +import { mkdir } from "node:fs/promises"; +import { resolve } from "node:path"; +import { chromium } from "playwright"; +import AxeBuilder from "@axe-core/playwright"; + +const baseUrl = process.env.PLAYWRIGHT_BASE_URL ?? "http://127.0.0.1:4173"; +const output = resolve(process.cwd(), "../../artifacts/visual-audit"); +await mkdir(output, { recursive: true }); + +const platforms = [ + { platform: "switch", games: 73, artifacts: 2240, bytes: 824_000_000_000 }, + { platform: "nds", games: 67, artifacts: 980, bytes: 33_000_000_000 }, + { platform: "windows", games: 41, artifacts: 3210, bytes: 1_170_000_000_000 }, + { platform: "ps5", games: 28, artifacts: 1370, bytes: 993_800_000_000 }, + { platform: "psx", games: 21, artifacts: 420, bytes: 19_000_000_000 }, + { platform: "n64", games: 18, artifacts: 210, bytes: 760_000_000 }, + { platform: "snes", games: 16, artifacts: 190, bytes: 490_000_000 }, + { platform: "gba", games: 15, artifacts: 170, bytes: 330_000_000 }, + { platform: "psvita", games: 14, artifacts: 540, bytes: 58_000_000_000 }, + { platform: "gamecube", games: 13, artifacts: 380, bytes: 24_000_000_000 }, +]; + +const status = { + version: "visual-review", + schemaVersion: 27, + libraries: 3, + artifacts: 13_856, + presentArtifacts: 13_856, + games: 347, + releases: 351, + bundles: 0, + totalBytes: 3_670_000_000_000, + hashedArtifacts: 0, + recognizedArtifacts: 13_500, + unknownArtifacts: 356, + missingArtifacts: 0, + duplicateArtifacts: 0, + duplicateBytes: 0, + openReviews: 0, + criticalFindings: 0, + activeScans: 0, + rootsRequiringAttention: 0, + excludedSupportGames: 0, + unresolvedGameCandidates: 0, + lastFullScanAt: "2026-08-12T08:30:00Z", + lastIncrementalScanAt: "2026-08-12T08:30:00Z", + catalogImportedWithoutScan: false, + confidencePercent: 98.6, +}; + +function payloadFor(pathname) { + if (pathname.endsWith("/system/status")) return status; + if (pathname.endsWith("/platforms")) return platforms; + if (pathname.endsWith("/system/storage")) return { libraries: [ + { name: "Games", bytes: 2_676_200_000_000 }, + { name: "PS4 Games", bytes: 0 }, + { name: "PS5 Games", bytes: 993_800_000_000 }, + ] }; + if (pathname.endsWith("/wishlist/summary")) return { total: 0, upcoming: 0 }; + if (pathname.includes("/wishlist")) return { items: [], total: 0, page: 1, pageSize: 3 }; + return []; +} + +const browser = await chromium.launch({ headless: true }); +try { + for (const viewport of [ + { name: "wide", width: 1920, height: 1080, captureFull: false }, + { name: "desktop", width: 1440, height: 1000, captureFull: true }, + { name: "tablet", width: 768, height: 900, captureFull: false }, + { name: "mobile", width: 390, height: 844, captureFull: true }, + { name: "mobile-320", width: 320, height: 720, captureFull: false }, + ]) { + const context = await browser.newContext({ viewport, reducedMotion: "reduce", colorScheme: "dark" }); + await context.addInitScript(() => { + sessionStorage.setItem("ludarium.adminToken", "visual-review-token"); + localStorage.setItem("ludarium.language", "nl"); + localStorage.setItem("ludarium.theme", "dark"); + }); + const page = await context.newPage(); + const consoleErrors = []; + page.on("console", (message) => { if (message.type() === "error") consoleErrors.push(message.text()); }); + await page.route("**/api/v1/**", async (route) => { + await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(payloadFor(new URL(route.request().url()).pathname)) }); + }); + await page.goto(`${baseUrl}/#Home`, { waitUntil: "networkidle" }); + const atlas = page.locator(".archive-atlas"); + await atlas.waitFor({ state: "visible" }); + + const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth); + if (overflow > 1) throw new Error(`${viewport.name}: document overflows by ${overflow}px`); + const tiles = atlas.locator(".archive-atlas-map li:visible"); + const expectedTiles = viewport.width <= 700 ? 5 : 7; + if (await tiles.count() !== expectedTiles) throw new Error(`${viewport.name}: expected ${expectedTiles} visible atlas regions, found ${await tiles.count()}`); + + await tiles.nth(1).locator("a").focus(); + const selectedLabel = await tiles.nth(1).locator(".archive-atlas-tile-copy b").innerText(); + if (await atlas.locator(".archive-atlas-ledger h3").innerText() !== selectedLabel) throw new Error(`${viewport.name}: keyboard focus did not update the archive ledger`); + + const axe = await new AxeBuilder({ page }).include(".home-atlas-section").withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"]).analyze(); + if (axe.violations.length) throw new Error(`${viewport.name}: accessibility violations: ${axe.violations.map((item) => item.id).join(", ")}`); + if (consoleErrors.length) throw new Error(`${viewport.name}: console errors: ${consoleErrors.join(" | ")}`); + + await page.locator(".home-atlas-section").screenshot({ path: resolve(output, `archive-atlas-${viewport.name}.png`) }); + if (viewport.captureFull) { + await page.evaluate(() => window.scrollTo({ top: 0, left: 0, behavior: "auto" })); + await page.screenshot({ path: resolve(output, `archive-atlas-home-${viewport.name}.png`), fullPage: true }); + } + await context.close(); + } + console.log(`Archive Atlas visual, overflow, keyboard and accessibility checks passed. Screenshots: ${output}`); +} finally { + await browser.close(); +} diff --git a/src/Ludarium.Web/e2e/game-data-vault.mjs b/src/Ludarium.Web/e2e/game-data-vault.mjs new file mode 100644 index 0000000..36bef1c --- /dev/null +++ b/src/Ludarium.Web/e2e/game-data-vault.mjs @@ -0,0 +1,128 @@ +import assert from "node:assert/strict"; +import { chromium, request } from "playwright"; +import AxeBuilder from "@axe-core/playwright"; + +const baseUrl = process.env.PLAYWRIGHT_BASE_URL ?? "http://127.0.0.1:1230"; +const token = process.env.LUDARIUM_ADMIN_TOKEN; +if (!token) throw new Error("LUDARIUM_ADMIN_TOKEN is required."); + +const errors = []; +const browser = await chromium.launch({ headless: true }); +const api = await request.newContext({ + baseURL: `${baseUrl}/api/v1/`, + extraHTTPHeaders: { Authorization: `Bearer ${token}` }, +}); + +try { + const title = `Vault browser fixture ${Date.now()}`; + const created = await api.post("games", { data: { title } }); + assert.equal(created.status(), 201, await created.text()); + const game = await created.json(); + + const context = await browser.newContext({ + viewport: { width: 1440, height: 900 }, + reducedMotion: "reduce", + }); + const page = await context.newPage(); + page.on("console", (message) => message.type() === "error" && errors.push(message.text())); + page.on("pageerror", (error) => errors.push(error.message)); + await page.goto(`${baseUrl}/#Library`, { waitUntil: "networkidle" }); + const login = page.getByLabel("Administrator token"); + await page.waitForFunction(() => + document.querySelector('.game-browser, .empty-state') !== null || + document.body.innerText.includes('Administrator token')); + if (await login.isVisible()) { + await login.fill(token); + await page.getByRole("button", { name: "Open Ludarium" }).click(); + await login.waitFor({ state: "hidden" }); + await page.goto(`${baseUrl}/#Library`, { waitUntil: "networkidle" }); + } + + await page.getByLabel("Search games").fill(title); + const card = page.locator(".game-card", { hasText: title }); + await card.waitFor(); + await card.click(); + await page.locator(".drawer").waitFor(); + await page.getByRole("tab", { name: "Saves & states" }).click(); + + const vault = page.locator(".detail-section", { + has: page.getByRole("heading", { name: "Game Data Vault" }), + }); + await vault.getByLabel("Name").fill("Main campaign"); + await vault.getByLabel("Emulator or core").fill("mGBA"); + await vault.getByLabel("Device").fill("Steam Deck"); + await vault.getByLabel("Notes").fill("Browser release fixture"); + const firstPayload = Buffer.from("SYNTHETIC-SAVE|browser|progress=42"); + await vault.getByLabel(/Save or state file/).setInputFiles({ + name: "browser-slot.sav", + mimeType: "application/octet-stream", + buffer: firstPayload, + }); + await vault.getByRole("button", { name: "Add to vault" }).click(); + await vault.getByText(/stored as revision 1/i).waitFor(); + + const vaultCard = vault.locator(".vault-card", { hasText: "Main campaign" }); + await vaultCard.getByText("browser-slot.sav").waitFor(); + assert.match((await vaultCard.locator(".vault-evidence code").textContent()) ?? "", /^[a-f0-9]{12}…$/); + + const firstDownloadPromise = page.waitForEvent("download"); + await vaultCard.getByRole("button", { name: "Download current" }).click(); + const firstDownload = await firstDownloadPromise; + assert.equal(firstDownload.suggestedFilename(), "browser-slot.sav"); + const firstStream = await firstDownload.createReadStream(); + const firstChunks = []; + for await (const chunk of firstStream) firstChunks.push(chunk); + assert.deepEqual(Buffer.concat(firstChunks), firstPayload); + + const secondPayload = Buffer.from("SYNTHETIC-SAVE|browser|progress=99"); + await vaultCard.locator('input[type="file"]').setInputFiles({ + name: "browser-slot.sav", + mimeType: "application/octet-stream", + buffer: secondPayload, + }); + await vaultCard.getByText(/Revision 2 stored/i).waitFor(); + await vaultCard.getByText("2 revisions · immutable history").click(); + await vaultCard.getByText("Revision 2", { exact: true }).waitFor(); + assert.equal(await vaultCard.locator(".vault-revision").count(), 2); + + await vaultCard.getByRole("button", { name: "Edit details" }).click(); + await vaultCard.getByLabel("Device").fill("Living room handheld"); + await vaultCard.getByRole("button", { name: "Save details" }).click(); + await vaultCard.getByText(/metadata updated/i).waitFor(); + const desktopA11y = await new AxeBuilder({ page }) + .withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"]) + .analyze(); + assert.equal(desktopA11y.violations.length, 0, JSON.stringify(desktopA11y.violations)); + + await page.setViewportSize({ width: 390, height: 844 }); + assert.equal( + await page.locator(".drawer").evaluate((element) => Math.round(element.getBoundingClientRect().width)), + 390, + ); + assert.equal( + await page.evaluate(() => document.documentElement.scrollWidth === document.documentElement.clientWidth), + true, + ); + const mobileA11y = await new AxeBuilder({ page }) + .withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"]) + .analyze(); + assert.equal(mobileA11y.violations.length, 0, JSON.stringify(mobileA11y.violations)); + + await vaultCard.getByRole("button", { name: "Delete" }).click(); + // Deleting an entry asks in the application's own dialog, whose button names the action. + const removeEntry = page.getByRole("alertdialog"); + await removeEntry.waitFor(); + await removeEntry.getByRole("button", { name: "Delete vault entry" }).click(); + await removeEntry.waitFor({ state: "detached" }); + await vaultCard.waitFor({ state: "detached" }); + await vault.getByText("The vault is empty").waitFor(); + const summary = await api.get(`games/${game.id}/data/summary`); + assert.equal(summary.status(), 200, await summary.text()); + assert.deepEqual(await summary.json(), { saves: 0, saveStates: 0, revisions: 0, bytes: 0 }); + assert.equal(errors.length, 0, `Console errors: ${errors.join("; ")}`); + await context.close(); + console.log("Game Data Vault browser gate passed: create, evidence, download, revision, history, edit, mobile reflow and delete."); +} finally { + await api.dispose(); + await browser.close(); +} diff --git a/src/Ludarium.Web/e2e/live-browserplay-core.mjs b/src/Ludarium.Web/e2e/live-browserplay-core.mjs new file mode 100644 index 0000000..801166e --- /dev/null +++ b/src/Ludarium.Web/e2e/live-browserplay-core.mjs @@ -0,0 +1,122 @@ +import assert from "node:assert/strict"; +import { chromium } from "playwright"; + +const baseUrl = process.env.PLAYWRIGHT_BASE_URL; +const token = process.env.LUDARIUM_ADMIN_TOKEN; +const gameId = process.env.BROWSERPLAY_GAME_ID; +const expectedPlatform = process.env.BROWSERPLAY_PLATFORM; +const expectedCore = process.env.BROWSERPLAY_CORE; +const expectedBytes = Number(process.env.BROWSERPLAY_BYTES); +const expectedMagic = (process.env.BROWSERPLAY_MAGIC ?? "").split(",").filter(Boolean).map(value => Number.parseInt(value, 16)); +const evidencePath = process.env.BROWSERPLAY_EVIDENCE_PATH; +const restoreEvidencePath = process.env.BROWSERPLAY_RESTORE_EVIDENCE_PATH; +const startTimeout = Number(process.env.BROWSERPLAY_START_TIMEOUT ?? 90_000); +assert.ok(baseUrl && token && gameId && expectedPlatform && expectedCore && expectedBytes > 0, + "core gate environment is incomplete"); + +const apiHeaders = { Authorization: `Bearer ${token}` }; +const capability = await (await fetch(`${baseUrl}/api/v1/games/${gameId}/play-capability`, { headers: apiHeaders })).json(); +assert.equal(capability.available, true, JSON.stringify(capability)); +assert.equal(capability.platform, expectedPlatform); +assert.equal(capability.core, expectedCore); +assert.equal(capability.automaticRestore, true); + +const startSession = async () => { + const response = await fetch(`${baseUrl}/api/v1/games/${gameId}/play-sessions`, { method: "POST", headers: apiHeaders }); + assert.equal(response.status, 201); + return response.json(); +}; +const cancelSession = session => fetch(`${baseUrl}/api/v1/play-sessions/${session.id}`, { method: "DELETE", headers: apiHeaders }); +const session = await startSession(); +const launch = new URL(session.launchUrl, baseUrl); +const capabilityToken = new URLSearchParams(launch.hash.slice(1)).get("token"); +const contentUrl = `${baseUrl}/api/v1/play-sessions/${session.id}/content`; +assert.equal((await fetch(contentUrl)).status, 404); +const range = await fetch(`${contentUrl}?token=${capabilityToken}`, { headers: { Range: "bytes=0-15" } }); +assert.equal(range.status, 206); +assert.equal(range.headers.get("content-range"), `bytes 0-15/${expectedBytes}`); +assert.deepEqual([...new Uint8Array(await range.arrayBuffer())].slice(0, expectedMagic.length), expectedMagic); + +const browser = await chromium.launch({ headless: true }); +const page = await browser.newPage({ viewport: { width: 1280, height: 800 } }); +const consoleErrors = []; +const requestFailures = []; +const externalRequests = []; +const expectedOrigin = new URL(baseUrl).origin; +page.on("console", message => { + if (message.type() !== "error") return; + const location = message.location().url; + if (location.endsWith("/favicon.ico")) return; + consoleErrors.push(`${location || "console"}: ${message.text()}`); +}); +page.on("requestfailed", request => requestFailures.push(`${request.method()} ${request.url()}: ${request.failure()?.errorText}`)); +page.on("request", request => { + const url = new URL(request.url()); + if ((url.protocol === "http:" || url.protocol === "https:") && url.origin !== expectedOrigin) + externalRequests.push(`${request.method()} ${request.url()}`); +}); +try { + await page.goto(launch.href, { waitUntil: "domcontentloaded" }); + await page.locator("#status").waitFor({ state: "detached", timeout: startTimeout }); + await page.locator("#game canvas").waitFor({ state: "visible", timeout: 60_000 }); + await page.waitForFunction(() => typeof window.EJS_emulator?.gameManager?.getState === "function", null, { timeout: 60_000 }); + await page.waitForTimeout(2_000); + const stateUpload = page.waitForResponse(response => response.request().method() === "PUT" && + response.url().includes(`/play-sessions/${session.id}/data/SaveState`), { timeout: 60_000 }); + const stateBytes = await page.evaluate(() => { + const state = window.EJS_emulator.gameManager.getState(); + if (!(state instanceof Uint8Array) || state.byteLength === 0) throw new Error("core returned no savestate bytes"); + window.EJS_onSaveState({ state }); + return state.byteLength; + }); + assert.ok(stateBytes > 0); + assert.equal((await stateUpload).status(), 200); + if (evidencePath) await page.screenshot({ path: evidencePath, fullPage: true }); + assert.equal((await cancelSession(session)).status, 204); + + const restoredSession = await startSession(); + const restoredLaunch = new URL(restoredSession.launchUrl, baseUrl); + const restoreDownload = page.waitForResponse(response => response.request().method() === "GET" && + response.url().includes(`/play-sessions/${restoredSession.id}/data/SaveState`), { timeout: 60_000 }); + try { + await page.goto(restoredLaunch.href, { waitUntil: "domcontentloaded" }); + assert.equal((await restoreDownload).status(), 200); + await page.locator("#status").waitFor({ state: "detached", timeout: 90_000 }); + await page.locator("#game canvas").waitFor({ state: "visible", timeout: 60_000 }); + await page.waitForFunction(() => document.body.dataset.restoreComplete === "true" && + document.body.dataset.restoredState === "true", null, { timeout: 60_000 }); + if (restoreEvidencePath) await page.screenshot({ path: restoreEvidencePath, fullPage: true }); + } finally { + await cancelSession(restoredSession); + } + assert.equal(externalRequests.length, 0, `player made external requests:\n${externalRequests.join("\n")}`); + assert.equal(requestFailures.length, 0, requestFailures.join("\n")); + assert.equal(consoleErrors.length, 0, consoleErrors.join("\n")); + console.log(`Core restore gate passed platform=${expectedPlatform} core=${expectedCore} ROM=${expectedBytes} state=${stateBytes}.`); +} catch (error) { + const statusText = await page.locator("#status").textContent().catch(() => "status element unavailable"); + const diagnostics = await page.evaluate(() => ({ + emulator: Boolean(window.EJS_emulator), + gameManager: Boolean(window.EJS_emulator?.gameManager), + crossOriginIsolated: window.crossOriginIsolated, + sharedArrayBuffer: typeof SharedArrayBuffer, + webgl2: Boolean(document.createElement("canvas").getContext("webgl2")), + emulatorKeys: window.EJS_emulator ? Object.keys(window.EJS_emulator).filter(key => /ready|load|start|core|game|error|text|config/i.test(key)) : [], + emulatorState: window.EJS_emulator ? { + core: window.EJS_emulator.getCore?.(), + system: window.EJS_emulator.config?.system, + failedToStart: window.EJS_emulator.failedToStart, + started: window.EJS_emulator.started, + text: window.EJS_emulator.textElem?.innerText + } : null, + canvases: document.querySelectorAll("canvas").length, + scripts: [...document.scripts].map(script => script.src), + resources: performance.getEntriesByType("resource").map(entry => ({ name: entry.name, duration: Math.round(entry.duration) })) + .filter(entry => /emulatorjs|play-sessions/.test(entry.name)) + })).catch(() => ({ diagnostics: "page unavailable" })); + throw new Error(`${error.message}\nplayer status: ${statusText}\ndiagnostics: ${JSON.stringify(diagnostics)}\nconsole errors:\n${consoleErrors.join("\n")}\nrequest failures:\n${requestFailures.join("\n")}`, + { cause: error }); +} finally { + await browser.close(); + await cancelSession(session); +} diff --git a/src/Ludarium.Web/e2e/live-browserplay-n64.mjs b/src/Ludarium.Web/e2e/live-browserplay-n64.mjs new file mode 100644 index 0000000..c6e207e --- /dev/null +++ b/src/Ludarium.Web/e2e/live-browserplay-n64.mjs @@ -0,0 +1,103 @@ +import assert from "node:assert/strict"; +import { chromium } from "playwright"; + +const baseUrl = process.env.PLAYWRIGHT_BASE_URL; +const token = process.env.LUDARIUM_ADMIN_TOKEN; +const gameId = process.env.BROWSERPLAY_GAME_ID; +const expectedBytes = Number(process.env.BROWSERPLAY_BYTES); +const evidencePath = process.env.BROWSERPLAY_EVIDENCE_PATH; +const restoreEvidencePath = process.env.BROWSERPLAY_RESTORE_EVIDENCE_PATH; +assert.ok(baseUrl && token && gameId && expectedBytes > 0, "N64 gate environment is incomplete"); + +const apiHeaders = { Authorization: `Bearer ${token}` }; +const capability = await (await fetch(`${baseUrl}/api/v1/games/${gameId}/play-capability`, { headers: apiHeaders })).json(); +assert.equal(capability.available, true, JSON.stringify(capability)); +assert.equal(capability.platform, "n64"); +assert.equal(capability.core, "n64wasm"); +assert.equal(capability.emulator, "Ludarium N64Wasm"); +assert.equal(capability.automaticRestore, true); + +const startSession = async () => { + const response = await fetch(`${baseUrl}/api/v1/games/${gameId}/play-sessions`, { method: "POST", headers: apiHeaders }); + assert.equal(response.status, 201); + return response.json(); +}; +const cancel = session => fetch(`${baseUrl}/api/v1/play-sessions/${session.id}`, { method: "DELETE", headers: apiHeaders }); +const browser = await chromium.launch({ headless: true }); +const page = await browser.newPage({ viewport: { width: 1280, height: 800 } }); +const errors = []; +const failures = []; +page.on("console", message => { if (message.type() === "error") errors.push(message.text()); }); +page.on("pageerror", error => errors.push(error.message)); +page.on("requestfailed", request => failures.push(`${request.method()} ${request.url()}: ${request.failure()?.errorText}`)); +let firstSession; +let restoredSession; +try { + firstSession = await startSession(); + const launch = new URL(firstSession.launchUrl, baseUrl); + assert.match(launch.pathname, /\/n64-player\.html$/); + const capabilityToken = new URLSearchParams(launch.hash.slice(1)).get("token"); + const contentUrl = `${baseUrl}/api/v1/play-sessions/${firstSession.id}/content`; + assert.equal((await fetch(contentUrl)).status, 404); + const range = await fetch(`${contentUrl}?token=${capabilityToken}`, { headers: { Range: "bytes=0-15" } }); + assert.ok([200, 206].includes(range.status), `Unexpected ROM response ${range.status}`); + if (range.status === 206) { + assert.equal(range.headers.get("content-range"), `bytes 0-15/${expectedBytes}`); + } + const probe = new Uint8Array(await range.arrayBuffer()); + if (range.status === 200) assert.equal(probe.byteLength, expectedBytes); + const magic = [...probe.slice(0, 4)]; + assert.ok([ + [0x80, 0x37, 0x12, 0x40], + [0x37, 0x80, 0x40, 0x12], + [0x40, 0x12, 0x37, 0x80] + ].some(candidate => candidate.every((value, index) => value === magic[index])), + `Invalid N64 byte-order magic ${magic.map(value => value.toString(16).padStart(2, "0")).join("")}`); + + await page.goto(launch.href, { waitUntil: "domcontentloaded" }); + await page.waitForFunction(() => document.body.dataset.started === "true", null, { timeout: 90_000 }); + await page.locator("#canvas").waitFor({ state: "visible" }); + const firstEvidence = await page.evaluate(() => ({ + isolated: crossOriginIsolated, + webgl2: Boolean(document.getElementById("canvas")?.getContext("webgl2")), + width: document.getElementById("canvas")?.width, + height: document.getElementById("canvas")?.height, + romBytes: window.LudariumN64?.romBytes + })); + assert.deepEqual(firstEvidence, { isolated: true, webgl2: true, width: 640, height: 480, romBytes: expectedBytes }); + + const stateUpload = page.waitForResponse(response => response.request().method() === "PUT" && + response.url().includes(`/play-sessions/${firstSession.id}/data/SaveState`), { timeout: 60_000 }); + await page.locator("#save-state").click(); + assert.equal((await stateUpload).status(), 200); + await page.waitForFunction(() => document.body.dataset.stateSaved === "true", null, { timeout: 10_000 }); + if (evidencePath) await page.screenshot({ path: evidencePath, fullPage: true }); + assert.equal((await cancel(firstSession)).status, 204); + + restoredSession = await startSession(); + const restoredLaunch = new URL(restoredSession.launchUrl, baseUrl); + const restoreDownload = page.waitForResponse(response => response.request().method() === "GET" && + response.url().includes(`/play-sessions/${restoredSession.id}/data/SaveState`), { timeout: 60_000 }); + await page.goto(restoredLaunch.href, { waitUntil: "domcontentloaded" }); + assert.equal((await restoreDownload).status(), 200); + await page.waitForFunction(() => document.body.dataset.started === "true" && + document.body.dataset.restoredState === "true", null, { timeout: 90_000 }); + if (restoreEvidencePath) await page.screenshot({ path: restoreEvidencePath, fullPage: true }); + assert.deepEqual(errors, []); + assert.deepEqual(failures, []); + console.log(`N64Wasm gate passed ROM=${expectedBytes} canvas=640x480 with bound savestate restore.`); +} catch (error) { + const diagnostics = await page.evaluate(() => ({ + status: document.getElementById("status")?.innerText, + started: document.body.dataset.started, + restored: document.body.dataset.restoredState, + stateSaved: document.body.dataset.stateSaved, + runtime: Boolean(window.LudariumN64), + resources: performance.getEntriesByType("resource").map(entry => entry.name).filter(name => /n64wasm|play-sessions/.test(name)) + })).catch(() => ({ page: "unavailable" })); + throw new Error(`${error.message}\ndiagnostics=${JSON.stringify(diagnostics)}\nerrors=${errors.join("\n")}\nfailures=${failures.join("\n")}`, { cause: error }); +} finally { + if (restoredSession) await cancel(restoredSession); + if (firstSession) await cancel(firstSession); + await browser.close(); +} diff --git a/src/Ludarium.Web/e2e/live-browserplay-ps2.mjs b/src/Ludarium.Web/e2e/live-browserplay-ps2.mjs new file mode 100644 index 0000000..fe6dea5 --- /dev/null +++ b/src/Ludarium.Web/e2e/live-browserplay-ps2.mjs @@ -0,0 +1,71 @@ +import assert from "node:assert/strict"; +import { chromium } from "playwright"; + +const baseUrl = process.env.PLAYWRIGHT_BASE_URL; +const token = process.env.LUDARIUM_ADMIN_TOKEN; +const gameId = process.env.BROWSERPLAY_GAME_ID; +const expectedBytes = Number(process.env.BROWSERPLAY_BYTES); +const startTimeout = Number(process.env.BROWSERPLAY_START_TIMEOUT || 120_000); +assert.ok(baseUrl && token && gameId && expectedBytes > 0, "PS2 gate environment is incomplete"); + +const apiHeaders = { Authorization: `Bearer ${token}` }; +const capability = await (await fetch(`${baseUrl}/api/v1/games/${gameId}/play-capability`, { headers: apiHeaders })).json(); +assert.equal(capability.available, true, JSON.stringify(capability)); +assert.equal(capability.platform, "ps2"); +assert.equal(capability.core, "playjs"); +assert.equal(capability.emulator, "Ludarium Play!.js"); +assert.equal(capability.automaticRestore, false); + +const response = await fetch(`${baseUrl}/api/v1/games/${gameId}/play-sessions`, { method: "POST", headers: apiHeaders }); +assert.equal(response.status, 201); +const session = await response.json(); +assert.match(session.launchUrl, /^\/ps2-player\.html\?/); +const launch = new URL(session.launchUrl, baseUrl); +const capabilityToken = new URLSearchParams(launch.hash.slice(1)).get("token"); +const contentUrl = `${baseUrl}/api/v1/play-sessions/${session.id}/content`; +assert.equal((await fetch(contentUrl)).status, 404); +const range = await fetch(`${contentUrl}?token=${capabilityToken}`, { headers: { Range: "bytes=0-15" } }); +assert.equal(range.status, 206); +assert.equal(range.headers.get("content-range"), `bytes 0-15/${expectedBytes}`); + +const browser = await chromium.launch({ headless: true }); +const page = await browser.newPage({ viewport: { width: 1280, height: 800 } }); +const errors = []; +page.on("console", message => { if (message.type() === "error") errors.push(message.text()); }); +page.on("pageerror", error => errors.push(error.message)); +try { + await page.goto(launch.href, { waitUntil: "domcontentloaded" }); + await page.waitForFunction(() => document.body.dataset.started === "true", null, { timeout: startTimeout }); + await page.locator("#outputCanvas").waitFor({ state: "visible" }); + const evidence = await page.evaluate(() => ({ + isolated: crossOriginIsolated, + frames: Number(document.body.dataset.frames), + size: window.LudariumPlay?.size, + failed: Boolean(window.LudariumPlay?.disc?.failed), + width: document.getElementById("outputCanvas")?.width, + height: document.getElementById("outputCanvas")?.height + })); + assert.equal(evidence.isolated, true); + assert.ok(evidence.frames > 0, JSON.stringify(evidence)); + assert.equal(evidence.size, expectedBytes); + assert.equal(evidence.failed, false); + assert.ok(evidence.width > 0 && evidence.height > 0); + assert.deepEqual(errors, []); + assert.equal((await fetch(`${baseUrl}/api/v1/play-sessions/${session.id}`, { method: "DELETE", headers: apiHeaders })).status, 204); + console.log(`PS2 Play!.js gate passed ROM=${expectedBytes} frames=${evidence.frames} canvas=${evidence.width}x${evidence.height}.`); +} catch (error) { + const diagnostics = await page.evaluate(() => ({ + status: document.getElementById("status")?.innerText, + started: document.body.dataset.started, + isolated: crossOriginIsolated, + play: Boolean(window.LudariumPlay), + frames: window.LudariumPlay?.module?.getFrames?.(), + failed: String(window.LudariumPlay?.disc?.failed || ""), + resources: performance.getEntriesByType("resource").map(entry => entry.name) + .filter(name => /playjs|play-sessions/.test(name)) + })).catch(() => ({ page: "unavailable" })); + throw new Error(`${error.message}\ndiagnostics=${JSON.stringify(diagnostics)}\nerrors=${errors.join("\n")}`, { cause: error }); +} finally { + await browser.close(); + await fetch(`${baseUrl}/api/v1/play-sessions/${session.id}`, { method: "DELETE", headers: apiHeaders }); +} diff --git a/src/Ludarium.Web/e2e/live-browserplay.mjs b/src/Ludarium.Web/e2e/live-browserplay.mjs new file mode 100644 index 0000000..83d3cb9 --- /dev/null +++ b/src/Ludarium.Web/e2e/live-browserplay.mjs @@ -0,0 +1,90 @@ +import assert from "node:assert/strict"; +import { chromium } from "playwright"; + +const baseUrl = process.env.PLAYWRIGHT_BASE_URL; +const token = process.env.LUDARIUM_ADMIN_TOKEN; +const gameId = process.env.BROWSERPLAY_GAME_ID; +const evidencePath = process.env.BROWSERPLAY_EVIDENCE_PATH ?? "/evidence/live-browserplay.png"; +const restoreEvidencePath = process.env.BROWSERPLAY_RESTORE_EVIDENCE_PATH ?? "/evidence/live-browserplay-restore.png"; +assert.ok(baseUrl && token && gameId, "live browser-play gate environment is incomplete"); + +const apiHeaders = { Authorization: `Bearer ${token}` }; +const capabilityResponse = await fetch(`${baseUrl}/api/v1/games/${gameId}/play-capability`, { headers: apiHeaders }); +assert.equal(capabilityResponse.status, 200); +const capability = await capabilityResponse.json(); +assert.equal(capability.available, true, JSON.stringify(capability)); +assert.equal(capability.platform, "nes"); +assert.equal(capability.core, "fceumm"); +assert.equal(capability.automaticRestore, true); + +const startSession = async () => { + const response = await fetch(`${baseUrl}/api/v1/games/${gameId}/play-sessions`, { + method: "POST", + headers: apiHeaders + }); + assert.equal(response.status, 201); + const value = await response.json(); + assert.match(value.launchUrl, /^\/player\.html\?[^#]+#token=[a-f0-9]{64}$/); + return value; +}; +const cancelSession = session => fetch(`${baseUrl}/api/v1/play-sessions/${session.id}`, { + method: "DELETE", headers: apiHeaders +}); + +const session = await startSession(); + +const launch = new URL(session.launchUrl, baseUrl); +const contentUrl = `${baseUrl}/api/v1/play-sessions/${session.id}/content`; +assert.equal((await fetch(contentUrl)).status, 404, "content must fail closed without the capability token"); +const capabilityToken = new URLSearchParams(launch.hash.slice(1)).get("token"); +const rangeResponse = await fetch(`${contentUrl}?token=${capabilityToken}`, { headers: { Range: "bytes=0-15" } }); +assert.equal(rangeResponse.status, 206); +assert.equal(rangeResponse.headers.get("content-range"), "bytes 0-15/24592"); +assert.deepEqual([...new Uint8Array(await rangeResponse.arrayBuffer())].slice(0, 4), [0x4e, 0x45, 0x53, 0x1a]); + +const browser = await chromium.launch({ headless: true }); +const page = await browser.newPage({ viewport: { width: 1280, height: 800 } }); +const consoleErrors = []; +const requestFailures = []; +page.on("console", message => { if (message.type() === "error") consoleErrors.push(message.text()); }); +page.on("requestfailed", request => requestFailures.push(`${request.method()} ${request.url()}: ${request.failure()?.errorText}`)); +try { + await page.goto(launch.href, { waitUntil: "domcontentloaded" }); + await page.locator("#status").waitFor({ state: "detached", timeout: 60_000 }); + await page.locator("#game canvas").waitFor({ state: "visible", timeout: 30_000 }); + await page.waitForTimeout(2_000); + await page.screenshot({ path: evidencePath, fullPage: true }); + await page.waitForFunction(() => typeof window.EJS_emulator?.gameManager?.getState === "function"); + const stateUpload = page.waitForResponse(response => + response.request().method() === "PUT" && response.url().includes(`/play-sessions/${session.id}/data/SaveState`)); + await page.evaluate(() => { + const state = window.EJS_emulator.gameManager.getState(); + if (!(state instanceof Uint8Array) || state.byteLength === 0) throw new Error("EmulatorJS returned no savestate bytes"); + window.EJS_onSaveState({ state }); + }); + assert.equal((await stateUpload).status(), 200); + assert.equal((await cancelSession(session)).status, 204); + + const restoredSession = await startSession(); + const restoredLaunch = new URL(restoredSession.launchUrl, baseUrl); + const restoreDownload = page.waitForResponse(response => + response.request().method() === "GET" && response.url().includes(`/play-sessions/${restoredSession.id}/data/SaveState`)); + try { + await page.goto(restoredLaunch.href, { waitUntil: "domcontentloaded" }); + assert.equal((await restoreDownload).status(), 200); + await page.locator("#status").waitFor({ state: "detached", timeout: 60_000 }); + await page.locator("#game canvas").waitFor({ state: "visible", timeout: 30_000 }); + await page.waitForFunction(() => document.body.dataset.restoreComplete === "true" && + document.body.dataset.restoredState === "true"); + await page.screenshot({ path: restoreEvidencePath, fullPage: true }); + } finally { + await cancelSession(restoredSession); + } + assert.equal(requestFailures.length, 0, requestFailures.join("\n")); + assert.equal(consoleErrors.length, 0, consoleErrors.join("\n")); +} finally { + await browser.close(); + await cancelSession(session); +} + +console.log(`Live native browser-play and bound savestate restore passed: game=${gameId} core=fceumm ROM bytes=24592.`); diff --git a/src/Ludarium.Web/e2e/live-switch-player.mjs b/src/Ludarium.Web/e2e/live-switch-player.mjs new file mode 100644 index 0000000..43154bd --- /dev/null +++ b/src/Ludarium.Web/e2e/live-switch-player.mjs @@ -0,0 +1,87 @@ +import assert from "node:assert/strict"; +import { chromium } from "playwright"; + +const baseUrl = process.env.PLAYWRIGHT_BASE_URL; +const token = process.env.LUDARIUM_ADMIN_TOKEN; +const gameId = process.env.SWITCH_GAME_ID; +const evidencePath = process.env.SWITCH_EVIDENCE_PATH; +assert.ok(baseUrl && token && gameId, "Switch player gate environment is incomplete"); + +const authorization = { Authorization: `Bearer ${token}` }; +const browser = await chromium.launch({ headless: true }); +const context = await browser.newContext({ viewport: { width: 1440, height: 900 } }); +const page = await context.newPage(); +const errors = []; +const failures = []; +let sockets = 0; +let session; + +page.on("console", message => { if (message.type() === "error") errors.push(message.text()); }); +page.on("pageerror", error => errors.push(error.message)); +page.on("requestfailed", request => failures.push(`${request.method()} ${request.url()}: ${request.failure()?.errorText}`)); +page.on("websocket", () => { sockets += 1; }); + +try { + const created = await context.request.post(`${baseUrl}/api/v1/games/${gameId}/switch-player-sessions`, { + headers: authorization + }); + assert.equal(created.status(), 201, await created.text()); + session = await created.json(); + + const navigation = await page.goto(session.launchUrl, { waitUntil: "domcontentloaded", timeout: 60_000 }); + assert.equal(navigation?.status(), 200); + await page.locator("video, canvas").first().waitFor({ state: "attached", timeout: 60_000 }); + await page.waitForTimeout(5_000); + + const surface = await page.evaluate(() => ({ + title: document.title, + videos: document.querySelectorAll("video").length, + canvases: document.querySelectorAll("canvas").length, + passwordFields: document.querySelectorAll('input[type="password"]').length, + text: document.body.innerText.slice(0, 1_000) + })); + assert.ok(surface.videos + surface.canvases > 0, JSON.stringify(surface)); + assert.equal(surface.passwordFields, 0, "Embedded player exposed a login form"); + assert.ok(!/sign in|log in|login/i.test(surface.text), "Embedded player exposed a login prompt"); + assert.ok(sockets > 0, "Selkies signalling WebSocket did not connect"); + assert.deepEqual(errors, []); + assert.deepEqual(failures, []); + if (process.env.SWITCH_REFRESH_GAME_LIST === "1") { + // Eden's refresh control is rendered inside the streamed desktop rather than the browser DOM. + // The validation viewport is fixed above, so this targets the lower-left refresh icon. + await page.mouse.click(229, 574); + await page.waitForTimeout(30_000); + } + if (evidencePath) await page.screenshot({ path: evidencePath, fullPage: true }); + + for (const action of ["pause-resume", "pause-resume", "fullscreen", "stop"]) { + const controlled = await context.request.post(`${baseUrl}/api/v1/switch-player-sessions/${session.id}/actions`, { + headers: authorization, + data: { action } + }); + assert.equal(controlled.status(), 202, `${action}: ${await controlled.text()}`); + } + + const ended = await context.request.delete(`${baseUrl}/api/v1/switch-player-sessions/${session.id}`, { + headers: authorization + }); + assert.equal(ended.status(), 204); + session = undefined; + assert.equal((await page.reload({ waitUntil: "domcontentloaded" }))?.status(), 401); + console.log(`Embedded Switch gate passed media=${surface.videos + surface.canvases} websockets=${sockets}; revoked session returned 401.`); +} catch (error) { + const diagnostics = await page.evaluate(() => ({ + title: document.title, + text: document.body.innerText.slice(0, 1_000), + videos: document.querySelectorAll("video").length, + canvases: document.querySelectorAll("canvas").length + })).catch(() => ({ page: "unavailable" })); + throw new Error(`${error.message}\ndiagnostics=${JSON.stringify(diagnostics)}\nerrors=${errors.join("\n")}\nfailures=${failures.join("\n")}`, { cause: error }); +} finally { + if (session) { + await context.request.delete(`${baseUrl}/api/v1/switch-player-sessions/${session.id}`, { + headers: authorization + }).catch(() => undefined); + } + await browser.close(); +} diff --git a/src/Ludarium.Web/e2e/mock-visual-audit-api.mjs b/src/Ludarium.Web/e2e/mock-visual-audit-api.mjs new file mode 100644 index 0000000..193fef0 --- /dev/null +++ b/src/Ludarium.Web/e2e/mock-visual-audit-api.mjs @@ -0,0 +1,116 @@ +import { createServer } from "node:http"; + +const port = Number(process.env.LUDARIUM_VISUAL_AUDIT_PORT ?? 8734); +const now = "2026-08-23T18:00:00Z"; +const ids = Array.from({ length: 8 }, (_, index) => `00000000-0000-0000-0000-0000000000${String(index + 1).padStart(2, "0")}`); +const titles = ["Gravity Circuit", "Sea of Stars", "Lumines", "Odin Sphere", "Tearaway", "Wipeout 2048", "Shovel Knight", "Child of Light"]; +const games = ids.map((id, index) => ({ id, title: titles[index], createdAt: now, version: 1, origin: "Scan" })); +const releases = games.map((game, index) => ({ id: `10000000-0000-0000-0000-0000000000${String(index + 1).padStart(2, "0")}`, gameId: game.id, title: game.title, platform: "psvita", region: "Europe", revision: null, version: 1, origin: "Scan" })); +const wishlistItems = games.slice(0, 3).map((game, index) => ({ + id: `40000000-0000-0000-0000-0000000000${String(index + 1).padStart(2, "0")}`, gameId: game.id, + title: game.title, platform: "psvita", priority: index === 0 ? "High" : "Normal", + notes: index === 0 ? "Collector's edition" : null, status: index === 2 ? "WaitingForSale" : "Interested", + desiredPrice: index === 2 ? 25 : null, currentPrice: index === 2 ? 20 : null, currency: "EUR", + storeName: null, storeUrl: null, edition: null, region: "Europe", releaseDate: null, + externalProvider: null, externalId: null, artworkUrl: null, createdAt: now, updatedAt: now, version: 1, +})); +const platforms = [ + { platform: "psvita", games: 8, artifacts: 12, bytes: 26_400_000_000 }, + { platform: "gamecube", games: 6, artifacts: 9, bytes: 8_400_000_000 }, + { platform: "xbox-series", games: 4, artifacts: 5, bytes: 315_000_000_000 }, +]; +const status = { + version: "visual-audit", schemaVersion: 27, libraries: 3, artifacts: 26, presentArtifacts: 26, + games: 18, releases: 18, bundles: 3, totalBytes: 349_800_000_000, hashedArtifacts: 24, + recognizedArtifacts: 25, unknownArtifacts: 1, missingArtifacts: 1, duplicateArtifacts: 0, + duplicateBytes: 0, openReviews: 2, criticalFindings: 0, activeScans: 0, + rootsRequiringAttention: 0, excludedSupportGames: 0, unresolvedGameCandidates: 1, + lastFullScanAt: "2026-08-22T18:00:00Z", lastIncrementalScanAt: "2026-08-23T17:30:00Z", + catalogImportedWithoutScan: false, confidencePercent: 96.4, +}; + +const review = (state) => ({ + id: `20000000-0000-0000-0000-0000000000${state === "Deferred" ? "02" : state === "Resolved" ? "03" : "01"}`, + reason: state === "Resolved" ? "Unknown platform classification" : "Incomplete related file group", + severity: state === "Open" ? "Warning" : "Notice", state, payloadJson: "{}", createdAt: now, + resolvedAt: state === "Resolved" ? now : null, + resolution: state === "Resolved" ? "Accepted PlayStation Vita after header verification" : null, +}); +const group = (state) => { + const item = review(state); + return { + key: `${state.toLowerCase()}-visual-group`, category: state === "Resolved" ? "unknown" : "incomplete", + reason: item.reason, severity: item.severity, count: state === "Open" ? 2 : 1, + estimatedBytes: 940_000_000, library: "Handhelds", platform: "psvita", + pathPattern: "PS Vita/Review/*", oldestAt: now, newestAt: now, + recommendedAction: state === "Resolved" ? "Inspect the recorded decision" : "Verify the companion files with a scan", + reviewIds: [item.id], resolution: item.resolution, resolvedAt: item.resolvedAt, totalCount: state === "Open" ? 2 : 1, + }; +}; + +function json(response, body, statusCode = 200) { + response.writeHead(statusCode, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" }); + response.end(JSON.stringify(body)); +} + +function coverSvg(index) { + const hues = [214, 167, 284, 24, 346, 198, 48, 252]; + const hue = hues[index % hues.length]; + return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 750 960"><defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop stop-color="hsl(${hue} 78% 58%)"/><stop offset="1" stop-color="hsl(${(hue + 54) % 360} 62% 17%)"/></linearGradient></defs><rect width="750" height="960" rx="28" fill="url(#g)"/><path d="M80 710 275 380l118 170 96-135 181 295z" fill="#fff" opacity=".18"/><circle cx="565" cy="235" r="112" fill="#fff" opacity=".14"/><rect x="78" y="782" width="594" height="18" rx="9" fill="#fff" opacity=".72"/><rect x="78" y="825" width="402" height="12" rx="6" fill="#fff" opacity=".35"/></svg>`; +} + +createServer((request, response) => { + const url = new URL(request.url ?? "/", `http://127.0.0.1:${port}`); + const path = url.pathname.replace(/^\/api\/v1/, ""); + if (request.method === "OPTIONS") return json(response, {}); + if (path === "/system/status") return json(response, status); + if (path === "/system/health") return json(response, { critical: 0, warning: 1, notice: 1, unknown: 1, missing: 1, incompleteBundles: 1, recentFindings: [] }); + if (path === "/platforms") return json(response, platforms); + if (path === "/wishlist/summary") return json(response, { total: wishlistItems.length, highPriority: 1, upcoming: 0, priceDrops: 1 }); + if (path === "/wishlist") return json(response, { items: wishlistItems, pageNumber: 1, pageSize: 24, total: wishlistItems.length }); + if (/^\/wishlist\/[^/]+\/artwork$/.test(path)) return json(response, null, 404); + if (path === "/collections" || path === "/tags" || path === "/system/remote-players") return json(response, path === "/system/remote-players" ? {} : []); + if (path === "/games" && url.searchParams.get("pageSize") === "1") return json(response, { items: [], page: 1, pageSize: 1, total: games.length }); + if (path === "/games") return json(response, { items: games, page: 1, pageSize: 48, total: games.length }); + if (path === "/releases") { + const gameId = url.searchParams.get("gameId"); + return json(response, gameId ? releases.filter((release) => release.gameId === gameId) : releases); + } + if (path === "/games/play-capabilities") return json(response, []); + const gameDetail = path.match(/^\/games\/([^/]+)$/); + if (request.method === "GET" && gameDetail) { + const game = games.find((item) => item.id === gameDetail[1]); + return game ? json(response, game) : json(response, { message: "Game not found" }, 404); + } + const detailResource = path.match(/^\/games\/([^/]+)\/(claims|artwork-info|artwork-candidates|tags|state|relations|media|achievements|data|play-capability)$/); + if (request.method === "GET" && detailResource) { + const [,, resource] = detailResource; + if (resource === "artwork-info") return json(response, { source: "Libretro", contentType: "image/svg+xml", length: null }); + if (resource === "state") return json(response, { + gameId: detailResource[1], favorite: false, status: "Backlog", rating: null, difficulty: null, + completionPercent: null, notes: null, playCount: 0, lastPlayedAt: null, updatedAt: now, version: 1, + }); + if (resource === "play-capability") return json(response, { + gameId: detailResource[1], available: false, state: "UnsupportedPlatform", + message: "No present ROM is linked to this game.", platform: "psvita", + emulator: null, core: null, desktopRecommended: true, checkedAt: now, automaticRestore: false, + }); + if (resource === "achievements") return json(response, null); + return json(response, []); + } + const artwork = path.match(/^\/games\/([^/]+)\/artwork$/); + if (artwork) { + const index = Math.max(0, ids.indexOf(artwork[1])); + response.writeHead(200, { "content-type": "image/svg+xml", "cache-control": "no-store" }); + return response.end(coverSvg(index)); + } + if (path === "/review-items") { + const state = url.searchParams.get("state") ?? "Open"; + const item = review(state); + return json(response, { items: [item], page: 1, pageSize: 200, total: 1 }); + } + if (path === "/review-groups") return json(response, [group(url.searchParams.get("state") ?? "Open")]); + if (request.method === "POST" && ["/review-groups/defer", "/review-groups/reopen", "/review-groups/apply"].includes(path)) return json(response, { id: "30000000-0000-0000-0000-000000000001" }); + if (request.method === "POST" && (/^\/review-items\/.+\/(?:defer|reopen)$/.test(path) || /^\/review-operations\/.+\/undo$/.test(path))) return json(response, {}); + return json(response, []); +}).listen(port, "127.0.0.1", () => console.log(`Visual audit API listening on http://127.0.0.1:${port}`)); diff --git a/src/Ludarium.Web/e2e/workflows.mjs b/src/Ludarium.Web/e2e/workflows.mjs new file mode 100644 index 0000000..fdc6e04 --- /dev/null +++ b/src/Ludarium.Web/e2e/workflows.mjs @@ -0,0 +1,501 @@ +import assert from "node:assert/strict"; +import { mkdir } from "node:fs/promises"; +import { chromium } from "playwright"; +const baseUrl = process.env.PLAYWRIGHT_BASE_URL ?? "http://127.0.0.1:1230"; +const token = process.env.LUDARIUM_ADMIN_TOKEN; +if (!token) throw new Error("LUDARIUM_ADMIN_TOKEN is required."); +const screenshotDir = process.env.PLAYWRIGHT_SCREENSHOT_DIR; +const mutateCatalog = process.env.PLAYWRIGHT_MUTATE_CATALOG !== "0"; +const browser = await chromium.launch({ headless: true }); +const errors = []; +async function authenticate(page) { + page.on("console", (m) => m.type() === "error" && errors.push(m.text())); + page.on("pageerror", (e) => errors.push(e.message)); + await page.goto(`${baseUrl}/#Home`, { waitUntil: "networkidle" }); + await page.locator("main").waitFor({ state: "visible", timeout: 60_000 }); + const login = page.getByLabel("Administrator token"); + if (await login.isVisible()) { + await login.fill(token); + await page.getByRole("button", { name: "Open Ludarium" }).click(); + await login.waitFor({ state: "hidden" }); + } + await page.locator(".home-status h1").waitFor(); +} +async function api(path) { + const response = await fetch(`${baseUrl}/api/v1${path}`, { + headers: { Authorization: `Bearer ${token}` }, + }); + // One run spends about 550 reads of the API's 1200-per-minute budget, and the browser's own + // requests share that budget, so a third run inside the same minute is refused. That is the + // limiter working, not a defect: give it a minute. + if (response.status === 429) + throw new Error(`GET ${path} was rate limited. Wait a minute between runs against one host.`); + if (!response.ok) throw new Error(`GET ${path} responded ${response.status}.`); + return response.json(); +} +/** + * Finds a game the archive cannot play. + * + * The play panel's refusal is only a real assertion when the game genuinely has no playable copy. + * Naming a title here tied the gate to one operator's archive, so it asks the catalogue instead. + */ +/** A refusal has to say what was wrong; an empty 400 tells a client nothing. */ +async function assertRefusalsCarryAMessage() { + const response = await fetch(`${baseUrl}/api/v1/libraries`, { + method: "POST", + headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, + body: JSON.stringify({ name: "gate", path: "/library/games", kind: "Rom", hashPolicy: "NotAPolicy" }), + }); + assert.equal(response.status, 400); + const body = await response.json(); + assert.ok(body.message, "a request the API could not read was refused without saying why"); +} +async function findUnplayableGame() { + const catalog = await api("/games?pageSize=50"); + const games = catalog.items ?? catalog; + assert.ok(games.length > 0, "The candidate archive holds no games to open."); + const capabilities = await api( + `/games/play-capabilities?ids=${games.map((game) => game.id).join(",")}`, + ); + const blocked = new Set(capabilities.filter((entry) => !entry.available).map((entry) => entry.gameId)); + const game = games.find((candidate) => blocked.has(candidate.id)); + assert.ok(game, "Every game in the candidate archive is playable; this step needs one that is not."); + return game; +} +async function shot(page, name) { + if (!screenshotDir) return; + await mkdir(screenshotDir, { recursive: true }); + await page.screenshot({ + path: `${screenshotDir}/${name}.png`, + fullPage: true, + }); +} +try { + const desktop = await browser.newContext({ + viewport: { width: 1440, height: 900 }, + reducedMotion: "reduce", + }); + const page = await desktop.newPage(); + await authenticate(page); + await page.evaluate(() => { + const buttons = Array.from({ length: 17 }, () => ({ pressed: false, touched: false, value: 0 })); + const pad = { axes: [0, 0, 0, 0], buttons, connected: true, id: "Ludarium workflow gamepad", index: 0, mapping: "standard", timestamp: 1, vibrationActuator: null }; + Object.defineProperty(navigator, "getGamepads", { configurable: true, value: () => [pad] }); + Object.defineProperty(window, "__ludariumWorkflowPad", { configurable: true, value: pad }); + const event = new Event("gamepadconnected"); + Object.defineProperty(event, "gamepad", { value: pad }); + dispatchEvent(event); + }); + await page.locator(".controller-status").waitFor(); + assert.ok(await page.getByText("Controller ready").isVisible()); + await page.evaluate(() => { window.__ludariumWorkflowPad.buttons[13] = { pressed: true, touched: true, value: 1 }; }); + await page.waitForFunction(() => Boolean(document.activeElement?.closest("main .page")), undefined, { timeout: 2_000 }); + await page.evaluate(() => { window.__ludariumWorkflowPad.buttons[13] = { pressed: false, touched: false, value: 0 }; }); + assert.equal(await page.evaluate(() => Boolean(document.activeElement?.closest("main .page"))), true); + await page.evaluate(() => { window.__ludariumWorkflowPad.buttons[9] = { pressed: true, touched: true, value: 1 }; }); + await page.getByRole("dialog", { name: "Search Ludarium" }).waitFor(); + await page.evaluate(() => { window.__ludariumWorkflowPad.buttons[9] = { pressed: false, touched: false, value: 0 }; }); + await page.waitForTimeout(40); + await page.evaluate(() => { window.__ludariumWorkflowPad.buttons[1] = { pressed: true, touched: true, value: 1 }; }); + await page.getByRole("dialog", { name: "Search Ludarium" }).waitFor({ state: "hidden" }); + await page.evaluate(() => { + Object.defineProperty(navigator, "getGamepads", { configurable: true, value: () => [] }); + const event = new Event("gamepaddisconnected"); + Object.defineProperty(event, "gamepad", { value: window.__ludariumWorkflowPad }); + dispatchEvent(event); + }); + await page.locator(".controller-status").waitFor({ state: "detached" }); + await page.waitForLoadState("networkidle"); + assert.doesNotMatch(await page.locator(".home-status h1").innerText(), /\b1 items\b/i); + // The primary sidebar stays focused on the eight top-level destinations. Deep archive and + // playability tools remain available inside Library and Settings instead of duplicating routes. + assert.equal(await page.locator("aside nav a").count(), 8); + for (const name of ["Collections", "Platforms"]) + assert.ok(await page.locator(".app-shell > aside").getByRole("link", { name }).isVisible(), `${name} is missing`); + // A configured archive offers a scan or a review; an empty one names the step that comes first. + assert.ok( + await page + .getByRole("button", { name: /scan libraries|review priorities/i }) + .or(page.getByRole("link", { name: /view active scan|add a library/i })) + .first() + .isVisible(), + ); + assert.equal(await page.locator(".metric").count(), 4); + // Home's ceiling covers a full "Recently played" section plus the prioritized archive-health + // signals. A third recent row or an unbounded attention list still exceeds this budget. + const homeHasRecent = await page.locator(".home-recent").count() > 0; + const homeBudget = homeHasRecent ? 1700 : 1380; + const heroHeight = await page.locator(".home-status").evaluate((section) => section.getBoundingClientRect().height); + const metricHeight = await page.locator(".metric-grid").evaluate((section) => section.getBoundingClientRect().height); + assert.ok(heroHeight <= 440, `Home hero is ${heroHeight}px tall; its budget is 440px.`); + assert.ok(metricHeight <= 140, `Home metrics are ${metricHeight}px tall; their budget is 140px.`); + const homeHeight = await page.evaluate(() => document.documentElement.scrollHeight); + assert.ok(homeHeight <= homeBudget, `Home is ${homeHeight}px tall; its budget is ${homeBudget}px.`); + if (homeHasRecent) + assert.ok( + (await page.locator(".home-recent").evaluate((section) => section.getBoundingClientRect().height)) <= 320, + "the recently-played section grew past the two rows its own limit allows", + ); + await shot(page, "home-desktop"); + await assertRefusalsCarryAMessage(); + const unplayable = await findUnplayableGame(); + await page.getByRole("button", { name: /search games/i }).click(); + await page.getByLabel("Search games, files, paths or hashes").fill(unplayable.title); + const option = page.getByRole("option").filter({ hasText: unplayable.title }).first(); + await option.waitFor(); + await option.click(); + await page.getByRole("heading", { name: "Library" }).waitFor(); + await page.locator(".drawer").waitFor(); + assert.equal( + await page.locator(".detail-lead h3").evaluate((heading) => getComputedStyle(heading).color), + "rgb(242, 247, 255)", + "the light theme must not render the game title dark on the dark detail hero", + ); + await shot(page, "game-detail-desktop"); + assert.equal(await page.getByRole("button", { name: "Play now" }).count(), 0); + const playOptions = page.getByRole("region", { name: "Play options" }); + await playOptions.waitFor(); + assert.ok(await playOptions.isVisible()); + assert.ok(await playOptions.getByText("No playable copy available").isVisible()); + if (mutateCatalog) { + const metadataValue = `Ludarium synthetic fixture studio ${Date.now()}`; + await page.getByRole("button", { name: "Edit metadata" }).click(); + await page.getByLabel("Developer").fill(metadataValue); + await page.getByRole("button", { name: "Save changes" }).click(); + await page.getByText(/manual field saved and locked/i).waitFor(); + await page.getByText("More details", { exact: true }).click(); + await page.getByRole("tab", { name: "Provenance" }).click(); + assert.ok(await page.getByText(/manually locked/i).first().isVisible()); + await page.getByRole("tab", { name: "Overview" }).click(); + assert.ok(await page.getByText(metadataValue).isVisible()); + } + await page.getByRole("button", { name: "Close" }).click(); + await page.goto(`${baseUrl}/#Library`, { waitUntil: "networkidle" }); + assert.ok( + await page + .getByRole("link", { name: "Games", exact: true }) + .getAttribute("class") + .then((x) => x?.includes("active")), + ); + const platformRailLink = page.locator('.library-platform-rail a[href*="platform="]').first(); + await platformRailLink.waitFor(); + await platformRailLink.click(); + assert.match(page.url(), /platform=/); + await page.getByLabel("Active filters").waitFor(); + await page.waitForFunction(() => { + const select = [...document.querySelectorAll("label.field")] + .find((label) => label.textContent?.includes("Platform")) + ?.querySelector("select"); + return select instanceof HTMLSelectElement && select.value !== "all"; + }); + assert.notEqual( + await page.locator("label.field").filter({ hasText: "Platform" }).locator("select").inputValue(), + "all", + ); + await page.getByRole("button", { name: "Reset filters" }).click(); + if (mutateCatalog) { + const browserPlay = page.getByRole("button", { name: /play .+ in browser/i }).first(); + await browserPlay.waitFor(); + const playLabel = await browserPlay.getAttribute("aria-label"); + const playedTitle = playLabel?.replace(/^Play /, "").replace(/ in browser$/, ""); + assert.ok(playedTitle, "A browser-playable title is required for the recently-played gate."); + await browserPlay.click(); + const player = page.getByRole("region", { name: new RegExp(`Browser player for ${playedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`) }); + await player.waitFor(); + await player.getByRole("button", { name: "Exit game" }).click(); + await player.waitFor({ state: "hidden" }); + await page.getByRole("button", { name: "Close" }).click(); + await page.goto(`${baseUrl}/#Home`, { waitUntil: "networkidle" }); + await page.getByRole("heading", { name: "Recently played" }).waitFor(); + const recent = page.locator(".home-recent-list article").filter({ hasText: playedTitle }).first(); + await recent.getByRole("link", { name: `Open details for ${playedTitle}` }).waitFor(); + const recentPlay = recent.getByRole("button", { name: /^(Play|Resume)$/ }); + await recentPlay.waitFor(); + await recentPlay.click(); + const resumedPlayer = page.getByRole("region", { name: new RegExp(`Browser player for ${playedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`) }); + await resumedPlayer.waitFor(); + await resumedPlayer.getByRole("button", { name: "Exit game" }).click(); + await resumedPlayer.waitFor({ state: "hidden" }); + await page.goto(`${baseUrl}/#Library`, { waitUntil: "networkidle" }); + } + // Any platform the archive actually holds proves the filter; naming one tied the gate to an + // archive that happened to contain Windows installers. + const [firstPlatform] = await api("/platforms"); + assert.ok(firstPlatform, "The candidate archive reports no platforms to filter on."); + await page + .locator("label.field") + .filter({ hasText: "Platform" }) + .locator("select") + .selectOption(firstPlatform.platform); + await page.waitForLoadState("networkidle"); + assert.match(page.url(), /platform=/); + await page.locator(".game-card").first().waitFor(); + assert.ok((await page.locator(".game-card").count()) > 0); + assert.equal( + await page.locator(".collection-toolbar").evaluate((toolbar) => + [...toolbar.children].every((child) => { + const item = child.getBoundingClientRect(); + const bounds = toolbar.getBoundingClientRect(); + return item.left >= bounds.left - 1 && item.right <= bounds.right + 1; + }), + ), + true, + ); + // A grid that gave a column to only the first few controls squeezed the rest to a sliver: the + // operator read "Any s" where the option says "Any status", and a quick filter was cut off. + assert.deepEqual( + await page.locator(".collection-toolbar select").evaluateAll((controls) => + controls.filter((control) => control.getBoundingClientRect().width < 120) + .map((control) => control.closest("label")?.textContent ?? "unnamed"), + ), + [], + ); + assert.equal(await page.getByLabel("Active filters").count(), 1); + await page.getByRole("button", { name: "Compact" }).click(); + assert.ok((await page.locator(".game-browser.density-compact").count()) === 1); + await page.reload({ waitUntil: "networkidle" }); + assert.ok((await page.locator(".game-browser.density-compact").count()) === 1); + await page.getByRole("button", { name: "Reset filters" }).click(); + assert.equal(await page.getByLabel("Active filters").count(), 0); + if (mutateCatalog) { + // Organising more than one game at a time is a per-game write behind one control, so the proof + // that it worked has to come from the games themselves rather than from the message on screen. + const catalog = await api("/games?pageSize=50"); + const [first, second] = catalog.items ?? catalog; + assert.ok(second, "Bulk organising needs at least two games in the archive."); + for (const game of [first, second]) + await page.getByRole("checkbox", { name: `Select ${game.title}` }).check(); + const bulk = page.getByRole("group", { name: "2 selected games" }); + await bulk.waitFor(); + await bulk.getByRole("button", { name: "Favourite", exact: true }).click(); + await page.getByText(/Favourite applied to 2 games/).waitFor(); + for (const game of [first, second]) + assert.equal((await api(`/games/${game.id}/state`)).favorite, true, `${game.title} was not favourited`); + + // A completed change clears the selection, so tagging starts from a fresh one. + const tag = `Gate ${Date.now()}`; + for (const game of [first, second]) + await page.getByRole("checkbox", { name: `Select ${game.title}` }).check(); + await bulk.waitFor(); + await bulk.getByLabel("Add tag").fill(tag); + await bulk.getByRole("button", { name: "Add", exact: true }).click(); + await page.getByText(new RegExp(`Tag ${tag} applied to 2 games`)).waitFor(); + for (const game of [first, second]) { + const applied = (await api(`/games/${game.id}/tags`)).find((entry) => entry.name === tag); + assert.ok(applied, `${game.title} did not receive the tag`); + const removed = await fetch(`${baseUrl}/api/v1/games/${game.id}/tags/${applied.id}`, { + method: "DELETE", + headers: { Authorization: `Bearer ${token}` }, + }); + assert.ok(removed.ok, `the gate could not remove its own tag from ${game.title}`); + } + + // Undo what the gate changed, so a rerun starts from the archive it found. + for (const game of [first, second]) + await page.getByRole("checkbox", { name: `Select ${game.title}` }).check(); + await bulk.waitFor(); + await bulk.getByRole("button", { name: "Unfavourite" }).click(); + await page.getByText(/Remove favourite applied to 2 games/).waitFor(); + for (const game of [first, second]) + assert.equal((await api(`/games/${game.id}/state`)).favorite, false, `${game.title} stayed favourited`); + } + await shot(page, "library-desktop"); + await page.locator(".app-shell > aside").getByRole("link", { name: "Platforms", exact: true }).click(); + await page.getByText("Your platforms").waitFor(); + await page.locator(".platform-grid > a").first().click(); + await page.getByLabel("Active filters").waitFor(); + await page.getByRole("link", { name: "Windows installers" }).click(); + await page.getByRole("heading", { name: "Library" }).waitFor(); + await page.getByRole("link", { name: /Files/ }).click(); + await page.getByText("Advanced file view").waitFor(); + const file = page.locator(".file-list > button").first(); + await file.focus(); + await file.press("Enter"); + await page.getByText("Technical details").waitFor(); + await page.getByText("Technical details").click(); + assert.ok(await page.getByText("Content identity").isVisible()); + await page.getByRole("button", { name: "Close" }).click(); + await page.goto(`${baseUrl}/#Wishlist`, { waitUntil: "networkidle" }); + await page.getByRole("heading", { name: "Wishlist" }).waitFor(); + const wishTitle = `Synthetic wishlist ${Date.now()}`; + await page.getByRole("button", { name: "Add a game" }).click(); + await page.getByLabel("Game title").fill(wishTitle); + await page.getByLabel("Priority").last().selectOption("High"); + await page.getByLabel("Status").last().selectOption("WaitingForSale"); + await page.getByLabel("Target price").fill("30"); + await page.getByRole("spinbutton", { name: "Current price" }).fill("20"); + await page.getByLabel("Note").fill("Release workflow fixture"); + await page.getByRole("button", { name: "Add to wishlist" }).click(); + await page.getByText(wishTitle).waitFor(); + await page.getByText(/target reached/).waitFor(); + await page.goto(`${baseUrl}/#Home`, { waitUntil: "networkidle" }); + await page.getByRole("heading", { name: "Your wishlist" }).waitFor(); + await page.getByText(wishTitle).waitFor(); + await page.goto(`${baseUrl}/#Wishlist`, { waitUntil: "networkidle" }); + await page.getByLabel("Search wishlist").fill(wishTitle); + // Every assertion stays inside this run's own card, so an entry left behind by an interrupted + // run cannot satisfy or confuse it. + const wishCard = page.locator(".wishlist-card", { hasText: wishTitle }); + await wishCard.getByText(wishTitle).click(); + await page.getByLabel("Note").fill("Updated release workflow fixture"); + await page.getByLabel("Status").last().selectOption("Reserved"); + await page.getByRole("spinbutton", { name: "Current price" }).fill("35"); + await page.getByRole("button", { name: "Save changes" }).click(); + await wishCard.locator(".wishlist-note", { hasText: "Updated release workflow fixture" }).waitFor(); + await wishCard.locator(".wishlist-card-status", { hasText: "Reserved" }).waitFor(); + await wishCard.getByText(wishTitle).click(); + await page.getByRole("button", { name: "Remove" }).click(); + // Removal asks in the application's own focus-trapped dialog, not the browser's, and its button + // names the action rather than saying "OK". + const removeWish = page.getByRole("alertdialog"); + await removeWish.waitFor(); + assert.match(await removeWish.getByRole("heading").innerText(), new RegExp(wishTitle)); + await removeWish.getByRole("button", { name: "Remove from wishlist" }).click(); + await removeWish.waitFor({ state: "detached" }); + await wishCard.waitFor({ state: "detached" }); + await shot(page, "wishlist-desktop"); + await page.goto(`${baseUrl}/#Attention`, { waitUntil: "networkidle" }); + await page.getByRole("heading", { name: "Attention", exact: true }).waitFor(); + await shot(page, "attention-desktop"); + const item = page.locator(".attention-list > button").first(); + if (await item.count()) { + await item.click(); + await page.getByText("Recommended action").waitFor(); + const defer = page.getByRole("button", { name: "Defer" }); + if (await defer.count()) { + const classify = page.getByRole("button", { name: "Classify as support content" }); + if (await classify.count()) await classify.click(); + else await defer.click(); + await page.getByRole("button", { name: "Undo" }).waitFor(); + await page.getByRole("button", { name: "Undo" }).click(); + } + } + await page.goto(`${baseUrl}/#Activity`, { waitUntil: "networkidle" }); + await page.getByText("Scan all libraries").waitFor(); + assert.equal(await page.getByLabel("Scan type").locator("option").count(), 3); + await page.getByRole("link", { name: "Snapshots" }).click(); + await page.getByText("Integrity snapshots").waitFor(); + await page.getByRole("link", { name: "History", exact: true }).click(); + await page.getByRole("heading", { name: "Scan history" }).waitFor(); + await page.getByRole("link", { name: "Exports", exact: true }).click(); + await page.getByRole("button", { name: /Inventory JSON/ }).click(); + const inventoryStatus = page.getByRole("status").filter({ hasText: "Inventory JSON" }); + await inventoryStatus.waitFor({ timeout: 90_000 }); + assert.doesNotMatch(await inventoryStatus.innerText(), /undefined/); + await page.getByRole("button", { name: /Integrity manifest/ }).click(); + await page.getByRole("status").filter({ hasText: "Integrity manifest" }).waitFor({ timeout: 90_000 }); + await shot(page, "activity-desktop"); + await page.goto(`${baseUrl}/#Settings`, { waitUntil: "networkidle" }); + await page.getByText("Game libraries").waitFor(); + await page.locator(".library-settings article").first().waitFor(); + // Settings must show every configured archive, not merely three of them. + const libraries = await api("/libraries"); + assert.equal(await page.locator(".library-settings article").count(), libraries.length); + await page.getByRole("button", { name: "Add library" }).click(); + await page.getByRole("heading", { name: "Add a game library" }).waitFor(); + await page.getByRole("button", { name: "Cancel" }).click(); + await shot(page, "settings-desktop"); + // Follow the focused Settings destination for playability. It opens on the games that need + // attention, and when none do it must say so rather than reading as a dead end. + await page.getByRole("navigation", { name: "Settings sections" }) + .getByRole("link", { name: "Playability & naming", exact: true }).click(); + await page.getByRole("heading", { name: "Playability & repair" }).waitFor(); + const blocked = Number( + (await page.locator(".repair-summary article").filter({ hasText: "Needs attention" }) + .locator("b").innerText()).replace(/\D/g, ""), + ); + if (blocked === 0) { + assert.equal(await page.getByText("No games match these filters").count(), 0); + assert.ok(await page.getByText("Every checked game can start").isVisible()); + } + await page.evaluate(() => window.scrollTo(0, document.documentElement.scrollHeight)); + await page.locator(".app-shell > aside").getByRole("link", { name: "Home", exact: true }).click(); + await page.locator(".home-status h1").waitFor(); + assert.equal(await page.evaluate(() => window.scrollY), 0); + assert.equal(errors.length, 0, `Console errors: ${errors.join("; ")}`); + assert.equal( + await page.evaluate( + () => + document.documentElement.scrollWidth === + document.documentElement.clientWidth, + ), + true, + ); + await desktop.close(); + const mobile = await browser.newContext({ + viewport: { width: 390, height: 844 }, + reducedMotion: "reduce", + }); + const mp = await mobile.newPage(); + await authenticate(mp); + assert.equal(await mp.locator(".mobile-nav a").count(), 3); + assert.equal(await mp.locator(".mobile-nav > a, .mobile-nav > button").count(), 5); + for (const destination of ["Home", "Library", "Search Ludarium", "Wishlist", "More"]) { + assert.ok(await mp.locator(".mobile-nav").getByRole(destination === "Search Ludarium" || destination === "More" ? "button" : "link", { name: destination }).isVisible()); + } + assert.equal( + await mp.locator(".app-shell > aside").evaluate((e) => getComputedStyle(e).display), + "none", + ); + await shot(mp, "home-mobile"); + await mp.locator(".mobile-nav").getByRole("link", { name: "Library" }).click(); + await mp.getByRole("heading", { name: "Library" }).waitFor(); + assert.equal( + await mp.getByRole("navigation", { name: "Library views" }).evaluate( + (tabs) => tabs.scrollWidth <= tabs.clientWidth, + ), + true, + ); + await mp.getByRole("button", { name: "Filters & view" }).click(); + const mobilePlatform = mp.locator("label.field").filter({ hasText: "Platform" }).locator("select"); + assert.ok((await mobilePlatform.locator("option").count()) > 1); + await mobilePlatform.selectOption({ index: 1 }); + await mp.waitForLoadState("networkidle"); + assert.match(mp.url(), /platform=/); + await mp.locator(".game-card").first().click(); + assert.equal( + await mp + .locator(".drawer") + .evaluate((e) => Math.round(e.getBoundingClientRect().width)), + 390, + ); + await shot(mp, "game-detail-mobile"); + await mp.getByRole("button", { name: "Close" }).click(); + await mp.locator(".drawer").waitFor({ state: "hidden" }); + await mp.locator(".mobile-nav").getByRole("button", { name: "More" }).click(); + await mp.getByRole("dialog", { name: "More in Ludarium" }).getByRole("link", { name: /Attention/ }).click(); + await mp.getByRole("heading", { name: "Attention", exact: true }).waitFor(); + assert.match(mp.url(), /view=decisions/); + await mp.getByRole("button", { name: "Filters", exact: true }).click(); + assert.equal(await mp.getByLabel("Filter attention items").count(), 1); + await mp.getByPlaceholder("Cause, path or action").fill("unlikely-filter-value"); + await mp.getByText("No matching items").waitFor(); + await shot(mp, "attention-mobile"); + await mp.locator(".mobile-nav").getByRole("button", { name: "More" }).click(); + await mp.getByRole("dialog", { name: "More in Ludarium" }).getByRole("link", { name: /Settings/ }).click(); + await mp.getByRole("heading", { name: "Settings" }).waitFor(); + const mobileSettingsSelect = mp.getByLabel("Settings section", { exact: true }); + await mobileSettingsSelect.waitFor({ state: "visible" }); + assert.ok(await mobileSettingsSelect.isVisible()); + assert.equal(await mobileSettingsSelect.locator('option[value="repair"]').count(), 1); + await mobileSettingsSelect.selectOption("repair"); + await mp.getByRole("heading", { name: "Playability & repair" }).waitFor(); + assert.equal( + await mp.getByRole("navigation", { name: "Settings sections" }).isVisible(), + false, + ); + assert.equal( + await mp.evaluate( + () => + document.documentElement.scrollWidth === + document.documentElement.clientWidth, + ), + true, + ); + await mobile.close(); + console.log( + "UX workflow gate passed: Home, global search, Library, Wishlist CRUD, Attention, Activity, Settings and mobile flows.", + ); +} finally { + await browser.close(); +} diff --git a/src/Ludarium.Web/index.html b/src/Ludarium.Web/index.html new file mode 100644 index 0000000..c9a421f --- /dev/null +++ b/src/Ludarium.Web/index.html @@ -0,0 +1,15 @@ +<!doctype html> +<html lang="en"> + <head> + <meta charset="UTF-8" /> + <link rel="icon" type="image/svg+xml" href="/favicon.svg" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <meta name="theme-color" content="#09101d" /> + <meta name="description" content="Ludarium — local-first game archive intelligence for Unraid" /> + <title>Ludarium — Game Archive Intelligence + + +
+ + + diff --git a/src/Ludarium.Web/package-lock.json b/src/Ludarium.Web/package-lock.json new file mode 100644 index 0000000..753b374 --- /dev/null +++ b/src/Ludarium.Web/package-lock.json @@ -0,0 +1,1782 @@ +{ + "name": "ludarium-web", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ludarium-web", + "version": "0.0.0", + "dependencies": { + "@fontsource-variable/inter": "5.3.0", + "@fontsource-variable/manrope": "5.3.0", + "react": "19.2.8", + "react-dom": "19.2.8" + }, + "devDependencies": { + "@axe-core/playwright": "4.12.1", + "@testing-library/jest-dom": "7.0.0", + "@testing-library/react": "16.3.2", + "@types/react": "19.2.14", + "@types/react-dom": "19.2.3", + "@vitejs/plugin-react": "6.0.4", + "happy-dom": "20.11.1", + "playwright": "1.62.1", + "typescript": "6.0.2", + "vite": "8.2.1", + "vitest": "4.1.10" + }, + "optionalDependencies": { + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0" + } + }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@axe-core/playwright": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.12.1.tgz", + "integrity": "sha512-rMd7xriptqKpP+w5265i4Hdkv2X5kbu6uiBi/B2I7uf3hieRBM3qDCfaKPtxfiYb2mKXfF+yLODJwIx+Jv1GDw==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "axe-core": "~4.12.1" + }, + "peerDependencies": { + "playwright-core": ">= 1.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@fontsource-variable/inter": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.3.0.tgz", + "integrity": "sha512-OupL48va4JNofb97w6NYeF9S7W/kHNKM0Er8Dem5nqi4jeOLrVJDoE8tZEpnMJmtkvNbB1EIPPwHcdkF6b1oUA==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource-variable/manrope": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource-variable/manrope/-/manrope-5.3.0.tgz", + "integrity": "sha512-6D5dgokHsWDDMtmXHznKa0hK229NN+1a4BLPmUCLqcO1Pw5EEhWY5RFt0AcXnVRAljFFPfRtLkJePQj6LSsV6g==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@oxc-project/types": { + "version": "0.143.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.143.0.tgz", + "integrity": "sha512-u6JZdLBTLotrNC9Vd6vPssINdzcCzleKAH6EJKImQb7GtYvX5keN2dxkoK44stCc4tffE6QQRtZTXVSzsLUlWA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.3.tgz", + "integrity": "sha512-zrJtHDcaZJ1Fp7xf4hNl+7seH9Cn/N5TwLYkhgXREtBwAd/jaqW3uqeHxpDugJLVICWg4eW44kOQEGJ1r6jCGw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.3.tgz", + "integrity": "sha512-ieIiibVCp0tX7TLu2cafoNPv8wJyYi01ekXpbf8q2j7F4rGAhhXb/eQh7ge9DRBY78GwmRQtvjZDux7EDbA8kA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.3.tgz", + "integrity": "sha512-Zh9tCon19eDXJoihx0rqKhMUlMYqzwj3aPsSuHmI4RWZh62dWUL+DJN4C5YQya5TcQBJU/Fe8+rY0jhXTQITqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.3.tgz", + "integrity": "sha512-nGbJWewA1wrXXZiQhjAT5rhibGfns5ZNkDVqxsO6zJ3f3YvpoDNNmGMSbbhLuXKjNScaBJVOAboztAWVespQMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.3.tgz", + "integrity": "sha512-QNniJr5Kml0kDEB98jiDOJjXNroxIIi0IXIbdYzY26Xt1pVbeP62+KnoIZLwirOymX/0jDk/2gI/bNUv7A7OIw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.3.tgz", + "integrity": "sha512-TkqEAcmmvH3I/q4114NB4RVt6241Dao48pF45uLcFGrwAaIn0iITgTAKP/dLjbN0R4buJjGb91+UHSoFmpgIWw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.3.tgz", + "integrity": "sha512-NHqjnxpsndf4MPymxteFAWHHfkTL8HjWh1KB7z23ofZ6QO2euONuxDXjat69dKZRALnGypg8k8SsK8vZJoXv1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.3.tgz", + "integrity": "sha512-6tbrbwfz5GB9DQ4Jwo6hy9v+vR31xZlvzZ6n5Xut6Hhx5PvrA9q/HsK8KMaYQp063iqZGXwNvZtYNLD7EM/x0w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.3.tgz", + "integrity": "sha512-oyuXxXmoZHjXC917IAPFAAv4wWAa0cM9afk8nx1+9/jNNOX1uPf8yDA6p7G0RypOfw/X0PQt5IfoquY1um+zSg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.3.tgz", + "integrity": "sha512-TytMwF2KVGqP2tgd0I1OY0PAv78dZRAYcF5ssDzjM34SUXCED3uXvSd5+lHoC0bTD6eEdFz7LdQNCO1y0oVk9w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.3.tgz", + "integrity": "sha512-/E9m3qstrJFVPoULV25mVQblSNExY2+kBsYe4sy0Tn0yOOgJ8wZbZt3KnRbF/XeU2Gl1STKUQnDNTqhIE5MD4A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.3.tgz", + "integrity": "sha512-Kr0OcsoQI816i6HOl3vFHpd1K0eZyh76zgfj4c1nTyaTsd5r2Mj1lwM4R90y/qaCfmTn9eHy0SKwi98eitRxug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.3.tgz", + "integrity": "sha512-hOtMwTqnME+/gJcH/PCZ0wn0zPUjiWOgkHpxbSJpfGKMezHltx1S7/k1SitzVa7Ww2cqrDDaFbZEhcJZO8o+Jw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.3.tgz", + "integrity": "sha512-ekcqMMkI2PlhYnfzQnB/cEdYUVVJViWvoUyLrbzgDoi3Snfc1mVBwdnc306ufA5ejy8JSPjT2RlW1nQSjW7efg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-7.0.0.tgz", + "integrity": "sha512-HKAH9C6mBo5yBG6yRO5i43L2iisencAo5z+o5P/saHUoY+miC5ivXRxHBJcFyB5ypPNxHJdK3BoF/3O4DIptMg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=22", + "npm": ">=6", + "yarn": ">=1" + }, + "peerDependencies": { + "@testing-library/dom": ">=10 <11" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.1.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.2.tgz", + "integrity": "sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/whatwg-mimetype": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz", + "integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.4.tgz", + "integrity": "sha512-XcCQz0TBpBgljhj0gMuuDj49i6Ytqh5q1osT/Gp5uAVJUCTWxyskk/l1jwYYiu2xcNHHipdMz40EGfM1VdamVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/axe-core": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.12.1.tgz", + "integrity": "sha512-s7iGf5GaVMxEG0ENN9x+xTr7GFZCb1ZP/1uATUpCEK2X78nDB3RwbtFCo9pGAf9ru+VwoQ464DkaLEeRM08wJA==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, + "node_modules/buffer-image-size": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/buffer-image-size/-/buffer-image-size-0.6.4.tgz", + "integrity": "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/happy-dom": { + "version": "20.11.1", + "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.11.1.tgz", + "integrity": "sha512-XSt8tMzbW9ymE7687xztkO1ckR7qJNQ3LywY9vlYGhGi3zXrGBHuUo2Cl1ztZaICW+1eAGdkLbj6iwVqDT33kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": ">=20.0.0", + "@types/whatwg-mimetype": "^3.0.2", + "@types/ws": "^8.18.1", + "buffer-image-size": "^0.6.4", + "entities": "^7.0.1", + "whatwg-mimetype": "^3.0.0", + "ws": "^8.21.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/happy-dom/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/happy-dom/node_modules/whatwg-mimetype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", + "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/playwright": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/react": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.8" + } + }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/rolldown": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.3.tgz", + "integrity": "sha512-rn9wpmxplLf7NLNyCk9FyWh3FM43DbY8jOzCdEPzH7uflhTftRbCEpqi6Ly2osgoU8OwObtmavMbWLaWy4LX7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.143.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.2.3", + "@rolldown/binding-darwin-arm64": "1.2.3", + "@rolldown/binding-darwin-x64": "1.2.3", + "@rolldown/binding-freebsd-x64": "1.2.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.3", + "@rolldown/binding-linux-arm64-gnu": "1.2.3", + "@rolldown/binding-linux-arm64-musl": "1.2.3", + "@rolldown/binding-linux-ppc64-gnu": "1.2.3", + "@rolldown/binding-linux-s390x-gnu": "1.2.3", + "@rolldown/binding-linux-x64-gnu": "1.2.3", + "@rolldown/binding-linux-x64-musl": "1.2.3", + "@rolldown/binding-openharmony-arm64": "1.2.3", + "@rolldown/binding-win32-arm64-msvc": "1.2.3", + "@rolldown/binding-win32-x64-msvc": "1.2.3" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/typescript": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz", + "integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.1.tgz", + "integrity": "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.25", + "rolldown": "~1.2.1", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.4.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ws": { + "version": "8.21.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", + "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/src/Ludarium.Web/package.json b/src/Ludarium.Web/package.json new file mode 100644 index 0000000..c3d511a --- /dev/null +++ b/src/Ludarium.Web/package.json @@ -0,0 +1,40 @@ +{ + "name": "ludarium-web", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "typecheck": "tsc --noEmit", + "build": "tsc --noEmit && vite build", + "preview": "vite preview", + "test": "vitest run", + "test:a11y": "node e2e/accessibility.mjs", + "test:e2e:atlas": "node e2e/archive-atlas.mjs", + "test:e2e": "node e2e/workflows.mjs", + "test:e2e:vault": "node e2e/game-data-vault.mjs" + }, + "devDependencies": { + "@axe-core/playwright": "4.12.1", + "@testing-library/jest-dom": "7.0.0", + "@testing-library/react": "16.3.2", + "@types/react": "19.2.14", + "@types/react-dom": "19.2.3", + "@vitejs/plugin-react": "6.0.4", + "happy-dom": "20.11.1", + "playwright": "1.62.1", + "typescript": "6.0.2", + "vite": "8.2.1", + "vitest": "4.1.10" + }, + "dependencies": { + "@fontsource-variable/inter": "5.3.0", + "@fontsource-variable/manrope": "5.3.0", + "react": "19.2.8", + "react-dom": "19.2.8" + }, + "optionalDependencies": { + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0" + } +} diff --git a/src/Ludarium.Web/public/brand/PROVENANCE.md b/src/Ludarium.Web/public/brand/PROVENANCE.md new file mode 100644 index 0000000..d678da9 --- /dev/null +++ b/src/Ludarium.Web/public/brand/PROVENANCE.md @@ -0,0 +1,15 @@ +# Living Archive design assets + +These assets are local design inputs for the `design/higgsfield-living-archive` experiment. They are not fetched or generated at runtime. + +| File | Generator | Model | Generation job | Purpose | +| --- | --- | --- | --- | --- | +| `living-archive-core.webp` | Higgsfield | Z Image | `deabe3ac-65a5-4c0b-9054-82c3abb1e79c` | Home archive hero and restrained detail ambience | +| `living-archive-shelves.webp` | Higgsfield | Z Image | `217a52f9-d261-4a88-8f68-79af5804a516` | Library header atmosphere | +| `living-archive-veo-loop.mp4` | Google Flow | Veo 3.1 Lite | Project `a7e8bde1-04a2-4c73-a793-c77932df3db8`, media `f9ef785a-e23a-47b7-890a-2cb3122ef323` | Stable Home hero atmosphere | + +Both image outputs were generated on 2026-08-23 at 2048×1152, inspected manually, resized to 1600 px wide and encoded as WebP with FFmpeg. Prompts explicitly excluded people, text, logos, recognizable consoles, copyrighted characters and game screenshots. Raw PNG experiments and unused concepts are not shipped. + +The Google Flow output was generated on 2026-08-23 from `living-archive-core.webp` using 10 free daily Flow credits. Its prompt locked all camera movement and limited animation to local light, reflection and dust. The eight-second 1280×720 source was inspected as a one-frame-per-second contact sheet. FFmpeg removed the generated audio and concatenated the forward and reversed video into a seamless sixteen-second H.264 loop. The original Flow download is not shipped. + +The imagery is decorative. It must not communicate operational state on its own, and the interface remains complete when images are unavailable or motion is reduced. diff --git a/src/Ludarium.Web/public/brand/living-archive-core.webp b/src/Ludarium.Web/public/brand/living-archive-core.webp new file mode 100644 index 0000000..7f803b8 Binary files /dev/null and b/src/Ludarium.Web/public/brand/living-archive-core.webp differ diff --git a/src/Ludarium.Web/public/brand/living-archive-shelves.webp b/src/Ludarium.Web/public/brand/living-archive-shelves.webp new file mode 100644 index 0000000..90b753f Binary files /dev/null and b/src/Ludarium.Web/public/brand/living-archive-shelves.webp differ diff --git a/src/Ludarium.Web/public/brand/living-archive-veo-loop.mp4 b/src/Ludarium.Web/public/brand/living-archive-veo-loop.mp4 new file mode 100644 index 0000000..c9bc026 Binary files /dev/null and b/src/Ludarium.Web/public/brand/living-archive-veo-loop.mp4 differ diff --git a/src/Ludarium.Web/public/favicon.svg b/src/Ludarium.Web/public/favicon.svg new file mode 100644 index 0000000..6893eb1 --- /dev/null +++ b/src/Ludarium.Web/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/Ludarium.Web/public/icons.svg b/src/Ludarium.Web/public/icons.svg new file mode 100644 index 0000000..e952219 --- /dev/null +++ b/src/Ludarium.Web/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Ludarium.Web/public/n64-player.html b/src/Ludarium.Web/public/n64-player.html new file mode 100644 index 0000000..aec4634 --- /dev/null +++ b/src/Ludarium.Web/public/n64-player.html @@ -0,0 +1,34 @@ + + + + + + + Ludarium Nintendo 64 player + + + + + +
Preparing the isolated Nintendo 64 player…The ROM is read from its read-only library only after this explicit play action.
+ + + diff --git a/src/Ludarium.Web/public/n64-player.js b/src/Ludarium.Web/public/n64-player.js new file mode 100644 index 0000000..82accb8 --- /dev/null +++ b/src/Ludarium.Web/public/n64-player.js @@ -0,0 +1,177 @@ +(() => { + "use strict"; + const MAX_ROM_BYTES = 512 * 1024 * 1024; + const MAX_STATE_BYTES = 64 * 1024 * 1024; + const AUDIO_BUFFER_SIZE = 1024; + const status = document.getElementById("status"); + const canvas = document.getElementById("canvas"); + const saveButton = document.getElementById("save-state"); + const loadButton = document.getElementById("load-state"); + const fullscreenButton = document.getElementById("fullscreen"); + const query = new URLSearchParams(location.search); + const fragment = new URLSearchParams(location.hash.slice(1)); + const session = query.get("session"); + const core = query.get("core"); + const token = fragment.get("token"); + history.replaceState(null, "", location.pathname + location.search); + + let restoredState = null; + let started = false; + let audio = null; + + const setDetail = message => { + const detail = status?.querySelector("span"); + if (detail) detail.textContent = message; + }; + const fail = message => { + if (!status) return; + status.classList.add("error"); + status.replaceChildren(); + const title = document.createElement("b"); + const detail = document.createElement("span"); + title.textContent = "The Nintendo 64 game could not start."; + detail.textContent = String(message); + status.append(title, detail); + }; + const scopedUrl = suffix => `/api/v1/play-sessions/${session}/${suffix}?token=${encodeURIComponent(token)}`; + + const readBytes = async (url, maximum, optional = false) => { + const response = await fetch(url, { cache: "no-store" }); + if (optional && (response.status === 204 || response.status === 404)) { + await response.arrayBuffer(); + return null; + } + if (!response.ok) throw new Error(`The scoped content request returned HTTP ${response.status}.`); + const declared = Number(response.headers.get("content-length") || 0); + if (declared > maximum) throw new Error("The scoped content exceeds the player limit."); + const bytes = new Uint8Array(await response.arrayBuffer()); + if (!bytes.byteLength || bytes.byteLength > maximum) throw new Error("The scoped content is empty or exceeds the player limit."); + return bytes; + }; + + const preserveState = async bytes => { + if (!bytes?.byteLength || bytes.byteLength > MAX_STATE_BYTES) throw new Error("The savestate is empty or oversized."); + const response = await fetch(scopedUrl("data/SaveState"), { + method: "PUT", headers: { "Content-Type": "application/octet-stream" }, body: bytes + }); + if (!response.ok) throw new Error(`Ludarium could not preserve the savestate (HTTP ${response.status}).`); + restoredState = new Uint8Array(bytes); + loadButton.disabled = false; + document.body.dataset.stateSaved = "true"; + }; + + const writeConfig = () => { + const joy = [12, 13, 14, 15, 0, 2, 9, 4, 6, 5, 11, -1, -1, -1, -1]; + const keyboard = ["b", "n", "y", "h", "Enter", "i", "k", "j", "l", "a", "q", "e", "s", "d", "`", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"]; + const flags = [0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 1, 0]; + window.FS.writeFile("config.txt", [...joy, ...keyboard, ...flags].join("\r\n") + "\r\n"); + window.FS.writeFile("cheat.txt", ""); + }; + + const initializeAudio = module => { + const AudioContext = window.AudioContext || window.webkitAudioContext; + if (!AudioContext) return null; + const context = new AudioContext({ latencyHint: "interactive", sampleRate: 44100 }); + const gain = context.createGain(); + gain.gain.value = 0.5; + gain.connect(context.destination); + const source = context.createScriptProcessor(AUDIO_BUFFER_SIZE, 2, 2); + const ring = new Int16Array(module.HEAP16.buffer, module._neilGetSoundBufferResampledAddress(), 64000); + let read = 0; + source.onaudioprocess = event => { + const left = event.outputBuffer.getChannelData(0); + const right = event.outputBuffer.getChannelData(1); + const write = module._neilGetAudioWritePosition(); + for (let index = 0; index < AUDIO_BUFFER_SIZE; index++) { + if (read !== write) { + left[index] = ring[read] / 32768; + right[index] = ring[read + 1] / 32768; + read = (read + 2) % 64000; + } else left[index] = right[index] = 0; + } + module._neil_set_buffer_remaining?.((write - read + 64000) % 64000); + }; + source.connect(gain); + const resume = () => context.resume().catch(() => {}); + window.addEventListener("pointerdown", resume, { passive: true }); + window.addEventListener("keydown", resume, { passive: true }); + return { context, source, gain }; + }; + + const applyState = () => { + if (!started || !restoredState) return; + window.FS.writeFile("/savestate.gz", restoredState); + window.Module._neil_unserialize(); + document.body.dataset.restoredState = "true"; + }; + + const markStarted = () => { + if (started) return; + started = true; + document.body.dataset.started = "true"; + saveButton.disabled = false; + loadButton.disabled = !restoredState; + status?.remove(); + canvas.focus(); + if (restoredState) setTimeout(applyState, 250); + }; + + if (!session || !/^[0-9a-f-]{36}$/i.test(session) || core !== "n64wasm" || !token) { + fail("This play capability is incomplete or expired. Return to Ludarium and start a new session."); + return; + } + + const start = async () => { + setDetail("Loading the verified runtime, ROM and compatible Ludarium savestate…"); + const [rom, assets, state] = await Promise.all([ + readBytes(scopedUrl("content"), MAX_ROM_BYTES), + readBytes("/n64wasm/assets.zip", 4 * 1024 * 1024), + readBytes(scopedUrl("data/SaveState"), MAX_STATE_BYTES, true) + ]); + restoredState = state; + + window.myApp = { + rivetsData: { inputController: { updateMobileControls() {} }, useZasCMobile: false }, + localCallback() {}, fullscreen() { canvas.requestFullscreen?.(); }, + SaveStateEvent() { + try { preserveState(window.FS.readFile("/savestate.gz")).catch(error => fail(error.message)); } + catch (error) { fail(error instanceof Error ? error.message : "The savestate could not be read."); } + }, + ExportEepEvent() {}, ExportSraEvent() {}, ExportFlaEvent() {}, + saveCloud() { window.Module._neil_serialize(); }, loadCloud: applyState + }; + window.Module = { + noInitialRun: true, + canvas, + locateFile: path => `/n64wasm/${path}`, + print(text) { + console.log(text); + if (String(text).includes("Starting R4300 emulator: Cached Interpreter")) markStarted(); + }, + printErr(text) { console.warn(text); }, + onAbort(reason) { fail(`The N64Wasm runtime aborted: ${reason || "unknown error"}.`); }, + onRuntimeInitialized() { + try { + window.FS.writeFile("assets.zip", assets); + window.FS.writeFile("custom.v64", rom); + writeConfig(); + audio = initializeAudio(window.Module); + window.Module.callMain(["custom.v64"]); + window.LudariumN64 = { module: window.Module, fs: window.FS, audio, romBytes: rom.byteLength }; + } catch (error) { + fail(error instanceof Error ? error.message : "The N64Wasm runtime could not initialize."); + } + } + }; + const loader = document.createElement("script"); + loader.src = "/n64wasm/n64wasm.js"; + loader.onerror = () => fail("The bundled N64Wasm runtime is unavailable."); + document.body.appendChild(loader); + setTimeout(() => { if (!started) fail("The N64 core did not start before the 90 second deadline."); }, 90_000); + }; + + saveButton.addEventListener("click", () => window.myApp?.saveCloud()); + loadButton.addEventListener("click", applyState); + fullscreenButton.addEventListener("click", () => canvas.requestFullscreen?.()); + start().catch(error => fail(error instanceof Error ? error.message : "The Nintendo 64 runtime could not initialize.")); +})(); diff --git a/src/Ludarium.Web/public/platforms/3ds.png b/src/Ludarium.Web/public/platforms/3ds.png new file mode 100644 index 0000000..a0fc3b4 Binary files /dev/null and b/src/Ludarium.Web/public/platforms/3ds.png differ diff --git a/src/Ludarium.Web/public/platforms/README.md b/src/Ludarium.Web/public/platforms/README.md new file mode 100644 index 0000000..86cbea3 --- /dev/null +++ b/src/Ludarium.Web/public/platforms/README.md @@ -0,0 +1,35 @@ +# Platform hardware imagery + +The platform overview uses locally bundled hardware photographs and interface illustrations so the interface remains fully self-hosted and does not leak browsing data to third-party image hosts. + +Unless noted otherwise, the photographs are by Evan-Amos and were released into the public domain on Wikimedia Commons. The source pages remain the authoritative license records. + +| Asset | Subject | Source | License | +|---|---|---|---| +| `switch.jpg` | Nintendo Switch | https://commons.wikimedia.org/wiki/File:Nintendo-Switch-Console-Docked-wJoyConRB.jpg | Public domain | +| `n64.png` | Nintendo 64 | https://commons.wikimedia.org/wiki/File:N64-Console-Set.png | Public domain | +| `psx.png` | PlayStation | https://commons.wikimedia.org/wiki/File:PSX-Console-wController.png | Public domain | +| `ps2.png` | PlayStation 2 | https://commons.wikimedia.org/wiki/File:PS2-Versions.png | Public domain | +| `ps5.png` | PlayStation 5 | https://commons.wikimedia.org/wiki/File:PlayStation_5_and_DualSense_with_transparent_background.png | CC BY-SA 4.0; original by Osh33m, retouched by Soberian | +| `psp.png` | PlayStation Portable | https://commons.wikimedia.org/wiki/File:Sony-PSP-1000-Body.png | Public domain | +| `psvita.png` | PlayStation Vita | https://commons.wikimedia.org/wiki/File:PlayStation-Vita-1101-FL.png | Public domain | +| `nes.png` | Nintendo Entertainment System | https://commons.wikimedia.org/wiki/File:NES-Console-Set.png | Public domain | +| `snes.jpg` | Super Nintendo | https://commons.wikimedia.org/wiki/File:SNES-Mod1-Console-Set.jpg | Public domain | +| `gb.jpg` | Game Boy | https://commons.wikimedia.org/wiki/File:Game-Boy-Original.jpg | Public domain | +| `gbc.jpg` | Game Boy Color | https://commons.wikimedia.org/wiki/File:Nintendo-Game-Boy-Color-FL.jpg | Public domain | +| `gba.png` | Game Boy Advance | https://commons.wikimedia.org/wiki/File:Nintendo-Game-Boy-Advance-Milky-Blue-FL.png | Public domain | +| `nds.png` | Nintendo DS Lite | https://commons.wikimedia.org/wiki/File:Nintendo-DS-Lite-w-stylus.png | Public domain | +| `3ds.png` | Nintendo 3DS | https://commons.wikimedia.org/wiki/File:Nintendo-3DS-AquaOpen.png | Public domain | +| `wii.png` | Nintendo Wii | https://commons.wikimedia.org/wiki/File:Wii_console.png | Public domain | +| `windows.jpg` | Gaming PC | https://commons.wikimedia.org/wiki/File:Gaming_pc.jpg | CC0 1.0 | + +Images are resized for web delivery without changing their subject matter. +# Transparent hardware cutouts + +`switch-cutout.png`, `gb-cutout-v2.png`, `gbc-cutout.png` and `snes-cutout-v2.png` are non-destructive background-extraction derivatives of the adjacent source photographs. They were produced with the built-in OpenAI image editing tool on 2026-08-23. Only the studio background was removed; the original source files remain unchanged for provenance and rollback. + +## Generated archive portraits + +`ps3-cutout.png`, `ps4-cutout.png`, `gamecube-cutout.png`, `wiiu-cutout.png`, `xbox-cutout.png`, `xbox360-cutout.png`, `xboxone-cutout.png`, and `xboxseries-cutout.png` are newly generated, unbranded hardware portraits made with the built-in OpenAI image tool on 2026-08-23. Each prompt requested a platform-recognizable, three-quarter product illustration with restrained archive lighting, no text or logos, and a genuine transparent background. These local assets replace the misleading Gaming PC fallback for supported console identifiers. + +`generic-platform.svg` is the intentionally neutral fallback for a future or unknown platform; it never implies that unknown hardware is a Windows PC. diff --git a/src/Ludarium.Web/public/platforms/gamecube-cutout.png b/src/Ludarium.Web/public/platforms/gamecube-cutout.png new file mode 100644 index 0000000..9d64ed0 Binary files /dev/null and b/src/Ludarium.Web/public/platforms/gamecube-cutout.png differ diff --git a/src/Ludarium.Web/public/platforms/gb-cutout-v2.png b/src/Ludarium.Web/public/platforms/gb-cutout-v2.png new file mode 100644 index 0000000..6414614 Binary files /dev/null and b/src/Ludarium.Web/public/platforms/gb-cutout-v2.png differ diff --git a/src/Ludarium.Web/public/platforms/gb.jpg b/src/Ludarium.Web/public/platforms/gb.jpg new file mode 100644 index 0000000..35791b7 Binary files /dev/null and b/src/Ludarium.Web/public/platforms/gb.jpg differ diff --git a/src/Ludarium.Web/public/platforms/gba.png b/src/Ludarium.Web/public/platforms/gba.png new file mode 100644 index 0000000..244972c Binary files /dev/null and b/src/Ludarium.Web/public/platforms/gba.png differ diff --git a/src/Ludarium.Web/public/platforms/gbc-cutout.png b/src/Ludarium.Web/public/platforms/gbc-cutout.png new file mode 100644 index 0000000..8532d5f Binary files /dev/null and b/src/Ludarium.Web/public/platforms/gbc-cutout.png differ diff --git a/src/Ludarium.Web/public/platforms/gbc.jpg b/src/Ludarium.Web/public/platforms/gbc.jpg new file mode 100644 index 0000000..5a26177 Binary files /dev/null and b/src/Ludarium.Web/public/platforms/gbc.jpg differ diff --git a/src/Ludarium.Web/public/platforms/generic-platform.svg b/src/Ludarium.Web/public/platforms/generic-platform.svg new file mode 100644 index 0000000..af88267 --- /dev/null +++ b/src/Ludarium.Web/public/platforms/generic-platform.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/src/Ludarium.Web/public/platforms/n64.png b/src/Ludarium.Web/public/platforms/n64.png new file mode 100644 index 0000000..7f7b8d0 Binary files /dev/null and b/src/Ludarium.Web/public/platforms/n64.png differ diff --git a/src/Ludarium.Web/public/platforms/nds.png b/src/Ludarium.Web/public/platforms/nds.png new file mode 100644 index 0000000..3d286f2 Binary files /dev/null and b/src/Ludarium.Web/public/platforms/nds.png differ diff --git a/src/Ludarium.Web/public/platforms/nes.png b/src/Ludarium.Web/public/platforms/nes.png new file mode 100644 index 0000000..c2e4cd8 Binary files /dev/null and b/src/Ludarium.Web/public/platforms/nes.png differ diff --git a/src/Ludarium.Web/public/platforms/ps2.png b/src/Ludarium.Web/public/platforms/ps2.png new file mode 100644 index 0000000..6ac41ce Binary files /dev/null and b/src/Ludarium.Web/public/platforms/ps2.png differ diff --git a/src/Ludarium.Web/public/platforms/ps3-cutout.png b/src/Ludarium.Web/public/platforms/ps3-cutout.png new file mode 100644 index 0000000..aa5fe67 Binary files /dev/null and b/src/Ludarium.Web/public/platforms/ps3-cutout.png differ diff --git a/src/Ludarium.Web/public/platforms/ps4-cutout.png b/src/Ludarium.Web/public/platforms/ps4-cutout.png new file mode 100644 index 0000000..ae3da0a Binary files /dev/null and b/src/Ludarium.Web/public/platforms/ps4-cutout.png differ diff --git a/src/Ludarium.Web/public/platforms/ps5.png b/src/Ludarium.Web/public/platforms/ps5.png new file mode 100644 index 0000000..b373d54 Binary files /dev/null and b/src/Ludarium.Web/public/platforms/ps5.png differ diff --git a/src/Ludarium.Web/public/platforms/psp.png b/src/Ludarium.Web/public/platforms/psp.png new file mode 100644 index 0000000..4ec1331 Binary files /dev/null and b/src/Ludarium.Web/public/platforms/psp.png differ diff --git a/src/Ludarium.Web/public/platforms/psvita.png b/src/Ludarium.Web/public/platforms/psvita.png new file mode 100644 index 0000000..789f91b Binary files /dev/null and b/src/Ludarium.Web/public/platforms/psvita.png differ diff --git a/src/Ludarium.Web/public/platforms/psx.png b/src/Ludarium.Web/public/platforms/psx.png new file mode 100644 index 0000000..835033b Binary files /dev/null and b/src/Ludarium.Web/public/platforms/psx.png differ diff --git a/src/Ludarium.Web/public/platforms/snes-cutout-v2.png b/src/Ludarium.Web/public/platforms/snes-cutout-v2.png new file mode 100644 index 0000000..0d9a780 Binary files /dev/null and b/src/Ludarium.Web/public/platforms/snes-cutout-v2.png differ diff --git a/src/Ludarium.Web/public/platforms/snes.jpg b/src/Ludarium.Web/public/platforms/snes.jpg new file mode 100644 index 0000000..42dad5c Binary files /dev/null and b/src/Ludarium.Web/public/platforms/snes.jpg differ diff --git a/src/Ludarium.Web/public/platforms/switch-cutout.png b/src/Ludarium.Web/public/platforms/switch-cutout.png new file mode 100644 index 0000000..53d250a Binary files /dev/null and b/src/Ludarium.Web/public/platforms/switch-cutout.png differ diff --git a/src/Ludarium.Web/public/platforms/switch.jpg b/src/Ludarium.Web/public/platforms/switch.jpg new file mode 100644 index 0000000..7eda291 Binary files /dev/null and b/src/Ludarium.Web/public/platforms/switch.jpg differ diff --git a/src/Ludarium.Web/public/platforms/wii.png b/src/Ludarium.Web/public/platforms/wii.png new file mode 100644 index 0000000..a7e9ce3 Binary files /dev/null and b/src/Ludarium.Web/public/platforms/wii.png differ diff --git a/src/Ludarium.Web/public/platforms/wiiu-cutout.png b/src/Ludarium.Web/public/platforms/wiiu-cutout.png new file mode 100644 index 0000000..142645b Binary files /dev/null and b/src/Ludarium.Web/public/platforms/wiiu-cutout.png differ diff --git a/src/Ludarium.Web/public/platforms/windows.jpg b/src/Ludarium.Web/public/platforms/windows.jpg new file mode 100644 index 0000000..3f61ddb Binary files /dev/null and b/src/Ludarium.Web/public/platforms/windows.jpg differ diff --git a/src/Ludarium.Web/public/platforms/xbox-cutout.png b/src/Ludarium.Web/public/platforms/xbox-cutout.png new file mode 100644 index 0000000..7b29323 Binary files /dev/null and b/src/Ludarium.Web/public/platforms/xbox-cutout.png differ diff --git a/src/Ludarium.Web/public/platforms/xbox360-cutout.png b/src/Ludarium.Web/public/platforms/xbox360-cutout.png new file mode 100644 index 0000000..fda1c69 Binary files /dev/null and b/src/Ludarium.Web/public/platforms/xbox360-cutout.png differ diff --git a/src/Ludarium.Web/public/platforms/xboxone-cutout.png b/src/Ludarium.Web/public/platforms/xboxone-cutout.png new file mode 100644 index 0000000..624cfce Binary files /dev/null and b/src/Ludarium.Web/public/platforms/xboxone-cutout.png differ diff --git a/src/Ludarium.Web/public/platforms/xboxseries-cutout.png b/src/Ludarium.Web/public/platforms/xboxseries-cutout.png new file mode 100644 index 0000000..390b863 Binary files /dev/null and b/src/Ludarium.Web/public/platforms/xboxseries-cutout.png differ diff --git a/src/Ludarium.Web/public/player.html b/src/Ludarium.Web/public/player.html new file mode 100644 index 0000000..db9304c --- /dev/null +++ b/src/Ludarium.Web/public/player.html @@ -0,0 +1,23 @@ + + + + + + + Ludarium browser player + + + +
+
Preparing the isolated player…The ROM is streamed from its read-only library only after this explicit play action.
+ + + diff --git a/src/Ludarium.Web/public/player.js b/src/Ludarium.Web/public/player.js new file mode 100644 index 0000000..b1a6c38 --- /dev/null +++ b/src/Ludarium.Web/public/player.js @@ -0,0 +1,136 @@ +(() => { + "use strict"; + const status = document.getElementById("status"); + const query = new URLSearchParams(location.search); + const fragment = new URLSearchParams(location.hash.slice(1)); + const session = query.get("session"); + const core = query.get("core"); + const token = fragment.get("token"); + history.replaceState(null, "", location.pathname + location.search); + + const fail = (message) => { + status.classList.add("error"); + status.replaceChildren(); + const title = document.createElement("b"); + const detail = document.createElement("span"); + title.textContent = "The game could not start."; + detail.textContent = String(message); + status.append(title, detail); + }; + if (!session || !/^[0-9a-f-]{36}$/i.test(session) || !core || !/^[a-z0-9_]+$/i.test(core) || !token) { + fail("This play capability is incomplete or expired. Return to Ludarium and start a new session."); + return; + } + + window.EJS_player = "#game"; + window.EJS_core = core; + window.EJS_gameName = (query.get("name") || "Ludarium game").replace(/[^a-z0-9 ._()-]/gi, "").slice(0, 120); + window.EJS_gameUrl = `/api/v1/play-sessions/${session}/content?token=${encodeURIComponent(token)}`; + if ((core === "pcsx_rearmed" || core === "mednafen_psx_hw") && query.get("firmware") === "1") + window.EJS_biosUrl = `/api/v1/play-sessions/${session}/firmware?token=${encodeURIComponent(token)}`; + window.EJS_pathtodata = "/emulatorjs/data/"; + window.EJS_color = "#79a9ff"; + window.EJS_backgroundColor = "#07090d"; + window.EJS_language = "en-US"; + window.EJS_disableAutoLang = true; + window.EJS_startOnLoaded = true; + window.EJS_threads = core === "ppsspp"; + window.EJS_alignStartButton = "center"; + window.EJS_defaultOptions = { "save-state-location": "browser", rewindEnabled: "enabled" }; + window.EJS_Buttons = { exitEmulation: false }; + window.EJS_onGameError = (error) => fail(error instanceof Error ? error.message : "The emulator rejected this file."); + let preserveQueue = Promise.resolve(); + const preserve = async (kind, data) => { + if (!data || data.byteLength === 0) return; + try { + const response = await fetch(`/api/v1/play-sessions/${session}/data/${kind}?token=${encodeURIComponent(token)}`, { + method: "PUT", headers: { "Content-Type": "application/octet-stream" }, body: data + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + } catch (error) { + console.error(`Ludarium could not preserve ${kind}`, error); + } + }; + const enqueuePreserve = (kind, data) => { + preserveQueue = preserveQueue.then(() => preserve(kind, data)); + }; + window.EJS_onSaveSave = ({ save }) => enqueuePreserve("Save", save); + window.EJS_onSaveState = ({ state }) => enqueuePreserve("SaveState", state); + + const restore = async (kind) => { + const response = await fetch(`/api/v1/play-sessions/${session}/data/${kind}?token=${encodeURIComponent(token)}`, { + headers: { Accept: "application/octet-stream" }, cache: "no-store" + }); + if (response.status === 204 || response.status === 404) { + await response.arrayBuffer(); + return null; + } + if (!response.ok) throw new Error(`Ludarium could not verify the stored ${kind.toLowerCase()} (HTTP ${response.status}).`); + const length = Number(response.headers.get("content-length") || 0); + if (length > 64 * 1024 * 1024) throw new Error(`The stored ${kind.toLowerCase()} exceeds the restore limit.`); + const bytes = new Uint8Array(await response.arrayBuffer()); + if (!bytes.byteLength || bytes.byteLength > 64 * 1024 * 1024) + throw new Error(`The stored ${kind.toLowerCase()} is empty or exceeds the restore limit.`); + return bytes; + }; + const cheats = async () => { + const response = await fetch(`/api/v1/play-sessions/${session}/cheats?token=${encodeURIComponent(token)}`, { + headers: { Accept: "application/json" }, cache: "no-store" + }); + // Draining the empty body ends the request; abandoning it unread makes the browser cancel the + // stream, which surfaces as an aborted request rather than the "no cheats here" this is. + if (response.status === 204 || response.status === 404) { await response.arrayBuffer(); return []; } + if (!response.ok) throw new Error(`Cheat catalog HTTP ${response.status}`); + const result = await response.json(); + return Array.isArray(result.cheats) ? result.cheats + .filter((cheat) => typeof cheat.name === "string" && typeof cheat.code === "string") + .slice(0, 500).map((cheat) => [cheat.name, cheat.code]) : []; + }; + const applySave = (manager, bytes) => { + const path = manager.getSaveFilePath(); + if (!path || !path.startsWith("/")) throw new Error("The selected core did not provide a contained save location."); + const parts = path.split("/"); + let directory = ""; + for (let index = 0; index < parts.length - 1; index++) { + if (!parts[index]) continue; + directory += `/${parts[index]}`; + if (!manager.FS.analyzePath(directory).exists) manager.FS.mkdir(directory); + } + if (manager.FS.analyzePath(path).exists) manager.FS.unlink(path); + manager.FS.writeFile(path, bytes); + manager.loadSaveFiles(); + }; + + const start = async () => { + const detail = status.querySelector("span"); + if (detail) detail.textContent = "Checking Ludarium for a compatible save and savestate…"; + const [savedGame, savedState, matchedCheats] = await Promise.all([ + restore("Save"), restore("SaveState"), cheats().catch((error) => { console.warn("Ludarium cheat lookup unavailable", error); return []; }) + ]); + window.EJS_cheats = matchedCheats; + document.body.dataset.cheatCount = String(matchedCheats.length); + window.EJS_onGameStart = () => { + try { + const manager = window.EJS_emulator?.gameManager; + if (!manager) throw new Error("The emulator did not expose its verified restore interface."); + if (savedGame) { + applySave(manager, savedGame); + document.body.dataset.restoredSave = "true"; + } + if (savedState) { + manager.loadState(savedState); + document.body.dataset.restoredState = "true"; + } + document.body.dataset.restoreComplete = "true"; + status.remove(); + } catch (error) { + fail(error instanceof Error ? error.message : "Stored game data could not be restored safely."); + } + }; + const loader = document.createElement("script"); + loader.src = "/emulatorjs/data/loader.js"; + loader.onerror = () => fail("The bundled EmulatorJS runtime is unavailable."); + document.body.appendChild(loader); + }; + start().catch((error) => fail(error instanceof Error ? error.message : "Stored game data could not be checked.")); +})(); diff --git a/src/Ludarium.Web/public/ps2-player.html b/src/Ludarium.Web/public/ps2-player.html new file mode 100644 index 0000000..995ed4b --- /dev/null +++ b/src/Ludarium.Web/public/ps2-player.html @@ -0,0 +1,25 @@ + + + + + + + Ludarium PS2 player + + + + +
Preparing the isolated PS2 player…The disc is read in bounded ranges from its read-only library.
+ + + diff --git a/src/Ludarium.Web/public/ps2-player.js b/src/Ludarium.Web/public/ps2-player.js new file mode 100644 index 0000000..677b3dc --- /dev/null +++ b/src/Ludarium.Web/public/ps2-player.js @@ -0,0 +1,101 @@ +import Play from "/playjs/Play.js"; + +const status = document.getElementById("status"); +const canvas = document.getElementById("outputCanvas"); +const query = new URLSearchParams(location.search); +const fragment = new URLSearchParams(location.hash.slice(1)); +const session = query.get("session"); +const core = query.get("core"); +const token = fragment.get("token"); +history.replaceState(null, "", location.pathname + location.search); + +const fail = (message) => { + status.classList.add("error"); + status.replaceChildren(); + const title = document.createElement("b"); + const detail = document.createElement("span"); + title.textContent = "The PS2 game could not start."; + detail.textContent = String(message); + status.append(title, detail); +}; + +if (!session || !/^[0-9a-f-]{36}$/i.test(session) || core !== "playjs" || !token) { + fail("This play capability is incomplete or expired. Return to Ludarium and start a new session."); +} else { + const contentUrl = `/api/v1/play-sessions/${session}/content?token=${encodeURIComponent(token)}`; + + class RemoteDiscImage { + constructor(module, size) { + this.module = module; + this.size = size; + this.pending = 0; + this.failed = null; + } + read(destination, offset, size) { + const end = offset + size; + if (!Number.isSafeInteger(destination) || destination < 0 || + !Number.isSafeInteger(offset) || offset < 0 || + !Number.isSafeInteger(size) || size <= 0 || size > 32 * 1024 * 1024 || + end > this.size || destination + size > this.module.HEAPU8.byteLength) { + this.failed = new Error("Play!.js requested an invalid or oversized disc range."); + fail(this.failed.message); + return; + } + this.pending += 1; + fetch(contentUrl, { headers: { Range: `bytes=${offset}-${offset + size - 1}` }, cache: "no-store" }) + .then(async response => { + if (response.status !== 206) throw new Error(`Disc range returned HTTP ${response.status}.`); + const bytes = new Uint8Array(await response.arrayBuffer()); + if (bytes.byteLength !== size) throw new Error("Disc range length did not match the emulator request."); + this.module.HEAPU8.set(bytes, destination); + }) + .catch(error => { + this.failed = error; + fail(error instanceof Error ? error.message : "The disc stream failed."); + }) + .finally(() => { this.pending -= 1; }); + } + getFileSize() { return this.size; } + isDone() { return this.pending === 0; } + } + + const start = async () => { + const probe = await fetch(contentUrl, { headers: { Range: "bytes=0-0" }, cache: "no-store" }); + if (probe.status !== 206) throw new Error(`Disc capability returned HTTP ${probe.status}.`); + await probe.arrayBuffer(); + const match = /^bytes 0-0\/(\d+)$/.exec(probe.headers.get("content-range") || ""); + const size = Number(match?.[1]); + if (!Number.isSafeInteger(size) || size <= 0 || size > 8 * 1024 * 1024 * 1024) + throw new Error("The disc size is invalid or exceeds the PS2 player limit."); + + const module = await Play({ + canvas, + locateFile: path => `/playjs/${path}`, + mainScriptUrlOrBlob: "/playjs/Play.js" + }); + module.FS.mkdir("/work"); + const disc = new RemoteDiscImage(module, size); + module.discImageDevice = disc; + module.ccall("initVm", "", [], []); + module.bootDiscImage((query.get("name") || "Ludarium PS2 game") + ".iso"); + window.LudariumPlay = { module, disc, size }; + + const deadline = performance.now() + 90_000; + const observe = () => { + if (disc.failed) return; + const frames = Number(module.getFrames?.() || 0); + if (frames > 0) { + document.body.dataset.started = "true"; + document.body.dataset.frames = String(frames); + status.remove(); + canvas.focus(); + return; + } + if (performance.now() >= deadline) return fail("The Play!.js core did not produce a frame before the startup deadline."); + requestAnimationFrame(observe); + }; + observe(); + }; + + start().catch(error => fail(error instanceof Error ? error.message : "The PS2 runtime could not initialize.")); +} diff --git a/src/Ludarium.Web/src/api/client.test.ts b/src/Ludarium.Web/src/api/client.test.ts new file mode 100644 index 0000000..816d319 --- /dev/null +++ b/src/Ludarium.Web/src/api/client.test.ts @@ -0,0 +1,46 @@ +// @vitest-environment happy-dom +import { afterEach, describe, expect, it } from "vitest"; +import { clearToken, readToken, storeToken, tokenKey } from "./client"; + +/** + * Where the administrator token lives. + * + * The default is this tab only, so closing it signs out. Staying signed in is a deliberate choice on + * a trusted device, and it must be the only thing that ever writes the token to durable storage. + */ +describe("administrator token storage", () => { + afterEach(() => clearToken()); + + it("keeps the token to one tab by default", () => { + storeToken("secret", false); + + expect(sessionStorage.getItem(tokenKey)).toBe("secret"); + expect(localStorage.getItem(tokenKey)).toBeNull(); + expect(readToken()).toBe("secret"); + }); + + it("remembers the token on a device only when asked", () => { + storeToken("secret", true); + + expect(localStorage.getItem(tokenKey)).toBe("secret"); + expect(sessionStorage.getItem(tokenKey)).toBeNull(); + expect(readToken()).toBe("secret"); + }); + + it("never leaves a copy behind when the choice changes", () => { + storeToken("secret", true); + storeToken("secret", false); + + expect(localStorage.getItem(tokenKey)).toBeNull(); + expect(sessionStorage.getItem(tokenKey)).toBe("secret"); + }); + + it("signing out clears both stores", () => { + storeToken("secret", true); + clearToken(); + + expect(readToken()).toBeNull(); + expect(localStorage.getItem(tokenKey)).toBeNull(); + expect(sessionStorage.getItem(tokenKey)).toBeNull(); + }); +}); diff --git a/src/Ludarium.Web/src/api/client.ts b/src/Ludarium.Web/src/api/client.ts new file mode 100644 index 0000000..e3ec4d2 --- /dev/null +++ b/src/Ludarium.Web/src/api/client.ts @@ -0,0 +1,137 @@ +export const tokenKey = "ludarium.adminToken"; + +/** + * Where the administrator token is kept. + * + * The default is this tab only, so closing it signs out. An operator can deliberately choose to stay + * signed in on a device they trust; that choice is theirs to make and to reverse, and it is the only + * thing that ever writes the token to durable storage. + */ +export function readToken() { + return sessionStorage.getItem(tokenKey) ?? localStorage.getItem(tokenKey); +} + +export function storeToken(token: string, remember: boolean) { + clearToken(); + (remember ? localStorage : sessionStorage).setItem(tokenKey, token); +} + +export function clearToken() { + sessionStorage.removeItem(tokenKey); + localStorage.removeItem(tokenKey); +} +export async function api( + path: string, + init?: RequestInit, +): Promise { + const token = readToken(); + const response = await fetch("/api/v1" + path, { + ...init, + headers: { + "Content-Type": "application/json", + ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...init?.headers, + }, + }); + if (response.status === 401) { + clearToken(); + dispatchEvent(new Event("ludarium:unauthorized")); + } + if (!response.ok) { + const body = await response + .json() + .catch(() => ({ title: response.statusText })); + throw new Error(body.detail || body.title || response.statusText); + } + if (response.status === 202 || response.status === 204) return undefined as T; + return response.json(); +} +export async function apiBlob(path: string, signal?: AbortSignal) { + const token = readToken(); + const response = await fetch("/api/v1" + path, { + signal, + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }); + if (response.status === 401) { + clearToken(); + dispatchEvent(new Event("ludarium:unauthorized")); + } + if (!response.ok || response.status === 204) { + if (response.status === 404 || response.status === 204) return null; + throw new Error(response.statusText || "The request failed."); + } + return response.blob(); +} +export async function apiDownload(path: string, filename: string) { + const blob = await apiBlob(path); + if (!blob) throw new Error("The export was empty."); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = filename; + anchor.click(); + setTimeout(() => URL.revokeObjectURL(url), 0); +} +export async function apiUpload(path: string, file: File, headers: HeadersInit = {}): Promise { + const token = readToken(); + const response = await fetch("/api/v1" + path, { + method: "PUT", + body: file, + headers: { + "Content-Type": file.type || "application/octet-stream", + ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...headers, + }, + }); + if (response.status === 401) { + clearToken(); + dispatchEvent(new Event("ludarium:unauthorized")); + } + if (!response.ok) { + const body = await response.json().catch(() => ({ title: response.statusText })); + throw new Error(body.detail || body.title || response.statusText); + } + return response.json(); +} +export const bytes = (n: number) => + n >= 1e12 + ? `${(n / 1e12).toFixed(2)} TB` + : n >= 1e9 + ? `${(n / 1e9).toFixed(1)} GB` + : n >= 1e6 + ? `${(n / 1e6).toFixed(1)} MB` + : n >= 1e3 + ? `${(n / 1e3).toFixed(1)} KB` + : `${n} B`; +export const platformNames: Record = { + windows: "Windows PC", + psx: "PlayStation", + ps2: "PlayStation 2", + ps3: "PlayStation 3", + ps4: "PlayStation 4", + ps5: "PlayStation 5", + psp: "PlayStation Portable", + psvita: "PlayStation Vita", + nes: "Nintendo Entertainment System", + snes: "Super Nintendo", + n64: "Nintendo 64", + gb: "Game Boy", + gbc: "Game Boy Color", + gba: "Game Boy Advance", + nds: "Nintendo DS", + "3ds": "Nintendo 3DS", + gamecube: "Nintendo GameCube", + wii: "Nintendo Wii", + wiiu: "Nintendo Wii U", + "wii-u": "Nintendo Wii U", + switch: "Nintendo Switch", + xbox: "Xbox", + xbox360: "Xbox 360", + "xbox-360": "Xbox 360", + xboxone: "Xbox One", + "xbox-one": "Xbox One", + xboxseries: "Xbox Series X|S", + "xbox-series": "Xbox Series X|S", +}; +export const platformName = (id: string | null) => + id ? platformNames[id] || id.toUpperCase() : "Unknown"; diff --git a/src/Ludarium.Web/src/api/nativePlayers.ts b/src/Ludarium.Web/src/api/nativePlayers.ts new file mode 100644 index 0000000..613e9b3 --- /dev/null +++ b/src/Ludarium.Web/src/api/nativePlayers.ts @@ -0,0 +1,75 @@ +import type { + NativeRemotePlayCapability, NativeRemotePlayerSession, NativeSaveData, NativeSaveDataResult, + RemotePlayers, +} from "./types"; +import { api } from "./client"; + +/** + * Which isolated player owns which platform. + * + * The server is the authority — this mirrors `NativeRemotePlayerRegistry` — but the interface has to + * know before it has asked anything, so it can decide which capability endpoint to call. Keeping the + * mapping here is what removes the `["gamecube", "wii"]` literal that used to be repeated across the + * library workspace, the game detail panel and the playability workspace. + */ +export const nativePlayers = [ + { key: "switch", emulator: "Eden", platforms: ["switch"] }, + { key: "dolphin", emulator: "Dolphin", platforms: ["gamecube", "wii"] }, +] as const; + +export type NativePlayerKey = (typeof nativePlayers)[number]["key"]; + +const byPlatform = new Map( + nativePlayers.flatMap((player) => player.platforms.map((platform) => [platform, player] as const)), +); + +export const nativePlatforms = [...byPlatform.keys()]; + +export function nativePlayerFor(platform: string | null | undefined) { + return platform ? byPlatform.get(platform.toLowerCase()) : undefined; +} + +/** + * Resolve the single native player a game belongs to. A game whose releases name two native + * platforms is deliberately unresolvable: Ludarium must never guess which disc to launch. + */ +export function nativePlayerForReleases(platforms: (string | null | undefined)[]) { + const matched = [...new Set(platforms.map((platform) => nativePlayerFor(platform)).filter(Boolean))]; + return matched.length === 1 ? matched[0] : undefined; +} + +export const capabilityUrl = (key: string, gameId: string) => `/games/${gameId}/${key}-play-capability`; +export const capabilitiesUrl = (key: string, ids: string) => `/games/${key}-play-capabilities?ids=${ids}`; +export const sessionsUrl = (key: string, gameId: string) => `/games/${gameId}/${key}-player-sessions`; +export const sessionUrl = (key: string, sessionId: string) => `/${key}-player-sessions/${sessionId}`; +export const saveDataUrl = (key: string, gameId: string) => `/games/${gameId}/${key}-save-data`; + +export const fetchNativeCapability = (key: string, gameId: string, init?: RequestInit) => + api(capabilityUrl(key, gameId), init); + +export const fetchNativeCapabilities = (key: string, ids: string, init?: RequestInit) => + api(capabilitiesUrl(key, ids), init); + +export const startNativeSession = (key: string, gameId: string) => + api(sessionsUrl(key, gameId), { method: "POST" }); + +export const endNativeSession = (key: string, sessionId: string) => + api(sessionUrl(key, sessionId), { method: "DELETE" }); + +export const listSaveData = (key: string, gameId: string, init?: RequestInit) => + api(saveDataUrl(key, gameId), init); + +export const captureSaveData = (key: string, gameId: string) => + api(saveDataUrl(key, gameId), { method: "POST" }); + +/** Omitting the revision restores the newest one the vault holds. */ +export const restoreSaveData = (key: string, gameId: string, revisionId?: string) => + api( + `${saveDataUrl(key, gameId)}/restore${revisionId ? `?revisionId=${revisionId}` : ""}`, + { method: "POST" }); + +export const playerStatus = (players: RemotePlayers | null, key: string) => players?.[key]; + +/** A readable label for a capability state, used wherever a blocked game explains itself. */ +export const describeState = (state: string | null | undefined) => + (state ?? "").replace(/([a-z])([A-Z])/g, "$1 $2"); diff --git a/src/Ludarium.Web/src/api/types.ts b/src/Ludarium.Web/src/api/types.ts new file mode 100644 index 0000000..48869fc --- /dev/null +++ b/src/Ludarium.Web/src/api/types.ts @@ -0,0 +1,443 @@ +export type Status = { + version: string; + schemaVersion: number; + libraries: number; + artifacts: number; + presentArtifacts: number; + games: number; + releases: number; + bundles: number; + totalBytes: number; + hashedArtifacts: number; + recognizedArtifacts: number; + unknownArtifacts: number; + missingArtifacts: number; + duplicateArtifacts: number; + duplicateBytes: number; + openReviews: number; + openReviewRecords: number; + artworkNeedingReview: number; + criticalFindings: number; + activeScans: number; + rootsRequiringAttention: number; + excludedSupportGames: number; + unresolvedGameCandidates: number; + lastFullScanAt: string | null; + lastIncrementalScanAt: string | null; + catalogImportedWithoutScan: boolean; + confidencePercent: number | null; +}; +export type Library = { + id: string; + name: string; + path: string; + kind: string; + isAvailable: boolean; + isReadOnly: boolean | null; + enabled: boolean; + hashPolicy: string; + inspectArchives: boolean; + maxConcurrency: number; + recursive: boolean; + hasEntries: boolean | null; + version: number; + lastVerifiedAt: string | null; +}; +export type LibraryHealth = { + libraryId: string; + artifacts: number; + games: number; + bytes: number; + detectedPlatforms: string[]; + lastFullScan: string | null; + lastIncrementalScan: string | null; +}; +export type Artifact = { + id: string; + relativePath: string; + size: number; + mediaType: string; + platform: string | null; + confidence: string; + state: string; + sha256: string | null; + modifiedAt: string; +}; +export type Finding = { + id: string; + category: string; + severity: string; + message: string; + artifactId: string | null; + bundleId: string | null; + createdAt: string; +}; +export type Bundle = { + id: string; + name: string; + kind: string; + state: string; + members: { artifactId: string; role: string; sequence: number }[]; + findings: string[]; +}; +export type ArtifactDetails = { + artifact: Artifact; + library: Library; + evidence: { + kind: string; + value: string; + source: string; + confidence: string; + observedAt: string; + }[]; + archiveMembers: { + id: string; + path: string; + uncompressedSize: number; + unsafePath: boolean; + }[]; + bundles: Bundle[]; + findings: Finding[]; + duplicates: Artifact[]; +}; +export type Game = { + id: string; + title: string; + createdAt: string; + version: number; + origin: "Scan" | "Manual"; +}; +export type CollectionRule = { + query?: string | null; + platform?: string | null; + tags?: string[] | null; + status?: GamePlayStatus | null; + favorite?: boolean | null; + metadataField?: string | null; + metadataValue?: string | null; +}; +export type GameGroup = { + id: string; + name: string; + description: string | null; + kind: "Static" | "Smart" | "Virtual"; + rule: CollectionRule | null; + pinned: boolean; + createdAt: string; + updatedAt: string; + version: number; + gameCount: number; +}; +export type GamePlayStatus = "None" | "Backlog" | "Playing" | "Paused" | "Completed" | "Abandoned"; +export type GameUserState = { + gameId: string; + favorite: boolean; + status: GamePlayStatus; + rating: number | null; + difficulty: number | null; + completionPercent: number | null; + notes: string | null; + playCount: number; + lastPlayedAt: string | null; + updatedAt: string; + version: number; +}; +export type RecentlyPlayedGame = { game: Game; state: GameUserState }; +export type GameTag = { id: string; gameId: string; name: string; normalizedName: string; sourceType: string; sourceId: string; createdAt: string }; +export type TagSummary = { name: string; normalizedName: string; gameCount: number }; +export type GameRelation = { + id: string; gameId: string; kind: "Dlc" | "Mod" | "Hack" | "Patch" | "Translation" | "Expansion" | "Manual" | "Extra" | "Sequel" | "Prequel"; + title: string; relatedGameId: string | null; artifactId: string | null; sourceType: string; sourceId: string; + confidence: string; manualLock: boolean; createdAt: string; +}; +export type GameMedia = { + id: string; gameId: string; kind: "Screenshot" | "Manual" | "Background" | "Logo" | "Video" | "Soundtrack"; + title: string; source: string; provider: string | null; externalId: string | null; location: string | null; + contentType: string | null; length: number | null; sha256: string | null; appOwned: boolean; selected: boolean; createdAt: string; +}; +export type GameDataKind = "Save" | "SaveState"; +export type GameDataEntry = { + id: string; gameId: string; kind: GameDataKind; name: string; emulator: string | null; + device: string | null; notes: string | null; currentRevisionId: string; revisionCount: number; + createdAt: string; updatedAt: string; version: number; +}; +export type GameDataRevision = { + id: string; entryId: string; sequence: number; fileName: string; contentType: string; + length: number; sha256: string; location: string; sourceType: string; sourceId: string; createdAt: string; +}; +export type GameDataItem = { entry: GameDataEntry; currentRevision: GameDataRevision }; +export type GameDataSummary = { saves: number; saveStates: number; revisions: number; bytes: number }; +export type Achievement = { id: string; externalId: string; title: string; description: string | null; points: number; unlocked: boolean; unlockedAt: string | null; badgeUrl: string | null }; +export type GameAchievementProgress = { gameId: string; provider: string; externalGameId: string; earned: number; total: number; updatedAt: string; achievements: Achievement[] }; +export type PlatformDefinition = { id: string; name: string; category: string; aliases: string[]; metadataOnly: boolean; custom: boolean; enabled: boolean; updatedAt: string; version: number }; +export type ProviderDescriptor = { id: string; name: string; configured: boolean; state: string; capabilities: string[]; requiresInternet: boolean; attributionUrl: string; message: string | null; lastSuccessfulSyncAt: string | null }; +export type ProviderValidationResult = { providerId: string; provider: string; state: string; configured: boolean; reachable: boolean; fixtureMatched: boolean; message: string; checkedAt: string }; +export type BrowserPlayCapability = { + gameId: string; available: boolean; state: "Available" | "Disabled" | "UnsupportedPlatform" | "UnsupportedFormat" | "MissingRom" | "MissingCore" | "EmulatorUnavailable" | "AmbiguousMapping" | "CapacityReached" | "Expired" | "Cancelled" | "Failed"; + message: string; platform: string | null; emulator: string | null; core: string | null; desktopRecommended: boolean; checkedAt: string | null; automaticRestore: boolean; +}; +export type BrowserPlaySession = { + id: string; gameId: string; state: "Starting" | "Ready" | "Active" | "Completed" | "Cancelled" | "Expired" | "Failed"; + platform: string; emulator: string; core: string; launchUrl: string; createdAt: string; expiresAt: string; endedAt: string | null; errorCode: string | null; errorMessage: string | null; +}; +/** One isolated native remote player, exactly as /system/remote-players describes it. */ +export type RemotePlayerStatus = { + key: string; configured: boolean; reachable: boolean; running: boolean; url: string | null; + emulator: string; platforms: string[]; sourceLibraryReadOnly: boolean; exactGameLaunch: boolean; + embeddedAvailable: boolean; supportsSavestates: boolean; automaticRestore: boolean; saveMode: string; + prodKeysConfigured: boolean | null; titleKeysConfigured: boolean | null; message: string; +}; +/** Keyed by player, so a new sidecar appears without a frontend change. */ +export type RemotePlayers = Record; +export type NativeRemotePlayState = + | "Available" | "UnsupportedPlatform" | "RuntimeUnavailable" | "MissingKeys" + | "MissingGame" | "MissingBaseGame" | "SourceNotReadOnly" | "AmbiguousMapping"; +export type NativeRemotePlayCapability = { + gameId: string; available: boolean; state: NativeRemotePlayState; message: string; + emulator: string; desktopRecommended: boolean; checkedAt: string; automaticRestore: boolean; + platform: string | null; player: string | null; supportsSavestates: boolean; +}; +export type NativeRemotePlayerSession = { + id: string; gameId: string; player: string; emulator: string; platform: string | null; + launchUrl: string; externalUrl: string | null; createdAt: string; expiresAt: string; + exactGameLaunch: boolean; saveMode: string; +}; +export type NativeSaveDataResult = { + id: string; sequence: number; sha256: string; length: number; capturedWhileRunning?: boolean; +}; +export type NativeSaveDataRevision = { + id: string; sequence: number; length: number; sha256: string; createdAt: string; current: boolean; +}; +export type NativeSaveData = { + entryId: string; revisionCount: number; revisions: NativeSaveDataRevision[]; +}; +export type WishlistItem = { + id: string; + gameId: string | null; + title: string; + platform: string | null; + priority: "Low" | "Normal" | "High"; + notes: string | null; + createdAt: string; + updatedAt: string; + version: number; + status: "Interested" | "WaitingForRelease" | "WaitingForSale" | "Reserved" | "Acquired"; + desiredPrice: number | null; currentPrice: number | null; currency: string | null; + storeName: string | null; storeUrl: string | null; edition: string | null; region: string | null; + releaseDate: string | null; externalProvider: string | null; externalId: string | null; artworkUrl: string | null; +}; +export type WishlistSummary = { total: number; highPriority: number; upcoming: number; priceDrops: number; acquired: number }; +export type GameDiscoveryResponse = { configured: boolean; provider: string; message: string | null; results: { provider: string; externalId: string; title: string; platform: string | null; releaseDate: string | null; artworkUrl: string | null; storeUrl: string | null }[] }; +export type ArtworkInfo = { + source: "Uploaded" | "Library" | "Libretro" | "Nintendo" | "Steam" | "SteamDigital" | + "PSXDataCenter" | "WorldOfGames" | "LaunchBox" | "Generated"; + contentType: string | null; + length: number | null; +}; +export type GameArtwork = { + id: string; gameId: string; role: string; verificationStatus: string; + source: string; provider: string | null; externalId: string | null; matchMethod: string; + confidence: string; width: number | null; height: number | null; aspectRatio: number | null; + length: number; sha256: string; qualityScore: number; retrievedAt: string; selected: boolean; + platform?: string | null; +}; +export type ArtworkReviewItem = { gameId: string; title: string; artwork: GameArtwork }; +export type Release = { + id: string; + gameId: string; + title: string; + platform: string | null; + region: string | null; + revision: string | null; + version: number; + origin: "Scan" | "Manual"; +}; +export type Claim = { + id: string; + field: string; + value: string; + sourceType: string; + confidence: string; + manualLock: boolean; + createdAt: string; +}; +export type PlatformSummary = { + platform: string; + artifacts: number; + bytes: number; + games: number; +}; +export type Review = { + id: string; + reason: string; + severity: string; + state: string; + payloadJson: string; + createdAt: string; + resolvedAt: string | null; + resolution: string | null; +}; +export type PlatformPlayabilitySummary = { platform: string; supported: number; ready: number; blocked: number }; +export type PlayabilitySummary = { supported: number; ready: number; blocked: number; unsupported: number; byPlatform: PlatformPlayabilitySummary[] }; +export type ReviewGroup = { + key: string; + category: string; + reason: string; + severity: string; + count: number; + estimatedBytes: number; + library: string | null; + platform: string | null; + pathPattern: string; + oldestAt: string; + newestAt: string; + recommendedAction: string; + reviewIds: string[]; + resolution?: string | null; + resolvedAt?: string | null; + totalCount?: number | null; +}; +export type HealthData = { + critical: number; + warning: number; + notice: number; + unknown: number; + missing: number; + incompleteBundles: number; + recentFindings: Finding[]; +}; +export type Scan = { + id: string; + libraryId: string; + mode: string; + state: string; + stage: string; + itemsCompleted: number; + itemsTotal: number | null; + bytesCompleted: number; + bytesTotal: number | null; + error: string | null; + createdAt?: string; + startedAt?: string | null; + finishedAt?: string | null; + newItems: number; + changedItems: number; + movedItems: number; + deletedItems: number; +}; +export type BackgroundJob = { + id: string; + kind: string; + state: string; + itemsCompleted: number; + itemsTotal: number | null; + attempts: number; + maxAttempts: number; + scheduledAt: string; + createdAt: string; + updatedAt: string; + leaseUntil: string | null; + error: string | null; +}; +export type Snapshot = { + id: string; + name: string; + schemaVersion: string; + applicationVersion: string; + createdAt: string; + items: { relativePath: string; size: number; sha256: string }[]; +}; +export type CatalogSource = { + id: string; + name: string; + format: string; + version: string; + sha256: string | null; + state: string; + importedAt: string; + entryCount: number; +}; +export type ProviderSnapshot = { + id: string; + provider: string; + version: string; + state: string; + eTag: string | null; + sha256: string; + retrievedAt: string; + expiresAt: string; + lastSuccessfulSyncAt: string | null; + error: string | null; +}; +export type OperationalMetrics = { + capturedAt: string; + jobs: { queued: number; active: number; failed: number; total: number }; + scans: { active: number; failed: number; averageItemsPerSecond: number }; + review: { groups: number; items: number; estimatedBytes: number }; + artwork: { needingReview: number; averageQuality: number }; + providers: { configured: number; degraded: number; lastSuccessfulSync: string | null }; +}; +export type StorageData = { + totalBytes: number; + duplicateBytes: number; + platforms: PlatformSummary[]; + libraries: { + libraryId: string; + name: string; + artifacts: number; + bytes: number; + missing: number; + }[]; +}; +export type OperationalSettings = { + reviewRetentionDays: number; + auditRetentionDays: number; + exportRetentionDays: number; + defaultPageSize: number; + defaultScanMode: string; + version: number; + updatedAt: string; +}; +export type Capabilities = { + offlineCore: boolean; + telemetry: boolean; + sourceMutation: boolean; + executableLaunch: boolean; + browserPlay?: { + provider: string; perGameDetection: boolean; sourceStreaming: boolean; explicitActionRequired: boolean; + releaseQualified?: { platform: string; core: string; extensions: string[] }[]; + bundledNotReleaseQualified?: { platform: string; core: string; extensions: string[] }[]; + }; + collections?: boolean; + smartCollections?: boolean; + tags?: boolean; + personalGameState?: boolean; + gameRelationships?: boolean; + richMedia?: { externalHttps: boolean; appOwnedUploads: boolean; sourceLibraryMutation: boolean }; + gameDataVault?: { saves: boolean; saveStates: boolean; immutableRevisions: boolean; maximumUploadBytes: number; sourceLibrarySync: boolean }; + achievements?: boolean; + metadataProviders?: ProviderDescriptor[]; + wishlist?: { + onlineDiscovery: boolean; + discoveryProvider: string | null; + manualPriceTracking: boolean; + automaticRetailPrices: boolean; + localArtworkUpload: boolean; + scanReconciliation: boolean; + }; + formats: { id: string; level: string; supported: boolean; version: string }[]; +}; +export type FirmwareAsset = { + id: string; platform: string; slot: string; kind: string; fileName: string; + length: number; sha256Prefix: string; selected: boolean; createdAt: string; version: number; +}; +export type FirmwareStatus = { + platform: string; slot: string; kind: string; label: string; runtimeState: string; + browserDelivery: boolean; maximumBytes: number; extensions: string[]; asset: FirmwareAsset | null; +}; +export type Page = { + items: T[]; + total: number; + pageNumber: number; + pageSize: number; +}; diff --git a/src/Ludarium.Web/src/app/App.tsx b/src/Ludarium.Web/src/app/App.tsx new file mode 100644 index 0000000..2f5f43b --- /dev/null +++ b/src/Ludarium.Web/src/app/App.tsx @@ -0,0 +1,105 @@ +import { lazy, Suspense, useEffect, useState } from "react"; +import { api, clearToken, readToken } from "../api/client"; +import type { Status } from "../api/types"; +import { parseRoute } from "./routes"; +import type { Route } from "./routes"; +import { AppShell } from "./AppShell"; +import { Login } from "./Login"; +import { Loading } from "../components/Overlay"; +import { useLocaleDocument } from "../i18n/locale"; + +const Home = lazy(() => import("../features/home/Home").then((module) => ({ default: module.Home }))); +const LibraryWorkspace = lazy(() => import("../features/library/LibraryWorkspace").then((module) => ({ default: module.LibraryWorkspace }))); +const Attention = lazy(() => import("../features/attention/Attention").then((module) => ({ default: module.Attention }))); +const Activity = lazy(() => import("../features/activity/Activity").then((module) => ({ default: module.Activity }))); +const Settings = lazy(() => import("../features/settings/Settings").then((module) => ({ default: module.Settings }))); +const Wishlist = lazy(() => import("../features/wishlist/Wishlist").then((module) => ({ default: module.Wishlist }))); +export function App() { + const locale = useLocaleDocument(); + const [authenticated, setAuthenticated] = useState( + () => !!readToken(), + ); + const [route, setRoute] = useState(parseRoute); + const [status, setStatus] = useState(null); + const [error, setError] = useState(""); + const refresh = () => + api("/system/status") + .then(setStatus) + .catch((e) => setError(e.message)); + useEffect(() => { + const change = () => setRoute(parseRoute()); + const unauthorized = () => setAuthenticated(false); + addEventListener("hashchange", change); + addEventListener("popstate", change); + addEventListener("ludarium:unauthorized", unauthorized); + return () => { + removeEventListener("hashchange", change); + removeEventListener("popstate", change); + removeEventListener("ludarium:unauthorized", unauthorized); + }; + }, []); + useEffect(() => { + const apply = () => { + const preference = localStorage.getItem("ludarium.theme") || "system"; + document.documentElement.dataset.theme = + preference === "system" + ? matchMedia("(prefers-color-scheme: light)").matches + ? "light" + : "dark" + : preference; + }; + apply(); + addEventListener("ludarium:theme", apply); + // On "System" the preference is the operating system's, which can change while Ludarium is open. + const system = matchMedia("(prefers-color-scheme: light)"); + system.addEventListener("change", apply); + return () => { + removeEventListener("ludarium:theme", apply); + system.removeEventListener("change", apply); + }; + }, []); + useEffect(() => { + if (authenticated) void refresh(); + }, [authenticated]); + useEffect(() => { + // Hash navigation has no matching document anchors, so browsers otherwise + // retain the previous workspace's scroll position. + window.scrollTo({ top: 0, left: 0, behavior: "auto" }); + }, [authenticated, route.page, route.section, route.id, route.filter]); + if (!authenticated) + return ( + { + setStatus(next); + setAuthenticated(true); + }} + /> + ); + const content = { + Home: , + Library: , + Wishlist: , + Attention: , + Activity: , + Settings: , + }[route.page]; + return ( + { + clearToken(); + setAuthenticated(false); + }} + > + {error && ( +

+ Could not refresh archive status. {error} +

+ )} + }>{content} +
+ ); +} diff --git a/src/Ludarium.Web/src/app/AppShell.test.tsx b/src/Ludarium.Web/src/app/AppShell.test.tsx new file mode 100644 index 0000000..f8c058b --- /dev/null +++ b/src/Ludarium.Web/src/app/AppShell.test.tsx @@ -0,0 +1,75 @@ +// @vitest-environment happy-dom +import "@testing-library/jest-dom/vitest"; +import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; +import { AppShell } from "./AppShell"; +import type { Route } from "./routes"; +import { useLocaleDocument } from "../i18n/locale"; + +const libraryRoute: Route = { + page: "Library", + section: "games", + filter: "n64", +}; + +function ShellHarness() { + const locale = useLocaleDocument(); + return undefined}>

Library content

; +} + +describe("AppShell wayfinding", () => { + afterEach(() => { + cleanup(); + document.documentElement.classList.remove("controller-navigation"); + localStorage.removeItem("ludarium.language"); + }); + + it("keeps global destinations stable and moves platform context into the breadcrumb", () => { + render(); + + const primary = screen.getAllByRole("navigation", { name: "Primary navigation" })[0]; + expect(within(primary).getByRole("link", { name: "Home" })).toBeInTheDocument(); + expect(within(primary).getByRole("link", { name: "Library" })).toHaveAttribute("aria-current", "page"); + expect(within(primary).getByRole("link", { name: "Platforms" })).toBeInTheDocument(); + expect(within(primary).getByRole("link", { name: "Collections" })).toBeInTheDocument(); + expect(within(primary).queryByRole("link", { name: "Playability" })).not.toBeInTheDocument(); + expect(within(primary).queryByRole("link", { name: "Windows archive" })).not.toBeInTheDocument(); + expect(within(primary).queryByRole("link", { name: "Advanced files" })).not.toBeInTheDocument(); + expect(within(primary).queryByRole("navigation", { name: "Library platforms" })).not.toBeInTheDocument(); + + const breadcrumb = screen.getByRole("navigation", { name: "Breadcrumb" }); + expect(within(breadcrumb).getByRole("link", { name: "Home" })).toBeInTheDocument(); + expect(within(breadcrumb).getByRole("link", { name: "Library" })).toBeInTheDocument(); + expect(within(breadcrumb).getByText("Nintendo 64")).toHaveAttribute("aria-current", "page"); + }); + + it("offers five mobile actions and returns focus when the More sheet closes", async () => { + render(); + + const mobile = screen.getAllByRole("navigation", { name: "Primary navigation" })[1]; + expect(within(mobile).getAllByRole("link")).toHaveLength(3); + expect(within(mobile).getAllByRole("button")).toHaveLength(2); + + const more = within(mobile).getByRole("button", { name: "More" }); + fireEvent.click(more); + expect(screen.getByRole("dialog", { name: "More in Ludarium" })).toBeInTheDocument(); + await waitFor(() => expect(screen.getByRole("button", { name: "Close navigation menu" })).toHaveFocus()); + + fireEvent.keyDown(window, { key: "Escape" }); + await waitFor(() => expect(screen.queryByRole("dialog", { name: "More in Ludarium" })).not.toBeInTheDocument()); + await waitFor(() => expect(more).toHaveFocus()); + }); + + it("persists an English and Dutch language choice", async () => { + render(); + + fireEvent.click(screen.getByRole("button", { name: "NL" })); + await waitFor(() => expect(screen.getAllByRole("link", { name: "Start" }).length).toBeGreaterThan(0)); + expect(localStorage.getItem("ludarium.language")).toBe("nl"); + expect(document.documentElement.lang).toBe("nl"); + + fireEvent.click(screen.getByRole("button", { name: "EN" })); + await waitFor(() => expect(screen.getAllByRole("link", { name: "Home" }).length).toBeGreaterThan(0)); + expect(localStorage.getItem("ludarium.language")).toBe("en"); + }); +}); diff --git a/src/Ludarium.Web/src/app/AppShell.tsx b/src/Ludarium.Web/src/app/AppShell.tsx new file mode 100644 index 0000000..e5aa38d --- /dev/null +++ b/src/Ludarium.Web/src/app/AppShell.tsx @@ -0,0 +1,275 @@ +import { useEffect, useRef, useState } from "react"; +import type { ReactNode } from "react"; +import type { Status } from "../api/types"; +import { href } from "./routes"; +import type { PrimaryRoute, Route } from "./routes"; +import { Icon } from "../components/Icon"; +import { GlobalSearch } from "../components/GlobalSearch"; +import { GamepadNavigation } from "../components/GamepadNavigation"; +import { platformName } from "../api/client"; +import type { LocaleController } from "../i18n/locale"; + +type ShellNavItem = { + page: PrimaryRoute; + label: string; + icon: "home" | "library" | "wishlist" | "attention" | "activity" | "settings"; + section?: string; +}; + +const playItems: ShellNavItem[] = [ + { page: "Home", label: "Home", icon: "home" }, + { page: "Library", label: "Library", icon: "library", section: "games" }, + { page: "Wishlist", label: "Wishlist", icon: "wishlist" }, +]; +const organizeItems: ShellNavItem[] = [ + { page: "Library", label: "Platforms", icon: "library", section: "platforms" }, + { page: "Library", label: "Collections", icon: "library", section: "collections" }, +]; +const operationsItems: ShellNavItem[] = [ + { page: "Attention", label: "Attention", icon: "attention", section: "decisions" }, + { page: "Activity", label: "Activity", icon: "activity", section: "scans" }, + { page: "Settings", label: "Settings", icon: "settings", section: "libraries" }, +]; +const navDescriptions: Record = { + platforms: "Browse console families", + collections: "Curated and smart sets", + windows: "Inspect installers safely", + files: "Inspect source evidence", + decisions: "Resolve items that need you", + scans: "Scans, jobs and exports", + repair: "Why a game will not start", + libraries: "Libraries, players and preferences", +}; + +function itemHref(item: ShellNavItem) { + return href(item.page, item.section); +} + +function itemActive(route: Route, item: ShellNavItem) { + if (route.page !== item.page) return false; + if (!item.section) return true; + // Library and Settings both expose several destinations under one page. + if (item.page !== "Library" && item.page !== "Settings") return true; + if (item.page === "Settings" && item.section === "libraries") + return route.section !== "repair"; + return route.section === item.section; +} + +const sectionLabels: Partial>> = { + Library: { games: "Games", collections: "Collections", platforms: "Platforms", windows: "Windows archive", files: "Advanced files" }, + Wishlist: { all: "All wishes" }, + Attention: { + all: "All evidence", decisions: "Review center", findings: "Findings", incomplete: "Incomplete sets", + changed: "Changed or damaged", unknown: "Unknown content", duplicates: "Duplicate space", + deferred: "Deferred", resolved: "Resolved history", + }, + Activity: { scans: "Scans & jobs", history: "Scan history", snapshots: "Snapshots", exports: "Exports" }, + Settings: { + libraries: "Libraries", scanning: "Scanning", repair: "Playability & naming", catalogs: "Catalog sources", + platforms: "Platforms", firmware: "Firmware & keys", artwork: "Cover quality", appearance: "Appearance", + system: "System & diagnostics", advanced: "Advanced", + }, +}; +export function AppShell({ + locale, + route, + status, + onSignOut, + children, +}: { + locale: LocaleController; + route: Route; + status: Status | null; + onSignOut: () => void; + children: ReactNode; +}) { + const [search, setSearch] = useState(false); + const { language, setLanguage, t } = locale; + const [mobileMore, setMobileMore] = useState(false); + const searchButton = useRef(null); + const mobileMoreButton = useRef(null); + const mobileMoreClose = useRef(null); + const mobileMoreSheet = useRef(null); + useEffect(() => { + const key = (e: KeyboardEvent) => { + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") { + e.preventDefault(); + setSearch(true); + } + if (e.key === "Escape") setSearch(false); + }; + addEventListener("keydown", key); + return () => removeEventListener("keydown", key); + }, []); + useEffect(() => { + if (!mobileMore) return; + const close = (event: KeyboardEvent) => { + if (event.key === "Escape") { + setMobileMore(false); + setTimeout(() => mobileMoreButton.current?.focus(), 0); + return; + } + if (event.key === "Tab") { + const focusable = [...(mobileMoreSheet.current?.querySelectorAll("a[href], button:not([disabled])") ?? [])]; + if (!focusable.length) return; + const first = focusable[0]; + const last = focusable.at(-1)!; + if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } + } + }; + const previousOverflow = document.body.style.overflow; + document.body.style.overflow = "hidden"; + addEventListener("keydown", close); + setTimeout(() => mobileMoreClose.current?.focus(), 0); + return () => { + document.body.style.overflow = previousOverflow; + removeEventListener("keydown", close); + }; + }, [mobileMore]); + const lastScanAt = status?.lastIncrementalScanAt || status?.lastFullScanAt; + const scanAgeDays = lastScanAt + ? Math.max(0, Math.floor((Date.now() - new Date(lastScanAt).getTime()) / 86_400_000)) + : null; + const scanIsStale = scanAgeDays !== null && scanAgeDays >= 7; + const openAttentionRecords = status?.openReviewRecords ?? status?.openReviews ?? 0; + const currentSection = sectionLabels[route.page]?.[route.section]; + const contextualLabel = route.id + ? route.section === "games" ? "Game details" : route.section === "collections" ? "Collection" : currentSection + : route.filter ? platformName(route.filter) : currentSection; + const renderNavItem = (item: ShellNavItem) => { + const active = itemActive(route, item); + return ( + + ); + }; + return ( +
+ + +
+
+ + + + + {status?.activeScans + ? `${status.activeScans} scanning` + : scanIsStale + ? `Quick scan due · ${scanAgeDays}d ago` + : "Library ready"} + + +
+ + +
+ +
+
{children}
+
+ + {mobileMore && ( +
{ + if (event.currentTarget === event.target) { + setMobileMore(false); + setTimeout(() => mobileMoreButton.current?.focus(), 0); + } + }}> + +
+ )} + { + setSearch(false); + setTimeout(() => searchButton.current?.focus(), 0); + }} + /> +
+ ); +} diff --git a/src/Ludarium.Web/src/app/Login.test.tsx b/src/Ludarium.Web/src/app/Login.test.tsx new file mode 100644 index 0000000..0f6d9d0 --- /dev/null +++ b/src/Ludarium.Web/src/app/Login.test.tsx @@ -0,0 +1,59 @@ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; +import { Login } from "./Login"; +import { languageStorageKey, useLocaleDocument } from "../i18n/locale"; + +function LoginHarness() { + const locale = useLocaleDocument(); + return undefined} />; +} + +describe("Login", () => { + afterEach(() => { + cleanup(); + localStorage.removeItem(languageStorageKey); + }); + + it("explains the private play-first boundary", () => { + render(); + + expect(screen.getByRole("heading", { name: "Every console. One calm place to play." })).toBeInTheDocument(); + expect(screen.getByText("Launch supported games directly")).toBeInTheDocument(); + expect(screen.getByText("Libraries always stay read-only")).toBeInTheDocument(); + expect(screen.getByText(/No cloud account, telemetry or external login required/)).toBeInTheDocument(); + }); + + it("keeps the token concealed unless explicitly revealed", () => { + render(); + const input = screen.getByLabelText("Administrator token"); + + expect(input).toHaveAttribute("type", "password"); + fireEvent.click(screen.getByRole("button", { name: "Show token" })); + expect(input).toHaveAttribute("type", "text"); + expect(screen.getByRole("button", { name: "Hide token" })).toHaveAttribute("aria-pressed", "true"); + }); + + it("does not submit an empty or whitespace-only token", () => { + render(); + const submit = screen.getByRole("button", { name: "Open Ludarium" }); + + expect(submit).toBeDisabled(); + fireEvent.change(screen.getByLabelText("Administrator token"), { target: { value: " " } }); + expect(submit).toBeDisabled(); + fireEvent.change(screen.getByLabelText("Administrator token"), { target: { value: "candidate-token" } }); + expect(submit).toBeEnabled(); + }); + + it("applies a stored language before authentication and can switch it back", () => { + localStorage.setItem(languageStorageKey, "nl"); + render(); + + expect(screen.getByRole("heading", { name: "Elke console. Eén rustige plek om te spelen." })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "NL" })).toHaveAttribute("aria-pressed", "true"); + expect(screen.getByLabelText("Beheerderstoken")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "EN" })); + expect(screen.getByRole("heading", { name: "Every console. One calm place to play." })).toBeInTheDocument(); + expect(localStorage.getItem(languageStorageKey)).toBe("en"); + }); +}); diff --git a/src/Ludarium.Web/src/app/Login.tsx b/src/Ludarium.Web/src/app/Login.tsx new file mode 100644 index 0000000..b5cae01 --- /dev/null +++ b/src/Ludarium.Web/src/app/Login.tsx @@ -0,0 +1,116 @@ +import { useState } from "react"; +import { api, clearToken, storeToken } from "../api/client"; +import type { Status } from "../api/types"; +import type { LocaleController } from "../i18n/locale"; +export function Login({ + locale, + onAuthenticated, +}: { + locale: LocaleController; + onAuthenticated: (token: string, status: Status) => void; +}) { + const { language, setLanguage, t } = locale; + const [token, setToken] = useState(""); + const [error, setError] = useState(""); + const [busy, setBusy] = useState(false); + const [showToken, setShowToken] = useState(false); + const [remember, setRemember] = useState(false); + async function submit(e: React.FormEvent) { + e.preventDefault(); + setBusy(true); + setError(""); + storeToken(token.trim(), remember); + try { + onAuthenticated(token.trim(), await api("/system/status")); + } catch (reason) { + clearToken(); + setError(reason instanceof Error ? reason.message : t("Sign-in failed")); + } finally { + setBusy(false); + } + } + return ( +
+
+ + +
+
+
LLudarium
+

{t("Your private game library")}

+

{t("Every console. One calm place to play.")}

+

+ {t("Rediscover the games you own, jump back into recent sessions and keep every version, save and source accounted for.")} +

+ +
    +
  • {t("Play-first")}{t("Launch supported games directly")}
  • +
  • {t("Source-safe")}{t("Libraries always stay read-only")}
  • +
  • {t("Resume-ready")}{t("Saves stay tied to exact evidence")}
  • +
+
+
+

{t("Welcome back")}

+

{t("Open your library")}

+

{t("Use the administrator token configured on your Ludarium server.")}

+
+ + + {error && ( +

+ {error} +

+ )} + +
+
+ +

{t("Private by design")}{t("No cloud account, telemetry or external login required.")}

+
+
+
+ ); +} diff --git a/src/Ludarium.Web/src/app/routes.test.ts b/src/Ludarium.Web/src/app/routes.test.ts new file mode 100644 index 0000000..3e90b2e --- /dev/null +++ b/src/Ludarium.Web/src/app/routes.test.ts @@ -0,0 +1,35 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { href, parseRoute } from "./routes"; + +describe("route state", () => { + beforeEach(() => { location.hash = ""; }); + + it("round-trips a platform-filtered library route", () => { + location.hash = `${href("Library", "games")}&platform=ps5`; + expect(parseRoute()).toMatchObject({ page: "Library", section: "games", filter: "ps5" }); + }); + + it("maps legacy destinations without losing their intent", () => { + location.hash = "#Review%20Center"; + expect(parseRoute()).toMatchObject({ page: "Attention", section: "decisions" }); + }); + + it("routes directly to the wishlist", () => { + location.hash = href("Wishlist"); + expect(parseRoute()).toMatchObject({ page: "Wishlist", section: "all" }); + }); + + it("round-trips a pinned collection deep link", () => { + location.hash = href("Library", "collections", "11111111-1111-1111-1111-111111111111"); + expect(parseRoute()).toMatchObject({ + page: "Library", + section: "collections", + id: "11111111-1111-1111-1111-111111111111", + }); + }); + + it("falls back safely for an unknown route", () => { + location.hash = "#NotARealPage"; + expect(parseRoute()).toMatchObject({ page: "Home", section: "overview" }); + }); +}); diff --git a/src/Ludarium.Web/src/app/routes.ts b/src/Ludarium.Web/src/app/routes.ts new file mode 100644 index 0000000..48dc0ee --- /dev/null +++ b/src/Ludarium.Web/src/app/routes.ts @@ -0,0 +1,75 @@ +export type PrimaryRoute = + | "Home" + | "Library" + | "Wishlist" + | "Attention" + | "Activity" + | "Settings"; +export type Route = { + page: PrimaryRoute; + section: string; + id?: string; + filter?: string; +}; +const redirects: Record = { + Overview: { page: "Home", section: "overview" }, + Games: { page: "Library", section: "games" }, + Platforms: { page: "Library", section: "platforms" }, + "Windows Archive": { page: "Library", section: "windows" }, + Bundles: { page: "Attention", section: "incomplete" }, + Storage: { page: "Library", section: "platforms" }, + "Review Center": { page: "Attention", section: "decisions" }, + "Library Health": { page: "Attention", section: "decisions" }, + "Scans & Jobs": { page: "Activity", section: "scans" }, + "Snapshots & Exports": { page: "Activity", section: "snapshots" }, + "Catalog Sources": { page: "Settings", section: "catalogs" }, +}; +export function parseRoute(): Route { + const raw = decodeURIComponent(location.hash.slice(1)) || "Home"; + if (redirects[raw]) return redirects[raw]; + const [pagePart, query = ""] = raw.split("?"); + const page = ( + ["Home", "Library", "Wishlist", "Attention", "Activity", "Settings"].includes(pagePart) + ? pagePart + : "Home" + ) as PrimaryRoute; + const params = new URLSearchParams(query); + return { + page, + section: + params.get("view") || + { + Home: "overview", + Library: "games", + Wishlist: "all", + Attention: "decisions", + Activity: "scans", + Settings: "libraries", + }[page], + id: params.get("id") || undefined, + filter: params.get("platform") || undefined, + }; +} +export function href(page: PrimaryRoute, section?: string, id?: string) { + const params = new URLSearchParams(); + if (section) params.set("view", section); + if (id) params.set("id", id); + return `#${page}${params.size ? `?${params}` : ""}`; +} + +export function gameDetailsHref(id: string, platform?: string) { + const params = new URLSearchParams({ view: "games", id }); + if (platform && platform !== "all") params.set("platform", platform); + return `#Library?${params}`; +} + +export function openGameDetails(id: string, platform?: string) { + const target = gameDetailsHref(id, platform); + if (location.hash === target) return; + history.pushState( + { ...history.state, ludariumOverlay: "game", ludariumOverlayFrom: location.hash || href("Home") }, + "", + target, + ); + dispatchEvent(new PopStateEvent("popstate", { state: history.state })); +} diff --git a/src/Ludarium.Web/src/assets/hero.png b/src/Ludarium.Web/src/assets/hero.png new file mode 100644 index 0000000..02251f4 Binary files /dev/null and b/src/Ludarium.Web/src/assets/hero.png differ diff --git a/src/Ludarium.Web/src/assets/typescript.svg b/src/Ludarium.Web/src/assets/typescript.svg new file mode 100644 index 0000000..6c9d69c --- /dev/null +++ b/src/Ludarium.Web/src/assets/typescript.svg @@ -0,0 +1 @@ + diff --git a/src/Ludarium.Web/src/assets/vite.svg b/src/Ludarium.Web/src/assets/vite.svg new file mode 100644 index 0000000..5101b67 --- /dev/null +++ b/src/Ludarium.Web/src/assets/vite.svg @@ -0,0 +1 @@ +Vite diff --git a/src/Ludarium.Web/src/components/ArchivePipeline.tsx b/src/Ludarium.Web/src/components/ArchivePipeline.tsx new file mode 100644 index 0000000..54b2d3b --- /dev/null +++ b/src/Ludarium.Web/src/components/ArchivePipeline.tsx @@ -0,0 +1,35 @@ +type PipelineState = "idle" | "running" | "paused" | "warning" | "complete"; + +const stages = [ + ["source", "Read-only source", "Observed in place"], + ["discover", "Discover", "Bounded inventory"], + ["identify", "Identify", "Headers + hashes"], + ["evidence", "Evidence", "Provenance retained"], + ["ready", "Play or review", "Never guessed"], +] as const; + +export function ArchivePipeline({ + state = "idle", + activeStage = 0, + compact = false, + label = "How Ludarium turns a read-only source into a trustworthy catalog", +}: { + state?: PipelineState; + activeStage?: number; + compact?: boolean; + label?: string; +}) { + const reached = state === "complete" ? stages.length - 1 : Math.max(0, Math.min(stages.length - 1, activeStage)); + return ( +
+ + {stages.map(([id, title, detail], index) => ( + + + {title}{!compact && {detail}} + + ))} + +
+ ); +} diff --git a/src/Ludarium.Web/src/components/ArtworkPreview.test.tsx b/src/Ludarium.Web/src/components/ArtworkPreview.test.tsx new file mode 100644 index 0000000..511c1f5 --- /dev/null +++ b/src/Ludarium.Web/src/components/ArtworkPreview.test.tsx @@ -0,0 +1,43 @@ +// @vitest-environment happy-dom +import "@testing-library/jest-dom/vitest"; +import { cleanup, render, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ArtworkPreview } from "./ArtworkPreview"; + +const { apiBlobMock } = vi.hoisted(() => ({ apiBlobMock: vi.fn() })); +vi.mock("../api/client", () => ({ + apiBlob: (path: string, signal?: AbortSignal) => apiBlobMock(path, signal), +})); + +describe("ArtworkPreview retail geometry", () => { + beforeEach(() => { + vi.stubGlobal("IntersectionObserver", undefined); + apiBlobMock.mockReset().mockResolvedValue(null); + }); + afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + }); + + it("uses platform-scoped artwork and exposes the shared retail profile", async () => { + const { container } = render(); + + const preview = container.querySelector(".artwork-preview"); + expect(preview).toHaveClass("retail-cover"); + expect(preview).toHaveAttribute("data-platform", "psvita"); + await waitFor(() => expect(apiBlobMock).toHaveBeenCalledWith( + "/games/game-1/artwork?platform=psvita", + expect.any(AbortSignal), + )); + }); + + it("leaves geometry and the artwork request unscoped when platform evidence is absent", async () => { + const { container } = render(); + + expect(container.querySelector(".artwork-preview")).not.toHaveAttribute("data-platform"); + await waitFor(() => expect(apiBlobMock).toHaveBeenCalledWith( + "/games/game-2/artwork", + expect.any(AbortSignal), + )); + }); +}); diff --git a/src/Ludarium.Web/src/components/ArtworkPreview.tsx b/src/Ludarium.Web/src/components/ArtworkPreview.tsx new file mode 100644 index 0000000..f9ed4fb --- /dev/null +++ b/src/Ludarium.Web/src/components/ArtworkPreview.tsx @@ -0,0 +1,81 @@ +import { useEffect, useRef, useState } from "react"; +import { apiBlob } from "../api/client"; +import { normalizeCoverPlatform } from "./retailCover"; + +/** + * A small, lazy artwork surface for operational screens. Artwork is optional: + * the generated fallback deliberately communicates that a verified cover still + * needs an operator decision instead of pretending that one exists. + */ +export function ArtworkPreview({ + gameId, + title, + candidateId, + platform, + className = "", +}: { + gameId: string; + title: string; + candidateId?: string; + platform?: string | null; + className?: string; +}) { + const [url, setUrl] = useState(null); + const [attempted, setAttempted] = useState(false); + const [visible, setVisible] = useState(false); + const ref = useRef(null); + + useEffect(() => { + const element = ref.current; + if (!element || typeof IntersectionObserver === "undefined") { + setVisible(true); + return; + } + const observer = new IntersectionObserver(([entry]) => { + if (entry.isIntersecting) { + setVisible(true); + observer.disconnect(); + } + }, { rootMargin: "240px" }); + observer.observe(element); + return () => observer.disconnect(); + }, []); + + useEffect(() => { + if (!visible) return; + const controller = new AbortController(); + let objectUrl: string | null = null; + const coverPlatform = normalizeCoverPlatform(platform); + const parameters = new URLSearchParams(); + if (candidateId) parameters.set("candidate", candidateId); + else if (coverPlatform) parameters.set("platform", coverPlatform); + setUrl(null); + setAttempted(false); + void apiBlob(`/games/${gameId}/artwork${parameters.size ? `?${parameters}` : ""}`, controller.signal) + .then((blob) => { + if (!blob || controller.signal.aborted) return; + objectUrl = URL.createObjectURL(blob); + setUrl(objectUrl); + }) + .catch(() => undefined) + .finally(() => { + if (!controller.signal.aborted) setAttempted(true); + }); + return () => { + controller.abort(); + if (objectUrl) URL.revokeObjectURL(objectUrl); + }; + }, [candidateId, gameId, platform, visible]); + + const coverPlatform = normalizeCoverPlatform(platform); + + return ( + + {url ? : } + + ); +} diff --git a/src/Ludarium.Web/src/components/ConfirmDialog.tsx b/src/Ludarium.Web/src/components/ConfirmDialog.tsx new file mode 100644 index 0000000..d1d3a6c --- /dev/null +++ b/src/Ludarium.Web/src/components/ConfirmDialog.tsx @@ -0,0 +1,96 @@ +import { useEffect, useRef, useState } from "react"; +import type { ReactNode } from "react"; + +export type ConfirmRequest = { + title: string; + body: ReactNode; + /** Label of the confirming control. Name the action, never "OK". */ + confirmLabel: string; + destructive?: boolean; + onConfirm: () => void | Promise; +}; + +/** + * The application's own confirmation, replacing `window.confirm`. + * + * A native confirm is unstyled, ignores the chosen theme, cannot explain consequences in more than + * one flat paragraph and blocks the page while it is open. Every destructive action in Ludarium + * deserves the same treatment as the rest of the interface: a focus-trapped dialog that names the + * action on its button and says exactly what survives. + */ +export function ConfirmDialog({ request, onClose }: { request: ConfirmRequest; onClose: () => void }) { + const dialog = useRef(null); + const confirmButton = useRef(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const titleId = useRef(`confirm-${Math.random().toString(36).slice(2, 9)}`); + + useEffect(() => { + const previousOverflow = document.body.style.overflow; + const previouslyFocused = document.activeElement as HTMLElement | null; + document.body.style.overflow = "hidden"; + confirmButton.current?.focus(); + const keyboard = (event: KeyboardEvent) => { + if (event.key === "Escape") { event.stopPropagation(); onClose(); return; } + if (event.key !== "Tab" || !dialog.current) return; + const focusable = [...dialog.current.querySelectorAll("button:not([disabled]),a[href]")]; + if (!focusable.length) return; + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); } + else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); } + }; + addEventListener("keydown", keyboard, true); + return () => { + document.body.style.overflow = previousOverflow; + removeEventListener("keydown", keyboard, true); + previouslyFocused?.focus(); + }; + }, [onClose]); + + const confirm = async () => { + setBusy(true); + setError(""); + try { + await request.onConfirm(); + onClose(); + } catch (reason) { + setError(reason instanceof Error ? reason.message : "The action could not be completed."); + setBusy(false); + } + }; + + return ( +
event.target === event.currentTarget && onClose()}> +
+

{request.title}

+
{request.body}
+ {error &&

{error}

} +
+ + +
+
+
+ ); +} + +/** Holds at most one pending confirmation, so a surface only tracks the request itself. */ +export function useConfirm() { + const [request, setRequest] = useState(null); + return { + request, + confirm: setRequest, + dialog: request ? setRequest(null)} /> : null, + }; +} diff --git a/src/Ludarium.Web/src/components/GamepadNavigation.test.ts b/src/Ludarium.Web/src/components/GamepadNavigation.test.ts new file mode 100644 index 0000000..9d32999 --- /dev/null +++ b/src/Ludarium.Web/src/components/GamepadNavigation.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; +import { directionalScore, selectDirectionalIndex, type FocusRect } from "./GamepadNavigation"; + +const rect = (left: number, top: number, width = 100, height = 60): FocusRect => ({ + left, top, width, height, right: left + width, bottom: top + height, +}); + +describe("controller spatial navigation", () => { + it("rejects controls outside the requested direction", () => { + expect(directionalScore(rect(100, 100), rect(0, 100), "right")).toBe(Number.POSITIVE_INFINITY); + expect(directionalScore(rect(100, 100), rect(100, 0), "down")).toBe(Number.POSITIVE_INFINITY); + }); + + it("prefers the nearest control on the same visual row", () => { + const current = rect(0, 0); + const candidates = [rect(130, 10), rect(115, 160), rect(320, 0)]; + expect(selectDirectionalIndex(current, candidates, "right")).toBe(0); + }); + + it("selects the nearest card in a vertical grid move", () => { + const current = rect(120, 0); + const candidates = [rect(0, 100), rect(120, 100), rect(240, 100)]; + expect(selectDirectionalIndex(current, candidates, "down")).toBe(1); + }); +}); diff --git a/src/Ludarium.Web/src/components/GamepadNavigation.tsx b/src/Ludarium.Web/src/components/GamepadNavigation.tsx new file mode 100644 index 0000000..fb2b146 --- /dev/null +++ b/src/Ludarium.Web/src/components/GamepadNavigation.tsx @@ -0,0 +1,226 @@ +import { useEffect, useRef, useState } from "react"; + +export type NavigationDirection = "up" | "down" | "left" | "right"; + +export type FocusRect = Pick; + +export function directionalScore(current: FocusRect, candidate: FocusRect, direction: NavigationDirection) { + const currentX = current.left + current.width / 2; + const currentY = current.top + current.height / 2; + const candidateX = candidate.left + candidate.width / 2; + const candidateY = candidate.top + candidate.height / 2; + const dx = candidateX - currentX; + const dy = candidateY - currentY; + const primary = direction === "left" ? -dx : direction === "right" ? dx : direction === "up" ? -dy : dy; + if (primary <= 2) return Number.POSITIVE_INFINITY; + const secondary = direction === "left" || direction === "right" ? Math.abs(dy) : Math.abs(dx); + // Prefer the closest control in the requested direction while strongly favouring the same row/column. + return primary + secondary * 2.25; +} + +export function selectDirectionalIndex(current: FocusRect, candidates: FocusRect[], direction: NavigationDirection) { + let best = -1; + let score = Number.POSITIVE_INFINITY; + candidates.forEach((candidate, index) => { + const next = directionalScore(current, candidate, direction); + if (next < score) { + best = index; + score = next; + } + }); + return best; +} + +const focusSelector = [ + "a[href]", + "button:not([disabled])", + "input:not([disabled])", + "select:not([disabled])", + "textarea:not([disabled])", + "[tabindex]:not([tabindex='-1'])", +].join(","); + +function visibleFocusable(scope: ParentNode = document) { + return [...scope.querySelectorAll(focusSelector)].filter((element) => { + const style = getComputedStyle(element); + const rect = element.getBoundingClientRect(); + return style.display !== "none" && style.visibility !== "hidden" && rect.width > 0 && rect.height > 0 && + !element.closest("[aria-hidden='true'],[inert]"); + }); +} + +function modalScope() { + return document.querySelector("[role='dialog'][aria-modal='true']") ?? document; +} + +function moveFocus(direction: NavigationDirection) { + const scope = modalScope(); + const candidates = visibleFocusable(scope); + if (!candidates.length) return; + const current = document.activeElement instanceof HTMLElement && candidates.includes(document.activeElement) + ? document.activeElement + : null; + if (!current) { + const pageCandidates = candidates.filter((element) => element.closest("main .page")); + const preferred = pageCandidates.length ? pageCandidates : candidates; + (direction === "up" || direction === "left" ? preferred.at(-1) : preferred[0])?.focus(); + return; + } + if (current instanceof HTMLSelectElement && (direction === "left" || direction === "right")) { + const step = direction === "left" ? -1 : 1; + const next = Math.max(0, Math.min(current.options.length - 1, current.selectedIndex + step)); + if (next !== current.selectedIndex) { + current.selectedIndex = next; + current.dispatchEvent(new Event("change", { bubbles: true })); + } + return; + } + const index = selectDirectionalIndex( + current.getBoundingClientRect(), + candidates.map((element) => element.getBoundingClientRect()), + direction, + ); + if (index >= 0 && candidates[index] !== current) { + candidates[index].focus({ preventScroll: true }); + candidates[index].scrollIntoView({ block: "nearest", inline: "nearest", behavior: "smooth" }); + } +} + +function gameplayOwnsController() { + return Boolean(document.querySelector(".browser-player, .switch-player-shell")); +} + +function pressFocused() { + const active = document.activeElement; + if (active instanceof HTMLButtonElement || active instanceof HTMLAnchorElement || active instanceof HTMLInputElement) + active.click(); +} + +function goBack() { + const modal = document.querySelector("[role='dialog'][aria-modal='true']"); + if (modal) { + dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); + return; + } + if (location.hash && !location.hash.startsWith("#Home")) location.hash = "#Home"; +} + +type RepeatState = { command: string; nextAt: number }; + +export const gamepadPreferenceKey = "ludarium.gamepad"; +export const gamepadPreferenceEvent = "ludarium:gamepad"; + +/** + * Controller navigation is on unless it was turned off. + * + * A controller connected for something else would otherwise move the interface's focus with no way + * to stop it, and the feature itself was invisible until a pad happened to be plugged in. + */ +export function gamepadNavigationEnabled() { + try { return localStorage.getItem(gamepadPreferenceKey) !== "off"; } + catch { return true; } +} + +export function GamepadNavigation() { + const [controller, setController] = useState(""); + const [active, setActive] = useState(false); + const [enabled, setEnabled] = useState(gamepadNavigationEnabled); + const previousButtons = useRef([]); + const repeat = useRef({ command: "", nextAt: 0 }); + + useEffect(() => { + const sync = () => setEnabled(gamepadNavigationEnabled()); + addEventListener(gamepadPreferenceEvent, sync); + return () => removeEventListener(gamepadPreferenceEvent, sync); + }, []); + + useEffect(() => { + if (!enabled) { + setController(""); + document.documentElement.classList.remove("controller-navigation"); + return; + } + let frame = 0; + let activeTimer = 0; + let knownIndex = -1; + const ping = () => { + setActive(true); + window.clearTimeout(activeTimer); + activeTimer = window.setTimeout(() => setActive(false), 900); + }; + const connected = (event: GamepadEvent) => { + knownIndex = event.gamepad.index; + setController(event.gamepad.id || "Gamepad"); + document.documentElement.classList.add("controller-navigation"); + if (!frame) frame = requestAnimationFrame(tick); + }; + const disconnected = (event: GamepadEvent) => { + if (event.gamepad.index === knownIndex) { + knownIndex = -1; + setController(""); + previousButtons.current = []; + document.documentElement.classList.remove("controller-navigation"); + cancelAnimationFrame(frame); + frame = 0; + } + }; + const tick = (now: number) => { + const pads = navigator.getGamepads?.() ?? []; + const pad = pads[knownIndex] ?? [...pads].find(Boolean) ?? null; + if (pad && knownIndex !== pad.index) { + knownIndex = pad.index; + setController(pad.id || "Gamepad"); + document.documentElement.classList.add("controller-navigation"); + } + if (pad && !gameplayOwnsController()) { + const buttons = pad.buttons.map((button) => button.pressed); + const edge = (index: number) => buttons[index] && !previousButtons.current[index]; + const x = Math.abs(pad.axes[0] ?? 0) > 0.62 ? pad.axes[0] : 0; + const y = Math.abs(pad.axes[1] ?? 0) > 0.62 ? pad.axes[1] : 0; + const direction: NavigationDirection | null = buttons[12] || y < 0 ? "up" + : buttons[13] || y > 0 ? "down" + : buttons[14] || x < 0 ? "left" + : buttons[15] || x > 0 ? "right" + : null; + if (direction) { + if (repeat.current.command !== direction || now >= repeat.current.nextAt) { + moveFocus(direction); + repeat.current = { + command: direction, + nextAt: now + (repeat.current.command === direction ? 115 : 330), + }; + ping(); + } + } else { + repeat.current = { command: "", nextAt: 0 }; + } + if (edge(0)) { pressFocused(); ping(); } + if (edge(1)) { goBack(); ping(); } + if (edge(9)) { + dispatchEvent(new KeyboardEvent("keydown", { key: "k", ctrlKey: true, bubbles: true })); + ping(); + } + previousButtons.current = buttons; + } else if (!pad) { + previousButtons.current = []; + } + frame = pad ? requestAnimationFrame(tick) : 0; + }; + addEventListener("gamepadconnected", connected); + addEventListener("gamepaddisconnected", disconnected); + if ([...(navigator.getGamepads?.() ?? [])].some(Boolean)) frame = requestAnimationFrame(tick); + return () => { + cancelAnimationFrame(frame); + window.clearTimeout(activeTimer); + removeEventListener("gamepadconnected", connected); + removeEventListener("gamepaddisconnected", disconnected); + document.documentElement.classList.remove("controller-navigation"); + }; + }, [enabled]); + + if (!enabled || !controller) return null; + return
+ + Controller readyD-pad move · A select · B home · Menu search +
; +} diff --git a/src/Ludarium.Web/src/components/GlobalSearch.test.tsx b/src/Ludarium.Web/src/components/GlobalSearch.test.tsx new file mode 100644 index 0000000..b46dfae --- /dev/null +++ b/src/Ludarium.Web/src/components/GlobalSearch.test.tsx @@ -0,0 +1,55 @@ +// @vitest-environment happy-dom +import "@testing-library/jest-dom/vitest"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { GlobalSearch } from "./GlobalSearch"; + +const { apiMock, apiBlobMock } = vi.hoisted(() => ({ apiMock: vi.fn(), apiBlobMock: vi.fn() })); +vi.mock("../api/client", () => ({ + api: (path: string) => apiMock(path), + apiBlob: (path: string, signal?: AbortSignal) => apiBlobMock(path, signal), + platformName: (platform: string | null) => platform ?? "Unknown", +})); + +describe("GlobalSearch cover geometry", () => { + beforeEach(() => { + Element.prototype.scrollIntoView = vi.fn(); + apiBlobMock.mockReset().mockResolvedValue(null); + apiMock.mockReset().mockImplementation((path: string) => { + if (path === "/platforms") return Promise.resolve([]); + if (path.startsWith("/games?q=")) return Promise.resolve({ + items: [ + { id: "game-vita", title: "Lumines", createdAt: "2026-01-01", version: 1, origin: "Scan" }, + { id: "game-mixed", title: "Shared release", createdAt: "2026-01-01", version: 1, origin: "Scan" }, + ], + page: 1, + pageSize: 8, + total: 2, + }); + if (path.startsWith("/artifacts?q=")) return Promise.resolve({ items: [], page: 1, pageSize: 8, total: 0 }); + if (path === "/releases?ids=game-vita,game-mixed") return Promise.resolve([ + { id: "vita-release", gameId: "game-vita", title: "Lumines", platform: "psvita" }, + { id: "pc-release", gameId: "game-mixed", title: "Shared release", platform: "windows" }, + { id: "ps-release", gameId: "game-mixed", title: "Shared release", platform: "ps5" }, + ]); + throw new Error(`Unexpected API request: ${path}`); + }); + }); + afterEach(() => cleanup()); + + it("profiles a unique release platform and leaves a mixed-platform result unprofiled", async () => { + render(); + fireEvent.change(screen.getByRole("combobox"), { target: { value: "re" } }); + + const vitaOption = (await screen.findByText("Lumines")).closest('[role="option"]'); + const mixedOption = screen.getByText("Shared release").closest('[role="option"]'); + expect(vitaOption?.querySelector(".result-cover")).toHaveAttribute("data-platform", "psvita"); + expect(mixedOption?.querySelector(".result-cover")).not.toHaveAttribute("data-platform"); + expect(screen.getByText("Game · psvita")).toBeInTheDocument(); + + await waitFor(() => { + expect(apiBlobMock).toHaveBeenCalledWith("/games/game-vita/artwork?platform=psvita", expect.anything()); + expect(apiBlobMock).toHaveBeenCalledWith("/games/game-mixed/artwork", expect.anything()); + }); + }); +}); diff --git a/src/Ludarium.Web/src/components/GlobalSearch.tsx b/src/Ludarium.Web/src/components/GlobalSearch.tsx new file mode 100644 index 0000000..f28501b --- /dev/null +++ b/src/Ludarium.Web/src/components/GlobalSearch.tsx @@ -0,0 +1,230 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { api, apiBlob, platformName } from "../api/client"; +import type { Artifact, Game, Page, PlatformSummary, Release } from "../api/types"; +import { href } from "../app/routes"; +import { Icon } from "./Icon"; +import { normalizeCoverPlatform, selectCoverPlatform } from "./retailCover"; + +type Result = { + id: string; + group: string; + title: string; + detail: string; + target: string; + game?: Game; + platform?: string; +}; + +/** + * Search across games, platforms and files. + * + * The results are a combobox listbox, not a list of buttons: focus stays in the input and the active + * option is named by `aria-activedescendant`. Moving focus onto the options instead made Arrow keys + * a one-way trip — a keyboard user could reach the first result and had no way to continue or return. + */ +export function GlobalSearch({ open, onClose }: { open: boolean; onClose: () => void }) { + const [q, setQ] = useState(""); + const [games, setGames] = useState([]); + const [releases, setReleases] = useState([]); + const [files, setFiles] = useState([]); + const [platforms, setPlatforms] = useState([]); + const [busy, setBusy] = useState(false); + const [active, setActive] = useState(0); + const input = useRef(null); + const dialog = useRef(null); + const listbox = useRef(null); + + useEffect(() => { + if (!open) return; + setTimeout(() => input.current?.focus(), 0); + api("/platforms").then(setPlatforms).catch(() => setPlatforms([])); + const previousOverflow = document.body.style.overflow; + document.body.style.overflow = "hidden"; + return () => { document.body.style.overflow = previousOverflow; }; + }, [open]); + + useEffect(() => { + if (!open || q.trim().length < 2) { + setBusy(false); + setGames([]); + setReleases([]); + setFiles([]); + return; + } + setBusy(true); + let live = true; + const timer = setTimeout(async () => { + try { + const gamesRequest = api>(`/games?q=${encodeURIComponent(q)}&page=1&pageSize=8`); + const filesRequest = api>(`/artifacts?q=${encodeURIComponent(q)}&page=1&pageSize=8`); + const foundGames = await gamesRequest; + const ids = foundGames.items.map((game) => game.id).join(","); + const [foundFiles, foundReleases] = await Promise.all([ + filesRequest, + ids ? api(`/releases?ids=${ids}`).catch(() => []) : Promise.resolve([]), + ]); + if (live) { setGames(foundGames.items); setReleases(foundReleases); setFiles(foundFiles.items); } + } catch { + if (live) { setGames([]); setReleases([]); setFiles([]); } + } finally { + if (live) setBusy(false); + } + }, 180); + return () => { live = false; clearTimeout(timer); }; + }, [q, open]); + + const results = useMemo(() => { + const releasePlatforms = new Map(); + for (const release of releases) { + const values = releasePlatforms.get(release.gameId) ?? []; + if (release.platform) values.push(release.platform); + releasePlatforms.set(release.gameId, values); + } + const matchedPlatforms = platforms + .filter((item) => platformName(item.platform).toLowerCase().includes(q.toLowerCase())) + .slice(0, 5); + return [ + ...games.map((game) => { + const platform = selectCoverPlatform(releasePlatforms.get(game.id) ?? []); + return { + id: `game-${game.id}`, group: "Games", title: game.title, + detail: platform ? `Game · ${platformName(platform)}` : "Game", + target: href("Library", "games", game.id), game, platform, + }; + }), + ...matchedPlatforms.map((item) => ({ + id: `platform-${item.platform}`, group: "Platforms", title: platformName(item.platform), + detail: `${item.games} games`, target: href("Library", "platforms", item.platform), + })), + ...files.map((file) => ({ + id: `file-${file.id}`, group: "Files", title: file.relativePath.split("/").pop() ?? file.relativePath, + detail: file.relativePath, target: href("Library", "files", file.id), + })), + ]; + }, [games, releases, platforms, files, q]); + + useEffect(() => { setActive(0); }, [q, results.length]); + + // The active option must stay visible while the caret never leaves the input. + useEffect(() => { + listbox.current?.querySelector('[aria-selected="true"]')?.scrollIntoView({ block: "nearest" }); + }, [active]); + + if (!open) return null; + + const go = (result: Result) => { + if (result.game) sessionStorage.setItem("ludarium:selected-game", JSON.stringify(result.game)); + location.hash = result.target.slice(1); + onClose(); + }; + + const keyboard = (event: React.KeyboardEvent) => { + if (event.key === "Tab" && dialog.current) { + const focusable = [...dialog.current.querySelectorAll("input,button:not([disabled])")]; + if (!focusable.length) return; + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); } + else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); } + return; + } + if (!results.length) return; + if (event.key === "ArrowDown") { event.preventDefault(); setActive((index) => (index + 1) % results.length); } + else if (event.key === "ArrowUp") { event.preventDefault(); setActive((index) => (index - 1 + results.length) % results.length); } + else if (event.key === "Home") { event.preventDefault(); setActive(0); } + else if (event.key === "End") { event.preventDefault(); setActive(results.length - 1); } + else if (event.key === "Enter") { event.preventDefault(); go(results[active]); } + }; + + const expanded = q.trim().length >= 2 && !busy && results.length > 0; + const groups = [...new Set(results.map((result) => result.group))]; + + return ( +
event.target === event.currentTarget && onClose()}> +
+ +
+ {q.trim().length < 2 ? ( +

Type at least two characters. Paste a hash to find an exact file.

+ ) : busy ? ( +

Searching…

+ ) : !results.length ? ( +

No matching games, platforms or files.

+ ) : ( + groups.map((group) => ( +
+ + {results.filter((result) => result.group === group).map((result) => { + const index = results.indexOf(result); + return ( +
{ event.preventDefault(); go(result); }} + onMouseEnter={() => setActive(index)} + > + {result.game + ? + : } + {result.title}{result.detail} +
+ ); + })} +
+ )) + )} +
+ +
+
+ ); +} + +function ResultArtwork({ gameId, title, platform }: { gameId: string; title: string; platform?: string }) { + const [url, setUrl] = useState(null); + useEffect(() => { + const controller = new AbortController(); + let objectUrl: string | null = null; + const coverPlatform = normalizeCoverPlatform(platform); + setUrl(null); + apiBlob(`/games/${gameId}/artwork${coverPlatform ? `?platform=${encodeURIComponent(coverPlatform)}` : ""}`, controller.signal).then((blob) => { + if (controller.signal.aborted || !blob) return; + objectUrl = URL.createObjectURL(blob); + setUrl(objectUrl); + }).catch(() => undefined); + return () => { controller.abort(); if (objectUrl) URL.revokeObjectURL(objectUrl); }; + }, [gameId, platform]); + const coverPlatform = normalizeCoverPlatform(platform); + return ( + + ); +} diff --git a/src/Ludarium.Web/src/components/Icon.tsx b/src/Ludarium.Web/src/components/Icon.tsx new file mode 100644 index 0000000..62e200f --- /dev/null +++ b/src/Ludarium.Web/src/components/Icon.tsx @@ -0,0 +1,34 @@ +export function Icon({ + name, +}: { + name: + | "home" + | "library" + | "wishlist" + | "attention" + | "activity" + | "settings" + | "search" + | "scan" + | "close" + | "menu"; +}) { + const paths = { + home: "M3 11.5 12 4l9 7.5V21a1 1 0 0 1-1 1h-5v-7H9v7H4a1 1 0 0 1-1-1z", + library: "M4 5h16v15H4z M8 5v15 M13 5v15", + wishlist: "M12 21s-8-4.8-8-11a4.5 4.5 0 0 1 8-2.8A4.5 4.5 0 0 1 20 10c0 6.2-8 11-8 11z", + attention: "M12 3 2.8 20h18.4z M12 9v5 M12 17h.01", + activity: "M3 12h4l2-5 4 10 3-7 2 2h3", + settings: + "M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8z M12 2v3 M12 19v3 M2 12h3 M19 12h3 M5 5l2 2 M17 17l2 2 M19 5l-2 2 M7 17l-2 2", + search: "M10.5 4a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13z M15.5 15.5 21 21", + scan: "M4 8V4h4 M16 4h4v4 M20 16v4h-4 M8 20H4v-4 M8 12h8", + close: "M5 5l14 14 M19 5 5 19", + menu: "M4 7h16 M4 12h16 M4 17h16", + }; + return ( + + ); +} diff --git a/src/Ludarium.Web/src/components/Overlay.test.tsx b/src/Ludarium.Web/src/components/Overlay.test.tsx new file mode 100644 index 0000000..60d3ad5 --- /dev/null +++ b/src/Ludarium.Web/src/components/Overlay.test.tsx @@ -0,0 +1,38 @@ +// @vitest-environment happy-dom +import { useState } from "react"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; +import { Overlay } from "./Overlay"; + +function Harness() { + const [open, setOpen] = useState(true); + return <> +
+ + {open && setOpen(false)}>} +
+ + ; +} + +describe("Overlay", () => { + afterEach(cleanup); + + it("makes every background branch inert while the modal is open and restores it on close", () => { + render(); + + const background = screen.getByText("Background action"); + const navigation = document.querySelector('nav[aria-label="Primary"]')!; + expect(background).toHaveProperty("inert", true); + expect(navigation).toHaveProperty("inert", true); + expect(background).toHaveAttribute("aria-hidden", "true"); + expect(navigation).toHaveAttribute("aria-hidden", "true"); + expect(screen.getByRole("button", { name: "Dialog action" })).not.toHaveProperty("inert", true); + + fireEvent.click(screen.getByRole("button", { name: "Close" })); + + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Background action" })).toHaveProperty("inert", false); + expect(screen.getByRole("navigation", { name: "Primary" })).not.toHaveAttribute("aria-hidden"); + }); +}); diff --git a/src/Ludarium.Web/src/components/Overlay.tsx b/src/Ludarium.Web/src/components/Overlay.tsx new file mode 100644 index 0000000..9629992 --- /dev/null +++ b/src/Ludarium.Web/src/components/Overlay.tsx @@ -0,0 +1,148 @@ +import { useEffect, useRef } from "react"; +import type { ReactNode } from "react"; +import { Icon } from "./Icon"; +let overlayCounter = 0; + +export function Overlay({ + title, + onClose, + children, + kind = "drawer", + returnFocus, + localizeTitle = true, +}: { + title: string; + onClose: () => void; + children: ReactNode; + kind?: "drawer" | "dialog"; + returnFocus?: HTMLElement | null; + localizeTitle?: boolean; +}) { + const ref = useRef(null); + const onCloseRef = useRef(onClose); + const returnFocusRef = useRef(returnFocus); + useEffect(() => { onCloseRef.current = onClose; }, [onClose]); + useEffect(() => { returnFocusRef.current = returnFocus; }, [returnFocus]); + // A fixed id would repeat if two overlays were ever open, leaving both titles ambiguous. + const titleId = useRef(`overlay-title-${++overlayCounter}`); + useEffect(() => { + const previous = document.body.style.overflow; + const hiddenSiblings: Array<{ element: HTMLElement; inert: boolean; ariaHidden: string | null }> = []; + let branch: HTMLElement | null = ref.current; + while (branch?.parentElement) { + const parent: HTMLElement = branch.parentElement; + for (const sibling of parent.children) { + if (!(sibling instanceof HTMLElement) || sibling === branch) continue; + hiddenSiblings.push({ element: sibling, inert: sibling.inert, ariaHidden: sibling.getAttribute("aria-hidden") }); + sibling.inert = true; + sibling.setAttribute("aria-hidden", "true"); + } + branch = parent; + if (parent === document.body) break; + } + document.body.style.overflow = "hidden"; + ref.current?.focus(); + const key = (e: KeyboardEvent) => { + if (e.key === "Escape") onCloseRef.current(); + if (e.key === "Tab" && ref.current) { + const focusable = [ + ...ref.current.querySelectorAll( + 'button:not([disabled]),a[href],input:not([disabled]),select:not([disabled]),summary,[tabindex="0"]', + ), + ]; + if (!focusable.length) return; + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + if (e.shiftKey && document.activeElement === first) { + e.preventDefault(); + last.focus(); + } else if (!e.shiftKey && document.activeElement === last) { + e.preventDefault(); + first.focus(); + } + } + }; + addEventListener("keydown", key); + return () => { + document.body.style.overflow = previous; + removeEventListener("keydown", key); + for (const { element, inert, ariaHidden } of hiddenSiblings.reverse()) { + element.inert = inert; + if (ariaHidden === null) element.removeAttribute("aria-hidden"); + else element.setAttribute("aria-hidden", ariaHidden); + } + returnFocusRef.current?.focus(); + }; + }, []); + return ( +
e.target === e.currentTarget && onClose()} + > +
+
+

{title}

+ +
+
{children}
+
+
+ ); +} +export function Empty({ + title, + text, + action, + visual = "archive", +}: { + title: string; + text: string; + action?: ReactNode; + visual?: "archive" | "library" | "attention" | "activity"; +}) { + return ( +
+ +

{title}

+

{text}

+ {action} +
+ ); +} +export function Loading() { + return ( +
+ + Loading… +
+ ); +} +export function LoadFailure({ + title = "This section is temporarily unavailable", + text, + onRetry, +}: { + title?: string; + text: string; + onRetry: () => void; +}) { + return ( + Try again} + /> + ); +} diff --git a/src/Ludarium.Web/src/components/PlatformVisual.test.tsx b/src/Ludarium.Web/src/components/PlatformVisual.test.tsx new file mode 100644 index 0000000..0fa8c0f --- /dev/null +++ b/src/Ludarium.Web/src/components/PlatformVisual.test.tsx @@ -0,0 +1,41 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { PlatformVisual, platformPresentation } from "./PlatformVisual"; + +describe("PlatformVisual", () => { + it("maps every platform in the dedicated presentation matrix to unique hardware artwork", () => { + const platforms = [ + "ps5", "ps4", "ps3", "windows", "switch", "psx", "3ds", "nds", "psvita", "wii", "wiiu", + "gamecube", "n64", "psp", "gba", "ps2", "gbc", "gb", "nes", "snes", "xbox", "xbox360", + "xboxone", "xboxseries", + ]; + const assets = platforms.map((platform) => platformPresentation(platform).asset); + + expect(new Set(assets).size).toBe(platforms.length); + expect(assets.every((asset) => /\.(?:png|jpg)$/.test(asset))).toBe(true); + }); + + it("normalizes known platform aliases without falling back to PC artwork", () => { + expect(platformPresentation("wii-u").asset).toBe("wiiu-cutout.png"); + expect(platformPresentation("xbox-360").asset).toBe("xbox360-cutout.png"); + expect(platformPresentation("xbox-one").asset).toBe("xboxone-cutout.png"); + expect(platformPresentation("xbox-series").asset).toBe("xboxseries-cutout.png"); + expect(platformPresentation("future-console").asset).toBe("generic-platform.svg"); + }); + + it("renders dedicated local artwork with a visible platform classification", () => { + const { container } = render(); + + expect(container.querySelector("img")).toHaveAttribute("src", "/platforms/n64.png"); + expect(container.querySelector("img")).toHaveAttribute("alt", ""); + expect(screen.getByText("Cartridge console")).toBeInTheDocument(); + expect(screen.getByText("Fifth generation")).toBeInTheDocument(); + }); + + it("keeps compact navigation artwork free of duplicate captions", () => { + const { container } = render(); + + expect(container.querySelector("img")).toHaveAttribute("src", "/platforms/switch-cutout.png"); + expect(screen.queryByText("Hybrid console")).not.toBeInTheDocument(); + }); +}); diff --git a/src/Ludarium.Web/src/components/PlatformVisual.tsx b/src/Ludarium.Web/src/components/PlatformVisual.tsx new file mode 100644 index 0000000..3abe7df --- /dev/null +++ b/src/Ludarium.Web/src/components/PlatformVisual.tsx @@ -0,0 +1,75 @@ +import type { CSSProperties } from "react"; + +export type PlatformPresentation = { + asset: string; + hardware: string; + category: string; + era: string; + accent: string; + glow: string; +}; + +const presentations: Record = { + ps5: { asset: "ps5.png", hardware: "PlayStation 5", category: "Home console", era: "Current generation", accent: "#5e9cff", glow: "#315fdb" }, + ps4: { asset: "ps4-cutout.png", hardware: "PlayStation 4", category: "Home console", era: "Eighth generation", accent: "#5e9cff", glow: "#315fdb" }, + ps3: { asset: "ps3-cutout.png", hardware: "PlayStation 3", category: "Home console", era: "Seventh generation", accent: "#799fff", glow: "#334f9c" }, + windows: { asset: "windows.jpg", hardware: "Gaming PC", category: "PC", era: "Desktop library", accent: "#42c8ec", glow: "#087fae" }, + switch: { asset: "switch-cutout.png", hardware: "Nintendo Switch", category: "Hybrid console", era: "Nintendo Switch", accent: "#ff5964", glow: "#d72638" }, + psx: { asset: "psx.png", hardware: "Original PlayStation", category: "Home console", era: "Fifth generation", accent: "#8da8dc", glow: "#415d91" }, + "3ds": { asset: "3ds.png", hardware: "Nintendo 3DS", category: "Dual-screen handheld", era: "Nintendo 3DS", accent: "#48b8e8", glow: "#176eae" }, + nds: { asset: "nds.png", hardware: "Nintendo DS Lite", category: "Dual-screen handheld", era: "Nintendo DS", accent: "#e2e8f3", glow: "#7183a7" }, + psvita: { asset: "psvita.png", hardware: "PlayStation Vita", category: "Handheld", era: "PlayStation Vita", accent: "#579bff", glow: "#234fbd" }, + wii: { asset: "wii.png", hardware: "Nintendo Wii", category: "Motion console", era: "Seventh generation", accent: "#72d7ef", glow: "#318ca8" }, + wiiu: { asset: "wiiu-cutout.png", hardware: "Nintendo Wii U", category: "Dual-screen console", era: "Eighth generation", accent: "#57c9ec", glow: "#237f9e" }, + "wii-u": { asset: "wiiu-cutout.png", hardware: "Nintendo Wii U", category: "Dual-screen console", era: "Eighth generation", accent: "#57c9ec", glow: "#237f9e" }, + gamecube: { asset: "gamecube-cutout.png", hardware: "Nintendo GameCube", category: "Home console", era: "Sixth generation", accent: "#a48cff", glow: "#5c42b5" }, + n64: { asset: "n64.png", hardware: "Nintendo 64", category: "Cartridge console", era: "Fifth generation", accent: "#ffcc45", glow: "#b45b2d" }, + psp: { asset: "psp.png", hardware: "PlayStation Portable", category: "Handheld", era: "PlayStation Portable", accent: "#8eb7ff", glow: "#3c58a1" }, + gba: { asset: "gba.png", hardware: "Game Boy Advance", category: "Handheld", era: "Sixth generation", accent: "#9f91ff", glow: "#6045c8" }, + ps2: { asset: "ps2.png", hardware: "PlayStation 2", category: "Home console", era: "Sixth generation", accent: "#738dff", glow: "#263eaa" }, + gbc: { asset: "gbc-cutout.png", hardware: "Game Boy Color", category: "Handheld", era: "Game Boy Color", accent: "#a989ff", glow: "#5836a7" }, + gb: { asset: "gb-cutout-v2.png", hardware: "Game Boy", category: "Handheld", era: "Fourth generation", accent: "#a7b47a", glow: "#58643c" }, + nes: { asset: "nes.png", hardware: "Nintendo Entertainment System", category: "Cartridge console", era: "8-bit era", accent: "#e45b65", glow: "#963842" }, + snes: { asset: "snes-cutout-v2.png", hardware: "Super Nintendo", category: "Cartridge console", era: "16-bit era", accent: "#b79ae8", glow: "#665292" }, + xbox: { asset: "xbox-cutout.png", hardware: "Xbox", category: "Home console", era: "Sixth generation", accent: "#6fd15b", glow: "#2e8d38" }, + xbox360: { asset: "xbox360-cutout.png", hardware: "Xbox 360", category: "Home console", era: "Seventh generation", accent: "#83d96f", glow: "#3b9144" }, + "xbox-360": { asset: "xbox360-cutout.png", hardware: "Xbox 360", category: "Home console", era: "Seventh generation", accent: "#83d96f", glow: "#3b9144" }, + xboxone: { asset: "xboxone-cutout.png", hardware: "Xbox One", category: "Home console", era: "Eighth generation", accent: "#77d56a", glow: "#32873b" }, + "xbox-one": { asset: "xboxone-cutout.png", hardware: "Xbox One", category: "Home console", era: "Eighth generation", accent: "#77d56a", glow: "#32873b" }, + xboxseries: { asset: "xboxseries-cutout.png", hardware: "Xbox Series X|S", category: "Home console", era: "Current generation", accent: "#76db6d", glow: "#2d8739" }, + "xbox-series": { asset: "xboxseries-cutout.png", hardware: "Xbox Series X|S", category: "Home console", era: "Current generation", accent: "#76db6d", glow: "#2d8739" }, +}; + +const fallback: PlatformPresentation = { + asset: "generic-platform.svg", + hardware: "Gaming platform", + category: "Game platform", + era: "Indexed collection", + accent: "#82a7ff", + glow: "#415d91", +}; + +export function platformPresentation(platform: string) { + return presentations[platform.toLowerCase()] ?? fallback; +} + +export function PlatformVisual({ platform, compact = false }: { platform: string; compact?: boolean }) { + const presentation = platformPresentation(platform); + const style = { + "--platform-accent": presentation.accent, + "--platform-glow": presentation.glow, + } as CSSProperties; + + return ( + + + ); +} diff --git a/src/Ludarium.Web/src/components/retailCover.test.ts b/src/Ludarium.Web/src/components/retailCover.test.ts new file mode 100644 index 0000000..15d3e1d --- /dev/null +++ b/src/Ludarium.Web/src/components/retailCover.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; +import { normalizeCoverPlatform, selectCoverPlatform } from "./retailCover"; + +describe("retail cover platform evidence", () => { + it("normalizes an explicit platform and omits empty evidence", () => { + expect(normalizeCoverPlatform(" PSVita ")).toBe("psvita"); + expect(normalizeCoverPlatform(" ")).toBeUndefined(); + expect(normalizeCoverPlatform(null)).toBeUndefined(); + }); + + it("uses one unique release platform but never guesses across releases", () => { + expect(selectCoverPlatform(["PS5", "ps5", null])).toBe("ps5"); + expect(selectCoverPlatform(["windows", "ps5"])).toBeUndefined(); + expect(selectCoverPlatform([])).toBeUndefined(); + expect(selectCoverPlatform(["windows", "ps5"], "switch")).toBe("switch"); + }); +}); diff --git a/src/Ludarium.Web/src/components/retailCover.ts b/src/Ludarium.Web/src/components/retailCover.ts new file mode 100644 index 0000000..f0456e0 --- /dev/null +++ b/src/Ludarium.Web/src/components/retailCover.ts @@ -0,0 +1,31 @@ +/** + * Platform ids become CSS evidence only when they contain an actual value. + * Leaving the attribute absent is intentional: an empty data attribute would + * activate the generic retail profile and pretend that an unknown case is 5:7. + */ +export function normalizeCoverPlatform(platform: string | null | undefined) { + const normalized = platform?.trim().toLowerCase(); + return normalized || undefined; +} + +/** + * A mixed-platform game has no honest default cover. Callers can provide a + * platform selected by the user; otherwise only one unique release platform + * is enough evidence to select retail geometry. + */ +export function selectCoverPlatform( + platforms: Iterable, + preferred?: string | null, +) { + const selected = normalizeCoverPlatform(preferred); + if (selected) return selected; + + let only: string | undefined; + for (const platform of platforms) { + const normalized = normalizeCoverPlatform(platform); + if (!normalized) continue; + if (only && only !== normalized) return undefined; + only = normalized; + } + return only; +} diff --git a/src/Ludarium.Web/src/features/OperationalRecovery.test.tsx b/src/Ludarium.Web/src/features/OperationalRecovery.test.tsx new file mode 100644 index 0000000..b3fa19a --- /dev/null +++ b/src/Ludarium.Web/src/features/OperationalRecovery.test.tsx @@ -0,0 +1,55 @@ +// @vitest-environment happy-dom +import "@testing-library/jest-dom/vitest"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { Activity } from "./activity/Activity"; +import { Attention } from "./attention/Attention"; +import { Settings } from "./settings/Settings"; + +const { apiMock } = vi.hoisted(() => ({ apiMock: vi.fn() })); +vi.mock("../api/client", () => ({ + api: (path: string, options?: unknown) => apiMock(path, options), + apiUpload: vi.fn(), + bytes: () => "1 B", + platformName: (value: string) => value, +})); + +describe("operational recovery states", () => { + beforeEach(() => apiMock.mockReset()); + afterEach(() => cleanup()); + + it("replaces a failed Activity spinner with an actionable retry", async () => { + apiMock.mockRejectedValueOnce(new Error("Activity service unavailable")); + render(); + + expect(await screen.findByText("Activity couldn’t be loaded")).toBeInTheDocument(); + expect(screen.getByText("Activity service unavailable")).toBeInTheDocument(); + + apiMock.mockImplementation((path: string) => { + if (path === undefined) return Promise.resolve(undefined); + if (path === "/scans" || path === "/libraries" || path === "/libraries/health" || path === "/jobs") return Promise.resolve([]); + if (path === "/system/status") return Promise.resolve({ hashedArtifacts: 0, presentArtifacts: 0 }); + throw new Error(`Unexpected request: ${path}`); + }); + fireEvent.click(screen.getByRole("button", { name: "Try again" })); + expect(await screen.findByRole("heading", { name: "Scan all libraries" })).toBeInTheDocument(); + }); + + it("offers retry when Attention cannot load its first evidence set", async () => { + apiMock.mockRejectedValueOnce(new Error("Evidence service unavailable")); + render(); + + expect(await screen.findByText("Attention couldn’t be loaded")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Try again" })).toBeInTheDocument(); + }); + + it("offers retry inside a failed Settings section", async () => { + apiMock.mockRejectedValueOnce(new Error("Capability service unavailable")); + render(); + + expect(await screen.findByText("Advanced capabilities couldn’t be loaded")).toBeInTheDocument(); + apiMock.mockResolvedValueOnce({ sourceMutation: false, executableLaunch: false, telemetry: false, formats: [] }); + fireEvent.click(screen.getByRole("button", { name: "Try again" })); + await waitFor(() => expect(screen.getByText("Source mutation")).toBeInTheDocument()); + }); +}); diff --git a/src/Ludarium.Web/src/features/activity/Activity.snapshots.test.tsx b/src/Ludarium.Web/src/features/activity/Activity.snapshots.test.tsx new file mode 100644 index 0000000..2c2da98 --- /dev/null +++ b/src/Ludarium.Web/src/features/activity/Activity.snapshots.test.tsx @@ -0,0 +1,57 @@ +// @vitest-environment happy-dom +import "@testing-library/jest-dom/vitest"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { Activity } from "./Activity"; + +const { apiMock } = vi.hoisted(() => ({ apiMock: vi.fn() })); +vi.mock("../../api/client", () => ({ + api: (path: string, options?: unknown) => apiMock(path, options), + apiDownload: vi.fn(), + bytes: () => "0 B", +})); + +const status = (hashedArtifacts: number) => ({ + hashedArtifacts, + presentArtifacts: 12, +}); + +describe("Activity integrity snapshots", () => { + beforeEach(() => apiMock.mockReset()); + afterEach(() => cleanup()); + + it("blocks an empty integrity baseline and names the scan prerequisite", async () => { + apiMock.mockImplementation((path: string) => path === undefined + ? Promise.resolve(undefined) + : path === "/snapshots" + ? Promise.resolve([]) + : path === "/system/status" + ? Promise.resolve(status(0)) + : Promise.reject(new Error(`Unexpected request: ${path}`))); + + render(); + + expect(await screen.findByText("No hashed files yet.")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Create snapshot" })).toBeDisabled(); + expect(screen.getByRole("link", { name: "Open scans" })).toHaveAttribute("href", "#Activity?view=scans"); + }); + + it("allows a hashed baseline and surfaces a server-side race refusal", async () => { + apiMock.mockImplementation((path: string, options?: { method?: string }) => { + if (path === undefined) return Promise.resolve(undefined); + if (path === "/snapshots" && options?.method === "POST") + return Promise.reject(new Error("Run an integrity scan first.")); + if (path === "/snapshots") return Promise.resolve([]); + if (path === "/system/status") return Promise.resolve(status(1)); + return Promise.reject(new Error(`Unexpected request: ${path}`)); + }); + + render(); + const button = await screen.findByRole("button", { name: "Create snapshot" }); + expect(button).toBeEnabled(); + fireEvent.change(screen.getByLabelText("Snapshot name"), { target: { value: "baseline" } }); + fireEvent.click(button); + expect(await screen.findByText(/Run an integrity scan first/)).toBeInTheDocument(); + await waitFor(() => expect(apiMock).toHaveBeenCalledWith("/snapshots", expect.objectContaining({ method: "POST" }))); + }); +}); diff --git a/src/Ludarium.Web/src/features/activity/Activity.tsx b/src/Ludarium.Web/src/features/activity/Activity.tsx new file mode 100644 index 0000000..880853c --- /dev/null +++ b/src/Ludarium.Web/src/features/activity/Activity.tsx @@ -0,0 +1,595 @@ +import { useEffect, useState } from "react"; +import { api, apiDownload, bytes } from "../../api/client"; +import type { BackgroundJob, Library, LibraryHealth, Scan, Snapshot, Status } from "../../api/types"; +import { Empty, LoadFailure, Loading } from "../../components/Overlay"; +import { ArchivePipeline } from "../../components/ArchivePipeline"; +const tabs = [ + ["scans", "Scans"], + ["history", "History"], + ["snapshots", "Snapshots"], + ["exports", "Exports"], +]; +export function Activity({ section }: { section: string }) { + const view = tabs.some((x) => x[0] === section) ? section : "scans"; + return ( + <> +
+
+

Activity

+

+ Start safe scans and follow what changed without needing to + understand internal jobs. +

+
+
+ + {view === "scans" ? ( + + ) : view === "history" ? ( + + ) : view === "snapshots" ? ( + + ) : ( + + )} + + ); +} +function Scans() { + const [scans, setScans] = useState(null); + const [libraries, setLibraries] = useState([]); + const [libraryHealth, setLibraryHealth] = useState([]); + const [catalogStatus, setCatalogStatus] = useState(null); + const [jobs, setJobs] = useState([]); + const [mode, setMode] = useState("Quick"); + const [error, setError] = useState(""); + const [loadError, setLoadError] = useState(""); + const [reloadVersion, setReloadVersion] = useState(0); + const [startingAll, setStartingAll] = useState(false); + const [startingLibrary, setStartingLibrary] = useState(null); + const [activeAction, setActiveAction] = useState(null); + const [showAllJobs, setShowAllJobs] = useState(false); + const load = async (signal?: AbortSignal) => { + try { + const [s, l, h, j, status] = await Promise.all([api("/scans", { signal }), api("/libraries", { signal }), api("/libraries/health", { signal }), api("/jobs", { signal }), api("/system/status", { signal })]); + if (!signal?.aborted) { + setScans(s); + setLibraries(l); + setLibraryHealth(h); + setJobs(j); + setCatalogStatus(status); + setLoadError(""); + } + } catch (nextError) { + if (!signal?.aborted) setLoadError(nextError instanceof Error ? nextError.message : "Activity could not be refreshed."); + } + }; + useEffect(() => { + const controller = new AbortController(); + let timer = 0; + const poll = async () => { + await load(controller.signal); + if (!controller.signal.aborted) timer = window.setTimeout(poll, 2000); + }; + void poll(); + return () => { controller.abort(); window.clearTimeout(timer); }; + }, [reloadVersion]); + async function startAll() { + if (startingAll) return; + setStartingAll(true); + setError(""); + try { + await Promise.all( + libraries + .filter((l) => l.enabled && l.isAvailable && l.hasEntries !== false) + .map((l) => + api(`/libraries/${l.id}/scans`, { + method: "POST", + headers: { "Idempotency-Key": `activity-${l.id}-${Date.now()}` }, + body: JSON.stringify({ mode }), + }), + ), + ); + await load(); + } catch (e) { + setError(e instanceof Error ? e.message : "Could not start scan"); + } finally { + setStartingAll(false); + } + } + async function action(id: string, name: string) { + const key = `scan-${id}-${name}`; + if (activeAction) return; + setActiveAction(key); + setError(""); + try { + await api(`/scans/${id}/${name}`, { method: "POST" }); + await load(); + } catch (nextError) { + setError(nextError instanceof Error ? nextError.message : `Could not ${name} scan`); + } finally { + setActiveAction(null); + } + } + async function jobAction(id: string, name: "cancel" | "retry") { + const key = `job-${id}-${name}`; + if (activeAction) return; + setActiveAction(key); + setError(""); + try { + await api(`/jobs/${id}/${name}`, { method: "POST" }); + await load(); + } catch (nextError) { + setError(nextError instanceof Error ? nextError.message : `Could not ${name} job`); + } finally { + setActiveAction(null); + } + } + if (loadError && !scans) return setReloadVersion((value) => value + 1)} />; + if (!scans) return ; + const activeJobs = jobs.filter((job) => ["Queued", "Leased", "Paused"].includes(job.state)); + const inactiveJobs = jobs.filter((job) => !["Queued", "Leased", "Paused"].includes(job.state)); + const visibleJobs = showAllJobs + ? jobs + : [...activeJobs, ...inactiveJobs].slice(0, Math.max(6, activeJobs.length)); + const staleAfter = 14 * 24 * 60 * 60 * 1000; + const currentScan = scans.find((scan) => ["Discovering", "Processing", "Paused"].includes(scan.state)); + const pipelineState = currentScan?.state === "Paused" ? "paused" : currentScan ? "running" : scans.some((scan) => scan.state === "Failed") ? "warning" : scans.some((scan) => scan.state === "Completed") ? "complete" : "idle"; + const pipelineStage = currentScan?.stage?.toLowerCase().includes("hash") ? 2 : currentScan?.state === "Processing" ? 3 : currentScan ? 1 : pipelineState === "complete" ? 4 : 0; + return ( + <> +
+
+

Scan all libraries

+

+ {mode === "Quick" + ? "Updates names, paths and file types. Best for everyday use." + : mode === "Deep" + ? "Adds hashes and deeper static inspection. Takes longer." + : "Hashes files that still lack a cryptographic identity, then checks them for unexpected changes."} +

+
+ + +
+
+
SAFE DISCOVERY

From source to playable record

The source stays outside Ludarium’s writable space. Every step adds evidence without changing the original.

+ +
+ {loadError &&
Live updates paused. {loadError}
} + {error && ( +

+ {error} +

+ )} +
+ {libraries.map((l) => { + const latest = scans.find((s) => s.libraryId === l.id); + const indexedArtifacts = libraryHealth.find((item) => item.libraryId === l.id)?.artifacts ?? 0; + const emptySource = l.hasEntries === false; + return ( +
+ +
+ {l.name} + + {emptySource + ? indexedArtifacts > 0 + ? `Empty source · ${indexedArtifacts.toLocaleString()} indexed files protected` + : "Empty source · add content, then verify" + : l.isAvailable + ? l.isReadOnly + ? "Online · read-only" + : "Online · safety check needed" + : "Offline"} + +
+ + {latest ? `${latest.mode} · ${latest.state}` : "Never scanned"} + + +
+ ); + })} +
+ {jobs.length > 0 && ( + <> +
+
+

Background work

+

{activeJobs.length ? `${activeJobs.length} active · ${jobs.length} total` : `Latest automation · ${jobs.length} recorded`}

+
+ {jobs.length > 6 && ( + + )} +
+
+ {visibleJobs.map((job) => ( +
+
+ {job.kind === "ArtworkEnrichment" ? "Artwork enrichment" : job.kind === "MetadataEnrichment" ? "Metadata enrichment" : job.kind} + {job.state === "Leased" ? "Running" : job.state} +
+

{job.itemsCompleted.toLocaleString()} of {job.itemsTotal?.toLocaleString() ?? "pending"} items

+
+ Attempt {job.attempts} of {job.maxAttempts} · {formatDuration(new Date(job.updatedAt).getTime() - new Date(job.createdAt).getTime())}{job.error ? ` · ${job.error}` : ""} +
+ {["Queued", "Leased", "Paused"].includes(job.state) && } + {["Failed", "DeadLetter", "Cancelled"].includes(job.state) && } +
+
+ ))} +
+ + )} +
+
+

Recent scans

+

The latest runs stay concise here; use History for the full timeline.

+
+ {scans.length > 8 && View history} +
+
+

Scan coverage

{catalogStatus ? `${catalogStatus.hashedArtifacts.toLocaleString()} of ${catalogStatus.presentArtifacts.toLocaleString()} present files have a cryptographic identity.` : "Checking cryptographic identity coverage…"} Use an integrity scan to establish hashes before relying on exact duplicates or manifests.

+
+ {libraries.map((library) => { + const health = libraryHealth.find((item) => item.libraryId === library.id); + const last = health?.lastIncrementalScan ?? health?.lastFullScan ?? null; + const stale = !last || Date.now() - new Date(last).getTime() > staleAfter; + return
{library.name}{library.hasEntries === false ? "Empty source — scans are paused" : health?.lastFullScan ? `Full scan ${new Date(health.lastFullScan).toLocaleDateString()}` : "No full scan recorded"} · {last ? `last update ${new Date(last).toLocaleDateString()}` : "never scanned"}{library.hasEntries === false ? "Waiting for content" : stale ? "Refresh due" : "Current"}
; + })} +
+
+ {!scans.length ? ( + + ) : ( +
+ {scans.slice(0, 8).map((s) => ( +
+
+ {s.mode === "Deep" ? "Full" : s.mode} scan + + {s.state} + +
+

+ {libraries.find((l) => l.id === s.libraryId)?.name || "Library"}{" "} + · {s.itemsCompleted.toLocaleString()} files +

+
+ +
+ {bytes(s.bytesCompleted)} checked +
+ {s.newItems.toLocaleString()} new + {s.changedItems.toLocaleString()} changed + {s.movedItems.toLocaleString()} moved + {s.deletedItems.toLocaleString()} deleted + {s.startedAt && s.finishedAt && {formatDuration(new Date(s.finishedAt).getTime() - new Date(s.startedAt).getTime())}} +
+
+ Technical progress +

+ {s.stage} + {s.error ? ` · ${s.error}` : ""} +

+
+
+ {["Discovering", "Processing"].includes(s.state) && ( + <> + + + + )} + {s.state === "Paused" && ( + + )} +
+
+ ))} +
+ )} + + ); +} + +function formatDuration(milliseconds: number) { + const seconds = Math.max(0, Math.round(milliseconds / 1000)); + if (seconds < 60) return `${seconds}s`; + const minutes = Math.floor(seconds / 60); + return `${minutes}m ${seconds % 60}s`; +} +function History() { + const [scans, setScans] = useState(null); + const [libraries, setLibraries] = useState([]); + const [showHistoricalFailures, setShowHistoricalFailures] = useState(false); + const [loadError, setLoadError] = useState(""); + const [reloadVersion, setReloadVersion] = useState(0); + useEffect(() => { + const controller = new AbortController(); + setLoadError(""); + Promise.all([api("/scans", { signal: controller.signal }), api("/libraries", { signal: controller.signal })]).then( + ([history, roots]) => { + setScans(history); + setLibraries(roots); + }, + ).catch((error) => { if (!controller.signal.aborted) setLoadError(error instanceof Error ? error.message : "Scan history could not be loaded."); }); + return () => controller.abort(); + }, [reloadVersion]); + if (loadError && !scans) return setReloadVersion((value) => value + 1)} />; + if (!scans) return ; + const finished = scans.filter((scan) => + ["Completed", "Failed", "Cancelled"].includes(scan.state), + ); + const historicalFailures = finished.filter((scan) => scan.state === "Failed" && scan.createdAt && Date.now() - new Date(scan.createdAt).getTime() > 14 * 24 * 60 * 60 * 1000); + const visibleFinished = showHistoricalFailures ? finished : finished.filter((scan) => !historicalFailures.some((legacy) => legacy.id === scan.id)); + return ( + <> +
+
+

Scan history

+

Recent completed and interrupted runs, with their source and outcome.

+
+ {historicalFailures.length > 0 && } +
+ {!visibleFinished.length ? ( + + ) :
+ {visibleFinished + .map((s) => ( +
+ + + + {libraries.find((library) => library.id === s.libraryId)?.name ?? "Removed library"} · {s.mode === "Deep" ? "Full" : s.mode} scan + + + {s.state} · {s.itemsCompleted.toLocaleString()} files · {bytes(s.bytesCompleted)} + {s.startedAt && s.finishedAt + ? ` · ${formatDuration(new Date(s.finishedAt).getTime() - new Date(s.startedAt).getTime())}` + : ""} + + {s.finishedAt ? new Date(s.finishedAt).toLocaleString() : "No finish time recorded"} + {s.error && {s.error}} + +
+ ))} +
} + + ); +} +function Snapshots() { + const [items, setItems] = useState(null); + const [catalogStatus, setCatalogStatus] = useState(null); + const [name, setName] = useState(""); + const [loadError, setLoadError] = useState(""); + const [reloadVersion, setReloadVersion] = useState(0); + const [creating, setCreating] = useState(false); + const load = async (signal?: AbortSignal) => { + try { + const [nextItems, nextStatus] = await Promise.all([ + api("/snapshots", { signal }), + api("/system/status", { signal }), + ]); + if (!signal?.aborted) { + setItems(nextItems); + setCatalogStatus(nextStatus); + setLoadError(""); + } + } catch (error) { + if (!signal?.aborted) setLoadError(error instanceof Error ? error.message : "Snapshots could not be loaded."); + } + }; + useEffect(() => { + const controller = new AbortController(); + void load(controller.signal); + return () => controller.abort(); + }, [reloadVersion]); + return ( + <> +
+
+

Integrity snapshots

+

+ Keep immutable SHA-256 reference points and compare archive changes later. +

+
+
{ + e.preventDefault(); + if (creating) return; + setCreating(true); setLoadError(""); + try { + await api("/snapshots", { method: "POST", body: JSON.stringify({ name }) }); + setName(""); + await load(); + } catch (error) { setLoadError(error instanceof Error ? error.message : "The snapshot could not be created."); } + finally { setCreating(false); } + }} + > + setName(e.target.value)} + placeholder="Snapshot name" + /> + +
+
+ {catalogStatus?.hashedArtifacts === 0 && ( +
+ No hashed files yet. Run a full or integrity scan before creating a trustworthy snapshot. + Open scans +
+ )} + {loadError && !items ? ( + setReloadVersion((value) => value + 1)} /> + ) : loadError ? ( +
Snapshot action failed. {loadError}
+ ) : !items ? ( + + ) : !items.length ? ( + + ) : ( +
+ {items.map((s) => ( +
+ + {s.name} + + {new Date(s.createdAt).toLocaleString()} ·{" "} + {s.items.length.toLocaleString()} hashed files{s.items.length === 0 ? " · legacy empty snapshot (not usable as a baseline)" : ""} + + +
+ ))} +
+ )} + + ); +} +function Exports() { + const [message, setMessage] = useState(""); + const [error, setError] = useState(""); + const [busy, setBusy] = useState(null); + async function createExport(format: string, label: string) { + setBusy(format); + setMessage(""); + setError(""); + try { + const result = await api<{ fileName: string; format: string; count: number; createdAt: string }>("/exports", { + method: "POST", + body: JSON.stringify({ format }), + }); + setMessage(`Created ${label}: ${result.fileName} · ${result.count.toLocaleString()} records`); + } catch (reason) { + setError(reason instanceof Error ? reason.message : `${label} could not be created.`); + } finally { + setBusy(null); + } + } + async function createCatalogExport(format: string, label: string) { + setBusy(`catalog-${format}`); + setMessage(""); + setError(""); + try { + const result = await api<{ fileName: string; format: string; count: number; createdAt: string }>("/game-catalog/exports", { method: "POST", body: JSON.stringify({ format }) }); + await apiDownload(`/game-catalog/exports/${encodeURIComponent(result.fileName)}`, result.fileName); + setMessage(`Created and downloaded ${label}: ${result.count.toLocaleString()} games`); + } catch (reason) { + setError(reason instanceof Error ? reason.message : `${label} could not be created.`); + } finally { setBusy(null); } + } + return ( +
+

Export your inventory

+

Create portable records without changing any source file.

+ {[ + ["json", "Inventory JSON"], + ["csv", "Inventory CSV"], + ["sha256", "Integrity manifest"], + ].map(([format, label]) => ( + + ))} +

Portable game catalog

+

Exports include cached metadata and personal library context. EmulationStation and Pegasus paths use non-executable ludarium:// identifiers because source files remain read-only and are never silently matched by title.

+ {[ + ["json", "Complete Ludarium catalog"], + ["emulationstation", "EmulationStation metadata"], + ["pegasus", "Pegasus metadata"], + ].map(([format, label]) => ( + + ))} + {message && ( +

+ {message} +

+ )} + {error &&

{error}

} +
+ ); +} diff --git a/src/Ludarium.Web/src/features/attention/Attention.lifecycle.test.tsx b/src/Ludarium.Web/src/features/attention/Attention.lifecycle.test.tsx new file mode 100644 index 0000000..c76a0a4 --- /dev/null +++ b/src/Ludarium.Web/src/features/attention/Attention.lifecycle.test.tsx @@ -0,0 +1,252 @@ +// @vitest-environment happy-dom +import "@testing-library/jest-dom/vitest"; +import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { languageStorageKey, useLocaleDocument } from "../../i18n/locale"; +import { Attention } from "./Attention"; + +const { apiMock } = vi.hoisted(() => ({ apiMock: vi.fn() })); +vi.mock("../../api/client", () => ({ + api: (path: string, options?: unknown) => apiMock(path, options), + bytes: () => "1 B", + platformName: (value: string) => value, +})); + +const status = { openReviews: 0, criticalFindings: 0, unknownArtifacts: 0, duplicateBytes: 0 }; +const health = { recentFindings: [] }; +const review = { + id: "review-1", + reason: "Bundle completeness", + severity: "Warning", + state: "Deferred", + payloadJson: "{}", + createdAt: "2026-08-20T10:00:00Z", + resolvedAt: null, + resolution: "Deferred exact review group", +}; +const group = { + key: "group-1", + category: "incomplete", + reason: "Bundle completeness", + severity: "Warning", + count: 1, + estimatedBytes: 1024, + library: "Games", + platform: "ps5", + pathPattern: "ps5/example/*", + oldestAt: "2026-08-20T10:00:00Z", + newestAt: "2026-08-20T10:00:00Z", + recommendedAction: "Inspect missing bundle members before resolving.", + reviewIds: ["review-1"], +}; +const groupPage = (items: typeof group[], total = items.length) => ({ items, pageNumber: 1, pageSize: 50, total }); +function LocalizedAttention({ section, refreshStatus }: { section: string; refreshStatus: () => void }) { + useLocaleDocument(); + return ; +} + +describe("Attention review lifecycle", () => { + beforeEach(() => apiMock.mockReset()); + afterEach(() => { + cleanup(); + localStorage.removeItem(languageStorageKey); + }); + + it("keeps deferred groups reachable, reopens them atomically and can undo that transition", async () => { + let deferred = true; + const refreshStatus = vi.fn(); + apiMock.mockImplementation((path: string, options?: { body?: string }) => { + if (path === undefined) return Promise.resolve(undefined); + if (path === "/review-items?state=Deferred&page=1&pageSize=200") + return Promise.resolve({ items: deferred ? [review] : [], page: 1, pageSize: 200, total: deferred ? 1 : 0 }); + if (path.startsWith("/review-group-items?state=Deferred")) return Promise.resolve(groupPage(deferred ? [group] : [])); + if (path === "/system/health") return Promise.resolve(health); + if (path === "/system/status") return Promise.resolve(status); + if (path === "/review-groups/reopen") { + expect(JSON.parse(options?.body ?? "{}")).toMatchObject({ ids: ["review-1"], expectedCount: 1, sourceState: "Deferred" }); + deferred = false; + return Promise.resolve({ id: "operation-1" }); + } + if (path === "/review-operations/operation-1/undo") { + deferred = true; + return Promise.resolve(undefined); + } + throw new Error(`Unexpected request: ${path}`); + }); + + render(); + fireEvent.click(await screen.findByRole("button", { name: /completeness/i })); + fireEvent.click(screen.getByRole("button", { name: "Return to Needs action" })); + + expect(await screen.findByRole("heading", { name: "Nothing deferred" })).toBeInTheDocument(); + expect(screen.getByText("Decision returned to Needs action.")).toBeInTheDocument(); + expect(refreshStatus).toHaveBeenCalledTimes(1); + + fireEvent.click(screen.getByRole("button", { name: "Undo" })); + expect(await screen.findByRole("button", { name: /completeness/i })).toBeInTheDocument(); + await waitFor(() => expect(refreshStatus).toHaveBeenCalledTimes(2)); + }); + + it("shows a recorded outcome but reopens the exact resolved group atomically with undo", async () => { + let resolved = true; + const refreshStatus = vi.fn(); + const resolvedReview = { + ...review, + state: "Resolved", + resolvedAt: "2026-08-22T14:30:00Z", + resolution: "Classified as support content", + }; + const resolvedGroup = { + ...group, + count: 2, + reviewIds: ["review-1", "review-2"], + resolvedAt: resolvedReview.resolvedAt, + resolution: resolvedReview.resolution, + }; + apiMock.mockImplementation((path: string, options?: { body?: string }) => { + if (path === undefined) return Promise.resolve(undefined); + if (path === "/review-items?state=Resolved&page=1&pageSize=200") + return Promise.resolve({ items: resolved ? [resolvedReview] : [], page: 1, pageSize: 200, total: resolved ? 1 : 0 }); + if (path.startsWith("/review-group-items?state=Resolved")) return Promise.resolve(groupPage(resolved ? [resolvedGroup] : [])); + if (path === "/system/health") return Promise.resolve(health); + if (path === "/system/status") return Promise.resolve(status); + if (path === "/review-groups/reopen") { + expect(JSON.parse(options?.body ?? "{}")).toMatchObject({ ids: ["review-1", "review-2"], expectedCount: 2, sourceState: "Resolved" }); + resolved = false; + return Promise.resolve({ id: "operation-resolved" }); + } + if (path === "/review-operations/operation-resolved/undo") { + resolved = true; + return Promise.resolve(undefined); + } + throw new Error(`Unexpected request: ${path}`); + }); + + render(); + fireEvent.click(await screen.findByRole("button", { name: /completeness/i })); + + expect(screen.getByRole("heading", { name: "Recorded outcome" })).toBeInTheDocument(); + expect(screen.getByText("Classified as support content")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Defer" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Accept decision" })).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Reopen decision" })); + expect(await screen.findByRole("heading", { name: "No resolved decisions yet" })).toBeInTheDocument(); + expect(apiMock.mock.calls.some(([path]) => path === "/review-items/review-1/reopen")).toBe(false); + expect(screen.getByText("Decision returned to Needs action.")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Undo" })); + expect(await screen.findByRole("button", { name: /completeness/i })).toBeInTheDocument(); + await waitFor(() => expect(refreshStatus).toHaveBeenCalledTimes(2)); + }); + + it("locks stale review data and closes its selection while a new category loads", async () => { + let releaseResolved!: () => void; + const resolvedGate = new Promise((resolve) => { releaseResolved = resolve; }); + const changedReview = { + ...review, + id: "review-resolved", + reason: "Integrity changed", + state: "Resolved", + resolvedAt: "2026-08-22T14:30:00Z", + resolution: "Accepted after verification", + }; + const changedGroup = { + ...group, + key: "group-resolved", + category: "changed", + reason: "Integrity changed", + reviewIds: ["review-resolved"], + }; + apiMock.mockImplementation((path: string) => { + if (path === undefined) return Promise.resolve(undefined); + if (path === "/review-items?state=Deferred&page=1&pageSize=200") + return Promise.resolve({ items: [review], page: 1, pageSize: 200, total: 1 }); + if (path.startsWith("/review-group-items?state=Deferred")) return Promise.resolve(groupPage([group])); + if (path === "/review-items?state=Resolved&page=1&pageSize=200") + return resolvedGate.then(() => ({ items: [changedReview], page: 1, pageSize: 200, total: 1 })); + if (path.startsWith("/review-group-items?state=Resolved")) return resolvedGate.then(() => groupPage([changedGroup])); + if (path === "/system/health") return Promise.resolve(health); + if (path === "/system/status") return Promise.resolve(status); + throw new Error(`Unexpected request: ${path}`); + }); + + const view = render(); + fireEvent.click(await screen.findByRole("button", { name: /completeness/i })); + expect(screen.getByRole("dialog")).toBeInTheDocument(); + + view.rerender(); + + expect(screen.getByRole("status")).toHaveTextContent("Loading…"); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + expect(screen.queryByText(/Bundle completeness/)).not.toBeInTheDocument(); + + await act(async () => { releaseResolved(); }); + expect(await screen.findByRole("button", { name: /Integrity changed/i })).toBeInTheDocument(); + expect(screen.queryByText(/Bundle completeness/)).not.toBeInTheDocument(); + }); + + it("paginates and filters the complete grouped result on the server", async () => { + const prefixReviews = Array.from({ length: 200 }, (_, index) => ({ + ...review, + id: `review-${index}`, + state: "Open", + })); + const firstPageGroups = Array.from({ length: 50 }, (_, index) => ({ + ...group, + key: `group-${index}`, + reason: `Decision ${index}`, + reviewIds: [`review-${index}`], + })); + apiMock.mockImplementation((path: string) => { + if (path === undefined) return Promise.resolve(undefined); + if (path === "/review-items?state=Open&page=1&pageSize=200") + return Promise.resolve({ items: prefixReviews, page: 1, pageSize: 200, total: 201 }); + if (path.startsWith("/review-group-items?state=Open") && path.includes("q=not-in-loaded-prefix")) + return Promise.resolve(groupPage([], 0)); + if (path.startsWith("/review-group-items?state=Open")) return Promise.resolve(groupPage(firstPageGroups, 501)); + if (path === "/system/health") return Promise.resolve(health); + if (path === "/system/status") return Promise.resolve(status); + throw new Error(`Unexpected request: ${path}`); + }); + + render(); + + expect(await screen.findByText(/201 review records are consolidated into 501 matching grouped causes/)).toBeInTheDocument(); + expect(screen.getByText("Complete filtered view.")).toBeInTheDocument(); + expect(screen.getByText(/Page 1 of 11 · 501 grouped causes/)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Next" })).toBeEnabled(); + + fireEvent.change(screen.getByPlaceholderText("Cause, path or action"), { target: { value: "not-in-loaded-prefix" } }); + expect(await screen.findByRole("heading", { name: "No matching items" })).toBeInTheDocument(); + expect(screen.getByText("Clear or adjust the active filters to see more archive evidence.")).toBeInTheDocument(); + }); + + it("translates known review evidence while preserving paths and operator provenance", async () => { + localStorage.setItem(languageStorageKey, "nl"); + const localizedReview = { ...review, reason: "Incomplete related file group" }; + const localizedGroup = { + ...group, + reason: localizedReview.reason, + recommendedAction: "Verify the companion files with a scan", + }; + apiMock.mockImplementation((path: string) => { + if (path === undefined) return Promise.resolve(undefined); + if (path === "/review-items?state=Deferred&page=1&pageSize=200") + return Promise.resolve({ items: [localizedReview], page: 1, pageSize: 200, total: 1 }); + if (path.startsWith("/review-group-items?state=Deferred")) return Promise.resolve(groupPage([localizedGroup])); + if (path === "/system/health") return Promise.resolve(health); + if (path === "/system/status") return Promise.resolve(status); + throw new Error(`Unexpected request: ${path}`); + }); + + render(); + + await waitFor(() => expect(screen.getByText("1 item · Onvolledige groep met bijbehorende bestanden")).toBeInTheDocument()); + expect(screen.getByText("ps5/example/* · Controleer de bijbehorende bestanden met een scan")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: /Onvolledige groep/ })); + await waitFor(() => expect(screen.getAllByText("Controleer de bijbehorende bestanden met een scan").length).toBeGreaterThan(0)); + expect(screen.getByText(/Deze actie geldt uitsluitend voor 1 item-id/)).toBeInTheDocument(); + expect(screen.getAllByText(/ps5\/example\/\*/).length).toBeGreaterThan(0); + }); +}); diff --git a/src/Ludarium.Web/src/features/attention/Attention.tsx b/src/Ludarium.Web/src/features/attention/Attention.tsx new file mode 100644 index 0000000..b803249 --- /dev/null +++ b/src/Ludarium.Web/src/features/attention/Attention.tsx @@ -0,0 +1,543 @@ +import { useEffect, useRef, useState } from "react"; +import { api, bytes, platformName } from "../../api/client"; +import type { + Finding, + HealthData, + Page, + Review, + ReviewGroup, + Status, +} from "../../api/types"; +import { Empty, LoadFailure, Loading, Overlay } from "../../components/Overlay"; +import { ArchivePipeline } from "../../components/ArchivePipeline"; +import { href } from "../../app/routes"; +type Item = { + id: string; + kind: "review" | "finding" | "summary"; + category: string; + title: string; + why: string; + severity: string; + review?: Review; + finding?: Finding; + count?: number; + group?: ReviewGroup; + sourceState?: ReviewStateName; +}; +type UndoState = { id: string; kind: "operation" | "resolution"; message: string }; +type LoadErrorState = { category: string; message: string }; +type ReviewStateName = "Open" | "Deferred" | "Resolved"; +const reviewPrefixLimit = 200; +const groupsPerPage = 50; +const categories = [ + ["all", "All"], + ["decisions", "Needs action"], + ["findings", "Findings"], + ["incomplete", "Missing or incomplete"], + ["changed", "Changed or damaged"], + ["unknown", "Unknown"], + ["duplicates", "Duplicate space"], + ["deferred", "Deferred"], + ["resolved", "Resolved history"], +]; +export function Attention({ + section, + refreshStatus, +}: { + section: string; + refreshStatus: () => void; +}) { + const [reviews, setReviews] = useState | null>(null); + const [health, setHealth] = useState(null); + const [status, setStatus] = useState(null); + const [reviewGroups, setReviewGroups] = useState([]); + const [reviewGroupTotal, setReviewGroupTotal] = useState(0); + const [loadedCategory, setLoadedCategory] = useState(null); + const [selected, setSelected] = useState(null); + const [undo, setUndo] = useState(null); + const [query, setQuery] = useState(""); + const [libraryFilter, setLibraryFilter] = useState("all"); + const [platformFilter, setPlatformFilter] = useState("all"); + const [severityFilter, setSeverityFilter] = useState("all"); + const [ageFilter, setAgeFilter] = useState("all"); + const [priorityOrder, setPriorityOrder] = useState("recommended"); + const [filtersOpen, setFiltersOpen] = useState(false); + const [page, setPage] = useState(1); + const [loadError, setLoadError] = useState(null); + const [actionError, setActionError] = useState(""); + const [actionBusy, setActionBusy] = useState(false); + const [reloadVersion, setReloadVersion] = useState(0); + const trigger = useRef(null); + const activeCategory = useRef(null); + const currentCategory = useRef(""); + const loadGeneration = useRef(0); + const category = categories.some((x) => x[0] === section) ? section : "decisions"; + currentCategory.current = category; + const reviewState = stateForCategory(category); + const openView = reviewState === "Open"; + const requestedGroupCategory = !openView ? null + : category === "decisions" ? "actionable" + : ["all", "findings"].includes(category) ? category === "findings" ? "findings" : null + : category; + const load = async (requestedCategory: string, signal?: AbortSignal) => { + if (currentCategory.current !== requestedCategory) return; + const generation = ++loadGeneration.current; + const requestedState = stateForCategory(requestedCategory); + if (currentCategory.current === requestedCategory) setLoadError(null); + try { + const contextRequest = Promise.allSettled([ + Promise.resolve().then(() => api("/system/health", { signal })), + Promise.resolve().then(() => api("/system/status", { signal })), + ]); + const since = ageFilter === "day" ? new Date(Date.now() - 86_400_000).toISOString() + : ageFilter === "week" ? new Date(Date.now() - 7 * 86_400_000).toISOString() + : ageFilter === "month" ? new Date(Date.now() - 30 * 86_400_000).toISOString() : ""; + const groupParameters = new URLSearchParams({ state: requestedState, page: String(page), pageSize: String(groupsPerPage) }); + if (requestedGroupCategory) groupParameters.set("category", requestedGroupCategory); + if (query.trim()) groupParameters.set("q", query.trim()); + if (libraryFilter !== "all") groupParameters.set("library", libraryFilter); + if (platformFilter !== "all") groupParameters.set("platform", platformFilter); + if (severityFilter !== "all") groupParameters.set("severity", severityFilter); + if (since) groupParameters.set("since", since); + groupParameters.set("order", priorityOrder === "impact" ? "Impact" : priorityOrder === "newest" ? "Newest" : "Recommended"); + const [r, groups] = await Promise.all([ + api>( + `/review-items?state=${requestedState}&page=1&pageSize=${reviewPrefixLimit}`, + { signal }, + ), + api>(`/review-group-items?${groupParameters}`, { signal }), + ]); + if (signal?.aborted || currentCategory.current !== requestedCategory || loadGeneration.current !== generation) return; + setReviews(r); + setReviewGroups(groups.items); + setReviewGroupTotal(groups.total); + setLoadedCategory(requestedCategory); + void contextRequest.then(([healthResult, statusResult]) => { + if (signal?.aborted || currentCategory.current !== requestedCategory || loadGeneration.current !== generation) return; + if (healthResult.status === "fulfilled") setHealth(healthResult.value); + if (statusResult.status === "fulfilled") setStatus(statusResult.value); + if (healthResult.status === "rejected" || statusResult.status === "rejected") + setLoadError({ category: requestedCategory, message: "Supporting health totals could not be refreshed. Review groups remain complete and actionable." }); + }); + } catch (error) { + if (!signal?.aborted && currentCategory.current === requestedCategory && loadGeneration.current === generation) { + setLoadError({ category: requestedCategory, message: error instanceof Error ? error.message : "Attention items could not be loaded." }); + } + } + }; + useEffect(() => setPage(1), [category]); + useEffect(() => { + const controller = new AbortController(); + setLoadedCategory(null); + setReviews(null); + setReviewGroups([]); + setReviewGroupTotal(0); + setHealth(null); + setStatus(null); + setSelected(null); + setActionError(""); + trigger.current = null; + void load(category, controller.signal); + return () => controller.abort(); + }, [category, query, libraryFilter, platformFilter, severityFilter, ageFilter, priorityOrder, page, reloadVersion]); + useEffect(() => { + activeCategory.current?.scrollIntoView?.({ block: "nearest", inline: "center" }); + }, [category, reviews]); + const currentLoadError = loadError?.category === category ? loadError.message : ""; + if (loadedCategory !== category || !reviews) { + return currentLoadError + ? setReloadVersion((value) => value + 1)} /> + : ; + } + const reviewsById = new Map(reviews.items.map((review) => [review.id, review])); + const visibleReviews: Item[] = reviewGroups.map((group) => ({ + id: group.key, + kind: "review", + category: group.category, + title: group.totalCount && group.totalCount > group.count + ? `${group.count.toLocaleString()} of ${group.totalCount.toLocaleString()} items · ${plainReason(group.reason)}` + : `${group.count.toLocaleString()} ${group.count === 1 ? "item" : "items"} · ${plainReason(group.reason)}`, + why: `${group.pathPattern} · ${group.recommendedAction}`, + severity: group.severity, + review: group.count === 1 ? reviewsById.get(group.reviewIds[0]) : undefined, + count: group.count, + group, + sourceState: reviewState, + })); + let items: Item[] = category === "resolved" || category === "deferred" + ? [...visibleReviews] + : [ + ...visibleReviews, + ...(health?.recentFindings ?? []) + .map((f) => ({ + id: f.id, + kind: "finding" as const, + category: categorize(f.category + " " + f.message), + title: plainFinding(f), + why: f.message, + severity: f.severity, + finding: f, + })), + ]; + if (openView && status?.unknownArtifacts) + items.unshift({ + id: "unknown-summary", + kind: "summary", + category: "unknown", + title: `${status.unknownArtifacts.toLocaleString()} files are not identified yet`, + why: "They remain safely inventoried. Review only when identification matters to you.", + severity: "Notice", + }); + if (openView && status?.duplicateBytes) + items.unshift({ + id: "duplicate-summary", + kind: "summary", + category: "duplicates", + title: `${bytes(status.duplicateBytes)} may be duplicate storage`, + why: "Only exact content identities are counted. No files will be removed automatically.", + severity: "Notice", + }); + if (openView) { + if (category === "decisions") + items = items.filter((item) => item.kind === "review" && !["unknown", "duplicates"].includes(item.category)); + else if (category === "findings") + items = items.filter((item) => item.kind === "finding"); + else if (category !== "all") + items = items.filter((item) => item.category === category); + } + const libraries = [...new Set(reviewGroups.flatMap((group) => group.library ? [group.library] : []))].sort(); + const platforms = [...new Set(reviewGroups.flatMap((group) => group.platform ? [group.platform] : []))].sort(); + const cutoff = ageFilter === "day" ? 1 : ageFilter === "week" ? 7 : ageFilter === "month" ? 30 : null; + const normalizedQuery = query.trim().toLocaleLowerCase(); + const filteredItems = items.filter((item) => { + const group = item.group; + if (libraryFilter !== "all" && group?.library !== libraryFilter) return false; + if (platformFilter !== "all" && group?.platform !== platformFilter) return false; + if (severityFilter !== "all" && item.severity !== severityFilter) return false; + if (cutoff && group && Date.now() - new Date(group.oldestAt).getTime() > cutoff * 86_400_000) return false; + return !normalizedQuery || `${item.title} ${item.why} ${group?.library ?? ""} ${group?.platform ?? ""}`.toLocaleLowerCase().includes(normalizedQuery); + }).toSorted((left, right) => { + if (priorityOrder === "impact") return (right.group?.estimatedBytes ?? 0) - (left.group?.estimatedBytes ?? 0) || (right.count ?? 1) - (left.count ?? 1); + if (priorityOrder === "newest") return new Date(right.group?.newestAt ?? 0).getTime() - new Date(left.group?.newestAt ?? 0).getTime(); + const severity = { Critical: 4, Warning: 3, Notice: 2, Info: 1 } as Record; + return (severity[right.severity] ?? 0) - (severity[left.severity] ?? 0) || (right.group?.estimatedBytes ?? 0) - (left.group?.estimatedBytes ?? 0) || (right.count ?? 1) - (left.count ?? 1); + }); + const pageCount = Math.max(1, Math.ceil(reviewGroupTotal / groupsPerPage)); + const currentPage = Math.min(page, pageCount); + const grouped = filteredItems; + const hasActiveFilters = Boolean( + query || + libraryFilter !== "all" || + platformFilter !== "all" || + severityFilter !== "all" || + ageFilter !== "all", + ); + const groupedRecordsMissing = reviewGroups.length === 0 && reviews.total > 0; + const emptyTitle = hasActiveFilters + ? "No matching items" + : category === "deferred" + ? "Nothing deferred" + : category === "resolved" + ? "No resolved decisions yet" + : category === "decisions" + ? "No actions waiting" + : "Nothing in this view"; + const emptyText = groupedRecordsMissing && !hasActiveFilters + ? "Review records exist for this state, but no grouped cause was returned. Reload before concluding that this state is empty." + : hasActiveFilters + ? "Clear or adjust the active filters to see more archive evidence." + : category === "deferred" + ? "Decisions you postpone stay available here until you return them to Needs action." + : category === "resolved" + ? "Completed decisions will appear here as exact, safely reopenable groups." + : category === "decisions" + ? "There are no prioritized decisions, missing groups or integrity changes waiting for you." + : "Your current category and filters contain no matching archive evidence."; + const selectedResolution = selected?.group?.resolution || selected?.review?.resolution || null; + const visibleReviewRecords = grouped.reduce((total, item) => total + (item.kind === "review" ? item.count ?? 1 : 0), 0); + const visibleFindings = grouped.filter((item) => item.kind === "finding").length; + const visibleSummaries = grouped.filter((item) => item.kind === "summary").length; + async function defer(item: Item) { + if (!item.group) throw new Error("This review group is no longer available. Reload Attention before acting."); + const result = await api<{ id: string }>("/review-groups/defer", { + method: "POST", + body: JSON.stringify({ ids: item.group.reviewIds, expectedCount: item.group.count, resolution: "Deferred exact review group" }), + }); + setUndo({ id: result.id, kind: "operation", message: "Decision deferred." }); + setSelected(null); + await load(category); + refreshStatus(); + } + async function reopen(item: Item) { + if (!item.group || (item.sourceState !== "Deferred" && item.sourceState !== "Resolved")) { + throw new Error("This review group is no longer available in a reopenable state. Reload Attention before acting."); + } + const result = await api<{ id: string }>("/review-groups/reopen", { + method: "POST", + body: JSON.stringify({ + ids: item.group.reviewIds, + expectedCount: item.group.count, + sourceState: item.sourceState, + resolution: "Returned exact review group to Needs action", + }), + }); + setUndo({ id: result.id, kind: "operation", message: "Decision returned to Needs action." }); + setSelected(null); + await load(category); + refreshStatus(); + } + async function applyBulkAction(item: Item, action: "ClassifySupportContent" | "AcceptPlatform" | "ExcludeFolderPattern") { + if (!item.group) return; + const result = await api<{ id: string }>("/review-groups/apply", { + method: "POST", + body: JSON.stringify({ + ids: item.group.reviewIds, + expectedCount: item.group.count, + action, + platform: action === "AcceptPlatform" ? item.group.platform : null, + pathPattern: action === "ExcludeFolderPattern" ? item.group.pathPattern : null, + }), + }); + setUndo({ id: result.id, kind: "operation", message: "Decision applied." }); + setSelected(null); + await load(category); + refreshStatus(); + } + async function performAction(action: () => Promise) { + if (actionBusy) return; + setActionBusy(true); + setActionError(""); + try { await action(); } + catch (error) { setActionError(error instanceof Error ? error.message : "The action could not be completed."); } + finally { setActionBusy(false); } + } + async function undoLastAction() { + if (!undo) return; + await performAction(async () => { + const endpoint = undo.kind === "operation" + ? `/review-operations/${undo.id}/undo` + : `/resolutions/${undo.id}/undo`; + await api(endpoint, { method: "POST" }); + setUndo(null); + await load(category); + refreshStatus(); + }); + } + return ( + <> +
+
+

Attention

+

+ One prioritized inbox for decisions, missing files, integrity + changes and unknown content. +

+
+ + {category === "deferred" + ? `${reviews.total.toLocaleString()} deferred · ${grouped.length.toLocaleString()} shown` + : category === "resolved" + ? `${reviews.total.toLocaleString()} resolved · ${grouped.length.toLocaleString()} shown` + : `${visibleReviewRecords.toLocaleString()} review ${visibleReviewRecords === 1 ? "record" : "records"} · ${visibleFindings.toLocaleString()} ${visibleFindings === 1 ? "finding" : "findings"} · ${visibleSummaries.toLocaleString()} ${visibleSummaries === 1 ? "summary" : "summaries"}`} + +
+ + +
+ + + + + + +
+ {hasActiveFilters &&
+ {query && } + {libraryFilter !== "all" && } + {platformFilter !== "all" && } + {severityFilter !== "all" && } + {ageFilter !== "all" && } + +
} + {undo && ( +
+ {undo.message} + +
+ )} + {currentLoadError && ( +
Attention may be out of date. {currentLoadError}
+ )} + {actionError && !selected &&
Attention action failed. {actionError}
} + {openView && ( + <>
+
EXPLAINABLE DECISIONS

Evidence before action

Ludarium keeps competing interpretations separate until you make a scoped, reversible choice.

+ +
+ {reviewGroupTotal.toLocaleString()}grouped causes + {status?.criticalFindings?.toLocaleString() ?? "—"}critical first + {grouped.length.toLocaleString()}shown in this view +

Related evidence is grouped before any action is offered, so a decision stays scoped, reversible and understandable.

+
+ )} +

+ Complete filtered view.{" "} + {`${reviews.total.toLocaleString()} review records are consolidated into ${reviewGroupTotal.toLocaleString()} matching grouped causes, loaded one server page at a time.`} +

+ {!grouped.length ? ( + + ) : ( +
+ {grouped.map((item) => ( + + ))} +
+ )} + {pageCount > 1 && ( +
+ + {`Page ${currentPage} of ${pageCount} · ${reviewGroupTotal.toLocaleString()} grouped causes`} + +
+ )} + {selected && ( + setSelected(null)} + returnFocus={trigger.current} + > + + {selected.severity} + +
+

Why this matters

+

{selected.why}

+
+
+

{selected.kind === "review" ? "Recommended action" : "What to inspect"}

+

+ {selected.review || selected.group + ? selected.group + ? selected.group.recommendedAction + : category === "resolved" + ? "Reopen this decision only when it needs operator attention again." + : "Review the available context, then decide now or defer it for later." + : "Inspect the affected library or file. Ludarium will never change source content automatically."} +

+ {selected.group && {selected.group.totalCount && selected.group.totalCount > selected.group.count + ? `This action is restricted to ${selected.group.count.toLocaleString()} of ${selected.group.totalCount.toLocaleString()} identifiers in this preview. The remaining group stays available for a later exact operation.` + : `This action is restricted to the ${selected.group.count.toLocaleString()} ${selected.group.count === 1 ? "identifier" : "identifiers"} in this preview. If any reviewed identifier changes, the operation is rejected and must be reloaded.`}} +
+ {category === "resolved" && (selected.group || selected.review) &&
+

Recorded outcome

+

{selectedResolution || "Resolved without a single shared recorded note."}

+ {selected.group?.resolvedAt || selected.review?.resolvedAt ? `Resolved ${new Date((selected.group?.resolvedAt || selected.review?.resolvedAt)!).toLocaleString()}` : "Resolution time unavailable"} +
} + {actionError &&

{actionError}

} + {selected.sourceState === "Deferred" && selected.group &&
+ +
} + {selected.sourceState === "Resolved" && selected.group &&
+ +
} + {openView && selected.group && ( +
+ + {selected.group?.category === "unknown" && } + {selected.group?.category === "unknown" && selected.group.pathPattern.endsWith("/*") && } + {selected.group?.category === "unknown" && selected.group.platform && } + {selected.group.category !== "unknown" && setSelected(null)}>Verify with a scan} +
+ )} + {selected.finding && } + {selected.kind === "summary" && } +
+ Show evidence details +
+              {selected.group
+                ? JSON.stringify(selected.group, null, 2)
+                : selected.review
+                ? JSON.stringify(
+                    JSON.parse(selected.review.payloadJson),
+                    null,
+                    2,
+                  )
+                : JSON.stringify(selected.finding, null, 2)}
+            
+
+
+ )} + + ); +} +const stateForCategory = (category: string): ReviewStateName => + category === "resolved" ? "Resolved" : category === "deferred" ? "Deferred" : "Open"; +const categorize = (text: string) => { + const t = text.toLowerCase(); + if ( + t.includes("missing") || + t.includes("incomplete") || + t.includes("companion") + ) + return "incomplete"; + if (t.includes("unknown") || t.includes("unclassified")) return "unknown"; + if (t.includes("duplicate")) return "duplicates"; + if (t.includes("changed") || t.includes("integrity") || t.includes("damaged")) + return "changed"; + return "decisions"; +}; +const categoryLabel = (id: string) => + categories.find((x) => x[0] === id)?.[1] || "Needs attention"; +const plainReason = (r: string) => + r + .replace(/artifact/gi, "file") + .replace(/bundle/gi, "related file group") + .replace(/classification/gi, "identification"); +const plainFinding = (f: Finding) => + `${f.category.replace(/artifact/gi, "File")}: ${f.message.split(".").shift()}`; +const tone = (s: string) => + s.toLowerCase().includes("critical") || s.toLowerCase().includes("error") + ? "danger" + : s.toLowerCase().includes("warn") + ? "warn" + : "info"; diff --git a/src/Ludarium.Web/src/features/home/ArchiveAtlas.test.ts b/src/Ludarium.Web/src/features/home/ArchiveAtlas.test.ts new file mode 100644 index 0000000..4605461 --- /dev/null +++ b/src/Ludarium.Web/src/features/home/ArchiveAtlas.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import type { PlatformSummary } from "../../api/types"; +import { buildArchiveAtlas } from "./ArchiveAtlas"; + +const platforms: PlatformSummary[] = [ + ["switch", 730, 7300, 73], + ["nds", 670, 6200, 67], + ["windows", 410, 12200, 41], + ["ps5", 280, 9900, 28], + ["psx", 210, 3200, 21], + ["n64", 180, 1200, 18], + ["snes", 160, 800, 16], + ["gb", 140, 400, 14], +].map(([platform, artifacts, size, games]) => ({ + platform: String(platform), + artifacts: Number(artifacts), + bytes: Number(size), + games: Number(games), +})); + +describe("Archive Atlas layout", () => { + it("keeps the largest platforms distinct and groups the long tail", () => { + const tiles = buildArchiveAtlas(platforms); + expect(tiles).toHaveLength(7); + expect(tiles.some((tile) => tile.platform === "windows")).toBe(true); + expect(tiles.find((tile) => tile.aggregate)).toMatchObject({ games: 30, bytes: 1200 }); + }); + + it("produces bounded proportional regions that fill the map", () => { + const tiles = buildArchiveAtlas(platforms); + expect(tiles.every((tile) => tile.left >= 0 && tile.top >= 0 && tile.width > 0 && tile.height > 0)).toBe(true); + expect(tiles.every((tile) => tile.left + tile.width <= 100.0001 && tile.top + tile.height <= 100.0001)).toBe(true); + const area = tiles.reduce((sum, tile) => sum + tile.width * tile.height, 0); + expect(area).toBeCloseTo(10_000, 4); + }); +}); diff --git a/src/Ludarium.Web/src/features/home/ArchiveAtlas.tsx b/src/Ludarium.Web/src/features/home/ArchiveAtlas.tsx new file mode 100644 index 0000000..a218e9a --- /dev/null +++ b/src/Ludarium.Web/src/features/home/ArchiveAtlas.tsx @@ -0,0 +1,174 @@ +import { useMemo, useState } from "react"; +import type { CSSProperties } from "react"; +import { bytes, platformName } from "../../api/client"; +import type { PlatformSummary, StorageData } from "../../api/types"; +import { href } from "../../app/routes"; +import { PlatformVisual } from "../../components/PlatformVisual"; + +type AtlasEntry = PlatformSummary & { + id: string; + label: string; + aggregate: boolean; + weight: number; +}; + +type AtlasTile = AtlasEntry & { + left: number; + top: number; + width: number; + height: number; +}; + +const atlasAccents = [ + "var(--accent)", + "var(--good)", + "var(--accent-2)", + "var(--brand)", + "var(--warn)", + "var(--accent)", + "var(--good)", +]; + +function splitEntries(entries: AtlasEntry[], left: number, top: number, width: number, height: number): AtlasTile[] { + if (entries.length === 1) return [{ ...entries[0], left, top, width, height }]; + const total = entries.reduce((sum, entry) => sum + entry.weight, 0); + let splitAt = 1; + let firstWeight = entries[0].weight; + while (splitAt < entries.length - 1 && firstWeight + entries[splitAt].weight <= total / 2) { + firstWeight += entries[splitAt].weight; + splitAt += 1; + } + const ratio = Math.max(0.24, Math.min(0.76, firstWeight / total)); + const first = entries.slice(0, splitAt); + const second = entries.slice(splitAt); + if (width >= height) { + const firstWidth = width * ratio; + return [ + ...splitEntries(first, left, top, firstWidth, height), + ...splitEntries(second, left + firstWidth, top, width - firstWidth, height), + ]; + } + const firstHeight = height * ratio; + return [ + ...splitEntries(first, left, top, width, firstHeight), + ...splitEntries(second, left, top + firstHeight, width, height - firstHeight), + ]; +} + +export function buildArchiveAtlas(platforms: PlatformSummary[]): AtlasTile[] { + if (!platforms.length) return []; + const ranked = [...platforms].sort((a, b) => b.bytes - a.bytes || b.games - a.games || a.platform.localeCompare(b.platform)); + const primary = ranked.slice(0, 6); + const remainder = ranked.slice(6); + const entries: AtlasEntry[] = primary.map((platform) => ({ + ...platform, + id: platform.platform, + label: platformName(platform.platform), + aggregate: false, + weight: Math.max(1, platform.bytes || platform.games), + })); + if (remainder.length) { + const aggregate = remainder.reduce( + (total, platform) => ({ + artifacts: total.artifacts + platform.artifacts, + bytes: total.bytes + platform.bytes, + games: total.games + platform.games, + }), + { artifacts: 0, bytes: 0, games: 0 }, + ); + entries.push({ + ...aggregate, + id: "other-platforms", + platform: "other-platforms", + label: `${remainder.length} more platforms`, + aggregate: true, + weight: Math.max(1, aggregate.bytes || aggregate.games), + }); + } + return splitEntries(entries, 0, 0, 100, 100); +} + +export function ArchiveAtlas({ platforms, storage }: { platforms: PlatformSummary[]; storage: StorageData | null }) { + const tiles = useMemo(() => buildArchiveAtlas(platforms), [platforms]); + const [selectedId, setSelectedId] = useState(tiles[0]?.id ?? ""); + const activeId = tiles.some((tile) => tile.id === selectedId) ? selectedId : tiles[0]?.id; + const selected = tiles.find((tile) => tile.id === activeId) ?? tiles[0]; + const totalBytes = platforms.reduce((sum, platform) => sum + platform.bytes, 0); + const totalGames = platforms.reduce((sum, platform) => sum + platform.games, 0); + const selectedShare = selected + ? Math.round(((totalBytes ? selected.bytes / totalBytes : selected.games / Math.max(1, totalGames)) * 100) * 10) / 10 + : 0; + + if (!tiles.length) { + return

Your collection map will grow herePlatforms, storage and verified playable copies appear after the first scan.

; + } + + return ( +
+
+ +
    + {tiles.map((tile, index) => { + const url = tile.aggregate + ? href("Library", "platforms") + : `${href("Library", "games")}&platform=${encodeURIComponent(tile.platform)}`; + const share = totalBytes ? (tile.bytes / totalBytes) * 100 : (tile.games / Math.max(1, totalGames)) * 100; + const style = { + left: `${tile.left}%`, + top: `${tile.top}%`, + width: `${tile.width}%`, + height: `${tile.height}%`, + "--atlas-accent": atlasAccents[index % atlasAccents.length], + "--atlas-share": `${Math.max(4, share)}%`, + } as CSSProperties; + return ( +
  1. + setSelectedId(tile.id)} + onPointerEnter={() => setSelectedId(tile.id)} + > + {String(index + 1).padStart(2, "0")} + + {tile.label} + {tile.games.toLocaleString()} games + {bytes(tile.bytes)} + + + +
  2. + ); + })} +
+
+ {selected && ( + + )} +
+ ); +} diff --git a/src/Ludarium.Web/src/features/home/Home.recent.test.tsx b/src/Ludarium.Web/src/features/home/Home.recent.test.tsx new file mode 100644 index 0000000..cf9f892 --- /dev/null +++ b/src/Ludarium.Web/src/features/home/Home.recent.test.tsx @@ -0,0 +1,75 @@ +// @vitest-environment happy-dom +import "@testing-library/jest-dom/vitest"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { Home } from "./Home"; + +const { apiMock } = vi.hoisted(() => ({ apiMock: vi.fn() })); +vi.mock("../../api/client", () => ({ + api: (path: string, options?: unknown) => options === undefined ? apiMock(path) : apiMock(path, options), + apiBlob: vi.fn().mockResolvedValue(null), + bytes: () => "1 GB", + platformName: (value: string | null) => value ?? "Unknown", +})); + +describe("Home recently played", () => { + beforeEach(() => { + apiMock.mockReset().mockImplementation((path: string) => { + if (path === "/scans" || path === "/platforms" || path === "/libraries") return Promise.resolve([]); + if (path === "/system/storage") return Promise.resolve({ libraries: [] }); + if (path.startsWith("/wishlist?")) return Promise.resolve({ items: [] }); + if (path === "/wishlist/summary") return Promise.resolve({ total: 0, highPriority: 0, upcoming: 0, priceDrops: 0, acquired: 0 }); + if (path === "/games/recently-played?limit=6") return Promise.resolve([{ + game: { id: "game-1", title: "Banjo-Kazooie", createdAt: "2026-01-01", version: 1, origin: "Scan" }, + state: { gameId: "game-1", favorite: true, status: "Playing", rating: null, difficulty: null, completionPercent: 35, notes: null, playCount: 4, lastPlayedAt: new Date().toISOString(), updatedAt: new Date().toISOString(), version: 4 }, + }]); + if (path.startsWith("/games/play-capabilities?ids=")) return Promise.resolve([{ + gameId: "game-1", available: true, state: "Available", message: "Ready", platform: "n64", emulator: "N64Wasm", core: "n64wasm", desktopRecommended: true, checkedAt: new Date().toISOString(), automaticRestore: true, + }]); + // Every native player is asked, so Recently Played can resume a Dolphin title too. + if (/^\/games\/[a-z]+-play-capabilities\?ids=/.test(path)) return Promise.resolve([]); + if (path === "/games/game-1/play-sessions") return Promise.resolve({ + id: "session-1", gameId: "game-1", state: "Ready", platform: "n64", emulator: "N64Wasm", core: "n64wasm", launchUrl: "about:blank", createdAt: new Date().toISOString(), expiresAt: new Date(Date.now() + 60_000).toISOString(), endedAt: null, errorCode: null, errorMessage: null, + }); + if (path === "/play-sessions/session-1") return Promise.resolve(undefined); + throw new Error(`Unexpected API request: ${path}`); + }); + }); + afterEach(() => cleanup()); + + it("offers a qualified one-click resume and a reliable details fallback", async () => { + render(); + + expect(await screen.findByRole("heading", { name: "Recently played" })).toBeInTheDocument(); + expect(await screen.findByRole("button", { name: "Resume" })).toBeInTheDocument(); + await waitFor(() => expect(document.querySelector(".home-recent-cover")).toHaveAttribute("data-platform", "n64")); + // The title carries the way to the details; a second control pointing at the same place only + // took room away from the name. + const details = screen.getByRole("link", { name: "Open details for Banjo-Kazooie" }); + expect(details).toHaveAttribute("href", "#Library?view=games&id=game-1"); + expect(details).toHaveTextContent("Banjo-Kazooie"); + expect(screen.getAllByRole("link", { name: /Open details for/ })).toHaveLength(1); + expect(screen.getByText("Just played · 4 sessions")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Resume" })); + expect(await screen.findByLabelText("Browser player for Banjo-Kazooie")).toBeInTheDocument(); + await waitFor(() => expect(apiMock).toHaveBeenCalledWith("/games/game-1/play-sessions", { method: "POST" })); + }); + it("names the one thing a fresh install has to do", async () => { + apiMock.mockReset().mockImplementation((path: string) => { + if (path === "/scans" || path === "/platforms" || path === "/libraries") return Promise.resolve([]); + if (path === "/system/storage") return Promise.resolve({ libraries: [] }); + if (path.startsWith("/wishlist?")) return Promise.resolve({ items: [] }); + if (path === "/wishlist/summary") return Promise.resolve({ total: 0, highPriority: 0, upcoming: 0, priceDrops: 0, acquired: 0 }); + if (path === "/games/recently-played?limit=6") return Promise.resolve([]); + throw new Error(`Unexpected API request: ${path}`); + }); + + render(); + + // "Your archive looks good" plus a scan that silently does nothing was the previous answer. + expect(await screen.findByRole("heading", { name: "Add your first library" })).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "Add a library" })).toHaveAttribute("href", "#Settings?view=libraries"); + expect(screen.getByRole("heading", { name: "Three steps to a working library" })).toBeInTheDocument(); + }); + +}); diff --git a/src/Ludarium.Web/src/features/home/Home.test.ts b/src/Ludarium.Web/src/features/home/Home.test.ts new file mode 100644 index 0000000..d20197d --- /dev/null +++ b/src/Ludarium.Web/src/features/home/Home.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; +import { attentionHeadline, lastPlayedLabel } from "./Home"; + +describe("attentionHeadline", () => { + it("uses singular grammar for numeric and serialized counts", () => { + expect(attentionHeadline(1)).toBe("1 item needs attention"); + expect(attentionHeadline("1")).toBe("1 item needs attention"); + }); + + it("uses plural grammar for zero and multiple items", () => { + expect(attentionHeadline(0)).toBe("0 items need attention"); + expect(attentionHeadline(2)).toBe("2 items need attention"); + }); +}); + +describe("lastPlayedLabel", () => { + const now = Date.parse("2026-08-12T12:00:00Z"); + + it("keeps recent sessions human readable", () => { + expect(lastPlayedLabel("2026-08-12T11:58:00Z", now)).toBe("Just played"); + expect(lastPlayedLabel("2026-08-12T11:30:00Z", now)).toBe("30 minutes ago"); + expect(lastPlayedLabel("2026-08-12T10:00:00Z", now)).toBe("2 hours ago"); + expect(lastPlayedLabel("2026-08-11T12:00:00Z", now)).toBe("1 day ago"); + }); +}); diff --git a/src/Ludarium.Web/src/features/home/Home.tsx b/src/Ludarium.Web/src/features/home/Home.tsx new file mode 100644 index 0000000..6680f11 --- /dev/null +++ b/src/Ludarium.Web/src/features/home/Home.tsx @@ -0,0 +1,554 @@ +import { useEffect, useRef, useState } from "react"; +import { api, bytes, platformName } from "../../api/client"; +import type { + BrowserPlayCapability, + BrowserPlaySession, + Game, + Library, + PlatformSummary, + PlayabilitySummary, + RecentlyPlayedGame, + Scan, + Status, + StorageData, + NativeRemotePlayCapability, + NativeRemotePlayerSession, + WishlistItem, + WishlistSummary, +} from "../../api/types"; +import { href } from "../../app/routes"; +import { Icon } from "../../components/Icon"; +import { ArchivePipeline } from "../../components/ArchivePipeline"; +import { ArtworkPreview } from "../../components/ArtworkPreview"; +import { ArchiveAtlas } from "./ArchiveAtlas"; +import { Overlay } from "../../components/Overlay"; +import { BrowserPlayer } from "../library/BrowserPlayer"; +import { NativeRemotePlayer } from "../library/NativeRemotePlayer"; +import { endNativeSession, fetchNativeCapabilities, nativePlayers, startNativeSession } from "../../api/nativePlayers"; + +type SystemMetrics = { + review: { groups: number; items: number; estimatedBytes: number }; + artwork: { needingReview: number; averageQuality: number }; +}; + +export function attentionHeadline(value: unknown) { + const count = Number(value) || 0; + return count === 1 + ? "1 item needs attention" + : `${count.toLocaleString()} items need attention`; +} + +export function lastPlayedLabel(value: string, now = Date.now()) { + const elapsed = Math.max(0, now - new Date(value).getTime()); + const minutes = Math.floor(elapsed / 60_000); + if (minutes < 5) return "Just played"; + if (minutes < 60) return `${minutes} minutes ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours} ${hours === 1 ? "hour" : "hours"} ago`; + const days = Math.floor(hours / 24); + if (days < 14) return `${days} ${days === 1 ? "day" : "days"} ago`; + return new Date(value).toLocaleDateString(); +} + +export function Home({ + status, + refresh, +}: { + status: Status | null; + refresh: () => void; +}) { + const [scans, setScans] = useState([]); + const [platforms, setPlatforms] = useState([]); + const [storage, setStorage] = useState(null); + const [wishlist, setWishlist] = useState([]); + const [wishlistSummary, setWishlistSummary] = useState(null); + const [recentlyPlayed, setRecentlyPlayed] = useState([]); + const [browserCapabilities, setBrowserCapabilities] = useState>(new Map()); + // Every native player, not only Eden: Recently Played used to be unable to resume a Dolphin title. + const [nativeCapabilities, setNativeCapabilities] = useState>(new Map()); + const [browserSession, setBrowserSession] = useState<{ game: Game; session: BrowserPlaySession } | null>(null); + const [nativeSession, setNativeSession] = useState<{ game: Game; session: NativeRemotePlayerSession; capability: NativeRemotePlayCapability } | null>(null); + const [launchingId, setLaunchingId] = useState(null); + const [launchMessage, setLaunchMessage] = useState(""); + const [launchFailureId, setLaunchFailureId] = useState(null); + const launchTrigger = useRef(null); + const browserSessionRef = useRef(browserSession); + const nativeSessionRef = useRef(nativeSession); + const [staleRoots, setStaleRoots] = useState([]); + const [metrics, setMetrics] = useState(null); + const [playability, setPlayability] = useState(null); + const [busy, setBusy] = useState(false); + const [message, setMessage] = useState(""); + useEffect(() => { + const controller = new AbortController(); + const request = { signal: controller.signal }; + void Promise.allSettled([ + api("/scans", request), api("/platforms", request), api("/system/storage", request), + api<{ items: WishlistItem[] }>("/wishlist?sort=release&page=1&pageSize=3", request), api("/wishlist/summary", request), api("/libraries", request), + api("/games/recently-played?limit=6", request), + Promise.resolve().then(() => api("/system/metrics", request)), + Promise.resolve().then(() => api("/system/playability", request)), + ]).then(([scansResult, platformsResult, storageResult, wishesResult, summaryResult, librariesResult, recentResult, metricsResult, playabilityResult]) => { + if (controller.signal.aborted) return; + if (scansResult.status === "fulfilled") setScans(scansResult.value); + if (platformsResult.status === "fulfilled") setPlatforms(platformsResult.value); + if (storageResult.status === "fulfilled") setStorage(storageResult.value); + if (wishesResult.status === "fulfilled") setWishlist(wishesResult.value.items); + if (summaryResult.status === "fulfilled") setWishlistSummary(summaryResult.value); + if (librariesResult.status === "fulfilled") setStaleRoots(librariesResult.value.filter((library) => library.enabled && library.hasEntries === false)); + if (metricsResult.status === "fulfilled" && metricsResult.value?.review && metricsResult.value?.artwork) + setMetrics(metricsResult.value); + if (playabilityResult.status === "fulfilled" && typeof playabilityResult.value.ready === "number" && typeof playabilityResult.value.supported === "number") + setPlayability(playabilityResult.value); + if (recentResult.status === "fulfilled") { + setRecentlyPlayed(recentResult.value); + const ids = recentResult.value.map(({ game }) => game.id).join(","); + if (ids) void Promise.allSettled([ + api(`/games/play-capabilities?ids=${ids}`, request), + ...nativePlayers.map((player) => fetchNativeCapabilities(player.key, ids, request)), + ]).then(([browserResult, ...nativeResults]) => { + if (controller.signal.aborted) return; + setBrowserCapabilities(new Map(browserResult.status === "fulfilled" ? browserResult.value.map((item) => [item.gameId, item]) : [])); + const native = new Map(); + for (const result of nativeResults) + if (result.status === "fulfilled") + // A game belongs to one player, so only its own player ever reports it available. + for (const capability of result.value) + if (capability.available || !native.has(capability.gameId)) native.set(capability.gameId, capability); + setNativeCapabilities(native); + }); + } + }); + return () => controller.abort(); + }, [status?.activeScans]); + useEffect(() => { browserSessionRef.current = browserSession; }, [browserSession]); + useEffect(() => { nativeSessionRef.current = nativeSession; }, [nativeSession]); + useEffect(() => () => { + const browser = browserSessionRef.current; + const remote = nativeSessionRef.current; + if (browser) void api(`/play-sessions/${browser.session.id}`, { method: "DELETE" }).catch(() => undefined); + if (remote) void endNativeSession(remote.session.player, remote.session.id).catch(() => undefined); + }, []); + const active = scans.find((s) => + ["Discovering", "Processing", "Paused"].includes(s.state), + ); + const last = scans.find((s) => s.state === "Completed"); + const lastScanAt = status?.lastIncrementalScanAt || status?.lastFullScanAt || last?.finishedAt; + const scanAgeDays = lastScanAt + ? Math.max(0, Math.floor((Date.now() - new Date(lastScanAt).getTime()) / 86_400_000)) + : null; + async function scanAll() { + setBusy(true); + setMessage(""); + try { + const libs = await api("/libraries"); + await Promise.all( + libs + .filter((l) => l.enabled && l.isAvailable && l.hasEntries !== false) + .map((l) => + api(`/libraries/${l.id}/scans`, { + method: "POST", + headers: { "Idempotency-Key": `home-${l.id}-${Date.now()}` }, + body: JSON.stringify({ mode: "Quick" }), + }), + ), + ); + setMessage("Quick scans started for non-empty available libraries."); + refresh(); + } catch (e) { + setMessage(e instanceof Error ? e.message : "Could not start scans"); + } finally { + setBusy(false); + } + } + async function playRecent(game: Game, source: HTMLElement) { + const browser = browserCapabilities.get(game.id); + const remote = nativeCapabilities.get(game.id); + if (!browser?.available && !remote?.available) return; + setLaunchingId(game.id); + setLaunchMessage(""); + setLaunchFailureId(null); + launchTrigger.current = source; + try { + if (browser?.available) { + const session = await api(`/games/${game.id}/play-sessions`, { method: "POST" }); + setBrowserSession({ game, session }); + } else if (remote?.available && remote.player) { + const session = await startNativeSession(remote.player, game.id); + setNativeSession({ game, session, capability: remote }); + } + } catch (error) { + setLaunchMessage(error instanceof Error ? error.message : "The game could not be started."); + setLaunchFailureId(game.id); + } finally { + setLaunchingId(null); + } + } + async function exitBrowserPlayer() { + if (!browserSession) return; + await api(`/play-sessions/${browserSession.session.id}`, { method: "DELETE" }).catch(() => undefined); + setBrowserSession(null); + launchTrigger.current?.focus(); + } + async function exitNativePlayer() { + if (!nativeSession) return; + await endNativeSession(nativeSession.session.player, nativeSession.session.id).catch(() => undefined); + setNativeSession(null); + launchTrigger.current?.focus(); + } + const reviewRecords = Math.max(Number(status?.openReviewRecords ?? status?.openReviews ?? 0), Number(metrics?.review.items || 0)); + const artworkQueue = Math.max(Number(status?.artworkNeedingReview || 0), Number(metrics?.artwork.needingReview || 0)); + const blockedGames = Number(playability?.blocked || 0); + const missingFiles = Number(status?.missingArtifacts || 0); + const rootProblems = Math.max(Number(status?.rootsRequiringAttention || 0), staleRoots.length); + const hashesMissing = status !== null && status.presentArtifacts > status.hashedArtifacts; + const scanRefreshDue = scanAgeDays !== null && scanAgeDays >= 14; + const attentionItems = reviewRecords + artworkQueue + missingFiles + blockedGames; + const needs = attentionItems > 0 || rootProblems > 0 || hashesMissing || scanRefreshDue; + // A fresh install has nothing to scan. Reporting that the archive "looks good" and offering a scan + // that silently does nothing left the one thing a new operator has to do unsaid. + const noLibraries = status !== null && !status.libraries; + const needsLabel = attentionHeadline(attentionItems + rootProblems + Number(hashesMissing) + Number(scanRefreshDue)); + const critical = Number(status?.criticalFindings || 0); + return ( + <> + {browserSession && void exitBrowserPlayer()} returnFocus={launchTrigger.current}> + void exitBrowserPlayer()} /> + } + {nativeSession && void exitNativePlayer()} + />} +
+ +
+ {message && ( +

+ {message} +

+ )} + {noLibraries && ( +
+
+
+ GETTING STARTED +

Three steps to a working library

+

Ludarium never writes to your game folders. Everything it derives lives in its own storage.

+
+
+ +
    +
  1. + Add a library + Choose the folder that holds your games. Ludarium mounts it read-only. + Open Libraries +
  2. +
  3. + Run a scan + Files are identified from their own headers, with the evidence kept per file. + Open Activity +
  4. +
  5. + Play or review + Qualified games gain a play action; anything uncertain becomes a reviewable decision. + Open Library +
  6. +
+
+ )} +
+ + + {/* The headline counted decisions while the line under it advertised critical findings, so + an archive with none of the first and two of the second read as "Needs attention 0". + Each number now says which queue it belongs to, as the status panel above already does. */} + + +
+ {recentlyPlayed.length > 0 && ( +
+
+
+ JUMP BACK IN +

Recently played

+

Your latest sessions, with personal progress kept in Ludarium.

+
+ Open library +
+
+ {recentlyPlayed.map(({ game, state }) => { + const browser = browserCapabilities.get(game.id); + const remote = nativeCapabilities.get(game.id); + const playable = Boolean(browser?.available || remote?.available); + const restores = browser?.available ? browser.automaticRestore : remote?.automaticRestore; + const playLabel = restores ? "Resume" : "Play"; + const coverPlatform = browser?.available + ? browser.platform + : remote?.available + ? remote.platform + : browser?.platform ?? remote?.platform; + return
+ + + {/* The title is the way to the details, so the card needs no second control + pointing at the same place — and the name gets that room back instead of + being truncated to "Ludarium Ma…". */} + {game.title} + {state.lastPlayedAt ? lastPlayedLabel(state.lastPlayedAt) : "Played recently"} · {state.playCount} {state.playCount === 1 ? "session" : "sessions"} + {state.completionPercent !== null && } + + + {playable && } + +
; + })} +
+ {launchMessage &&

{launchMessage} Open details

} +
+ )} +
+
+
+
+

{needs ? "What needs your attention" : "Nothing urgent"}

+

+ {needs + ? "Start with decisions that have the biggest effect on your archive." + : "Unknown files stay visible without being treated as damage."} +

+
+ Open Attention +
+
+ {status?.missingArtifacts ? ( + + ) : null} + {reviewRecords ? ( + + ) : ( + + )} + {artworkQueue > 0 && } + {blockedGames > 0 && } + {hashesMissing && } + {rootProblems > 0 && } +
+
+
+
+
+

Collection at a glance

+

Explore how every stored byte is distributed across your platform shelves.

+
+ Open platform overview +
+ +
+
+
+
+
+ NEXT TO PLAY +

Your wishlist

+

{wishlistSummary?.upcoming ? `${wishlistSummary.upcoming} upcoming ${wishlistSummary.upcoming === 1 ? "release" : "releases"}` : "Keep future games, editions and price targets in one place."}

+
+ Open wishlist +
+ {wishlist.length ? :

Nothing saved yetAdd a game manually or from your library details.

Start a wishlist
} +
+ + ); +} +function ArchiveScene({ + active, + needsAttention, + libraries, +}: { + active: boolean; + needsAttention: boolean; + libraries: number; +}) { + return ( +
+ + + LCATALOG + {[0, 1, 2].map((root) => ( + + + ROOT {root + 1} + + ))} + +
+ ); +} +function Metric({ + label, + value, + detail, + href: url, +}: { + label: string; + value: string; + detail: string; + href: string; +}) { + return ( + + {label} + {value} + {detail} + + ); +} +function Row({ + title, + detail, + tone, +}: { + title: string; + detail: string; + tone: string; +}) { + return ( +
+ + + {title} + {detail} + + +
+ ); +} diff --git a/src/Ludarium.Web/src/features/library/BrowserPlayer.test.tsx b/src/Ludarium.Web/src/features/library/BrowserPlayer.test.tsx new file mode 100644 index 0000000..a93eca2 --- /dev/null +++ b/src/Ludarium.Web/src/features/library/BrowserPlayer.test.tsx @@ -0,0 +1,48 @@ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { BrowserPlaySession, Game } from "../../api/types"; +import { BrowserPlayer } from "./BrowserPlayer"; + +vi.mock("../../api/client", () => ({ platformName: (platform: string) => platform.toUpperCase() })); + +const game = { id: "game-1", title: "Synthetic PSP" } as Game; +const session: BrowserPlaySession = { + id: "session-1", + gameId: game.id, + state: "Ready", + platform: "psp", + emulator: "Ludarium EmulatorJS", + core: "ppsspp", + launchUrl: "about:blank#token=secret", + createdAt: "2026-08-28T00:00:00Z", + expiresAt: "2026-08-28T02:00:00Z", + endedAt: null, + errorCode: null, + errorMessage: null, +}; + +describe("BrowserPlayer", () => { + afterEach(cleanup); + + it("hands threaded PSP playback off to an isolated top-level page", () => { + render( undefined} />); + + expect(screen.queryByTitle(/Synthetic PSP/)).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Open player fullscreen" })).not.toBeInTheDocument(); + expect(screen.getByRole("status")).toHaveTextContent("threaded WebAssembly"); + const link = screen.getByRole("link", { name: "Open PSP player" }); + expect(link).toHaveAttribute("href", session.launchUrl); + expect(link).toHaveAttribute("target", "_blank"); + }); + + it("keeps non-threaded ROM cores embedded", () => { + const nesSession = { ...session, platform: "nes", core: "fceumm" }; + render( undefined} />); + + const player = screen.getByTitle("Synthetic NES — Ludarium EmulatorJS"); + expect(player).toHaveAttribute("src", nesSession.launchUrl); + expect(screen.getByRole("status")).toHaveTextContent("Starting Ludarium EmulatorJS"); + fireEvent.load(player); + expect(screen.queryByRole("status")).not.toBeInTheDocument(); + }); +}); diff --git a/src/Ludarium.Web/src/features/library/BrowserPlayer.tsx b/src/Ludarium.Web/src/features/library/BrowserPlayer.tsx new file mode 100644 index 0000000..e85017b --- /dev/null +++ b/src/Ludarium.Web/src/features/library/BrowserPlayer.tsx @@ -0,0 +1,35 @@ +import { useRef, useState } from "react"; +import { platformName } from "../../api/client"; +import type { BrowserPlaySession, Game } from "../../api/types"; + +export function BrowserPlayer({ game, session, onExit }: { + game: Game; + session: BrowserPlaySession; + onExit: () => void; +}) { + const frame = useRef(null); + const [state, setState] = useState<"connecting" | "ready" | "error">("connecting"); + const isPlayJs = session.core === "playjs"; + const requiresTopLevelContext = session.core === "ppsspp"; + return
+
+
PLAYING IN BROWSER

{game.title}

{platformName(session.platform)} · {session.emulator} · core {session.core}

+
+ {!requiresTopLevelContext && } + {!requiresTopLevelContext && Open separately} + +
+
+ {requiresTopLevelContext ?
+
ISOLATED PLAYER REQUIRED

Open the PSP player in its own tab

PPSSPP uses threaded WebAssembly, which browsers only enable in an isolated top-level page. Your read-only game and Ludarium save data stay protected.

+ Open PSP player +
: <> + {state === "connecting" &&
{`Starting ${session.emulator}…`}{`The bundled player is preparing the selected read-only ${isPlayJs ? "disc" : "ROM"}.`}
} + {state === "error" &&
The embedded player could not connect.Open it separately or exit and try again.
} +