Initial commit
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
# Prevent local build artifacts from leaking into Docker
|
||||
node_modules/
|
||||
frontend/node_modules/
|
||||
dist/
|
||||
frontend/dist/
|
||||
|
||||
# Runtime data
|
||||
data/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# Binary
|
||||
dostalgia
|
||||
@@ -0,0 +1,10 @@
|
||||
# DOStalgia — Environment Configuration
|
||||
# Copy this file to .env and fill in your values:
|
||||
# cp .env.example .env
|
||||
|
||||
# Twitch Client ID for IGDB metadata auto-scrape.
|
||||
# Get yours at https://dev.twitch.tv/console/apps → Create Application
|
||||
# (Set OAuth Redirect URL to http://localhost, Category to "Other")
|
||||
# Optional — IGDB features gracefully skip if not set.
|
||||
# TWITCH_CLIENT_ID=your_client_id_here
|
||||
# TWITCH_CLIENT_SECRET=your_client_secret_here
|
||||
@@ -0,0 +1,45 @@
|
||||
name: Publish Docker image
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
jobs:
|
||||
push:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_HUB_TOKEN }}
|
||||
|
||||
- name: Extract tags
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: droideparanoico/dostalgia
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
frontend/node_modules/
|
||||
|
||||
# Environment — secrets
|
||||
.env
|
||||
.env.local
|
||||
|
||||
# Built output
|
||||
frontend/dist/
|
||||
dist/
|
||||
|
||||
# Maven / Java
|
||||
target/
|
||||
*.class
|
||||
*.jar
|
||||
*.war
|
||||
|
||||
# Runtime data
|
||||
data/games/*
|
||||
data/artwork/*
|
||||
data/saves/*
|
||||
!data/games/.gitkeep
|
||||
!data/artwork/.gitkeep
|
||||
!data/saves/.gitkeep
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.iml
|
||||
*.ipr
|
||||
*.iws
|
||||
|
||||
# Test/upload artifacts
|
||||
*.zip
|
||||
*.ZIP
|
||||
*.jsdos
|
||||
*.JSDOS
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
# DOStalgia — Quarkus multi-stage build
|
||||
|
||||
# ─── 1. Build frontend ───────────────────────────
|
||||
FROM node:20-alpine AS frontend
|
||||
WORKDIR /build
|
||||
COPY frontend/package.json frontend/package-lock.json ./
|
||||
RUN npm ci 2>/dev/null || npm install --legacy-peer-deps
|
||||
COPY frontend/ ./
|
||||
RUN npm run build
|
||||
RUN npm test
|
||||
|
||||
# ─── 2. Build Quarkus app ────────────────────────
|
||||
FROM maven:3-eclipse-temurin-21-alpine AS build
|
||||
WORKDIR /build
|
||||
COPY pom.xml ./
|
||||
COPY src ./src
|
||||
# Copy the built frontend into the source tree so maven-resources-plugin picks it up
|
||||
COPY --from=frontend /build/dist ./frontend/dist/
|
||||
RUN mvn package -q -DskipFrontend=true -DskipFrontendTests=true
|
||||
|
||||
# ─── 3. Runtime ──────────────────────────────────
|
||||
FROM eclipse-temurin:21-jre-alpine
|
||||
WORKDIR /app
|
||||
# Quarkus uber-jar layout
|
||||
COPY --from=build /build/target/quarkus-app/ /app/
|
||||
EXPOSE 8765
|
||||
VOLUME /data
|
||||
ENV DOSTALGIA_DATA_DIR=/data
|
||||
ENTRYPOINT ["java", "-XX:MinHeapFreeRatio=10", "-XX:MaxHeapFreeRatio=20", "-jar", "quarkus-run.jar"]
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 droideparanoico
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,134 @@
|
||||
# DOStalgia 🕹️
|
||||
|
||||
A nostalgic DOS game hub. Upload your old DOS games, auto-scrape artwork and metadata from IGDB, and play them directly in your browser via js-dos (DOSBox compiled to WebAssembly).
|
||||
|
||||
### Library view
|
||||

|
||||
|
||||
### Game detail view
|
||||

|
||||
|
||||
## Features
|
||||
|
||||
### 🎮 Play in the browser
|
||||
Every uploaded game is packaged into a `.jsdos` bundle — a standard ZIP with embedded DOSBox configuration. When you hit **Play**, js-dos v8 is loaded from CDN and starts the emulator instantly in your browser. No plugins, no native installs.
|
||||
|
||||
**Save states are automatic** — js-dos persists your game progress to the browser's storage. Come back anytime and pick up where you left off.
|
||||
|
||||
### 📦 Handles any file structure
|
||||
DOS games come in all shapes. DOStalgia handles them transparently:
|
||||
|
||||
- **Flattening** — If your ZIP has a single root directory (e.g. `doom/` with all files inside), the extractor flattens it so the game files sit at the bundle root. No extra nesting.
|
||||
- **Subdirectory games** — If files are deeper, the autoexec automatically `cd`s to the right directory before launching the executable.
|
||||
- **CD images** — Games shipped on CD-ROM often need the disc mounted. DOStalgia detects `.iso`, `.cue`, `.img`, `.ccd`, and `.bin` files, fixes broken CloneCD `.cue` references (where the referenced `.bin` is actually `.img`), and mounts them in both `dosbox.conf` and `jsdos.json`.
|
||||
- **CD-only games** — If the archive contains only CD images with no executable, a CD-only bundle is created that mounts the disc and drops you at the DOS prompt.
|
||||
- **Hardcoded paths** — `ConfigPatcher` scans game config files for hardcoded absolute paths (e.g. `C:\FALLOUT1\MASTER.DAT`) that broke after flattening, and rewrites them to relative paths.
|
||||
|
||||
Other solutions such as [RomM](https://www.romm.app/) generate raw ZIP bundles and require you to write your own `dosbox.conf` with manual `mount` and `imgmount` commands. DOStalgia automates all of this for you.
|
||||
|
||||
### 🔍 Smart executable detection
|
||||
On upload, DOStalgia scans every `.exe`, `.com`, and `.bat` file and picks the best candidate as the main executable using a scoring system:
|
||||
|
||||
1. **Not an installer** — INSTALL/SETUP/CONFIG executables are deprioritised
|
||||
2. **DOS executables** — Pure DOS apps are preferred over Windows ones
|
||||
3. **Larger files** — Bigger executables are more likely to be the game
|
||||
4. **Shallow depth** — Files closer to the root are preferred
|
||||
5. **Self-extractor filtering** — PKZIP/PKSFX stubs are filtered out
|
||||
|
||||
All discovered executables are stored and available in the **Edit** page, where you can pick a different one via radio buttons.
|
||||
|
||||
Again, RomM requires you to manually specify the executable in a custom `dosbox.conf`, while DOStalgia detects and configures it for you.
|
||||
|
||||
> **⚠️ Cache note:** If you change the executable, the `.jsdos` bundle is patched in-place. Your browser may serve a cached version of the old bundle — if the game doesn't launch with the new executable, **hard-refresh** the play page (Ctrl+Shift+R / Cmd+Shift+R).
|
||||
|
||||
### 🛠 One-click Setup launcher
|
||||
Many DOS games include a `SETUP.EXE`, `INSTALL.EXE`, or `CONFIG.EXE` used to configure sound, controls, and graphics. When DOStalgia detects one:
|
||||
|
||||
- A **🛠 Setup** button appears on the game detail page
|
||||
- Clicking it launches the setup executable *without modifying the main game bundle*
|
||||
- The setup bundle is generated **on-the-fly** by the server — a modified `.jsdos` is streamed with the setup executable in the autoexec, then discarded. Nothing is written to disk.
|
||||
|
||||
This is not supported in RomM, where you would have to run the setup manually from the DOS prompt every time.
|
||||
|
||||
### 🪟 Windows game detection
|
||||
DOSBox can't run Windows executables. DOStalgia's `PlatformDetector` reads MZ/PE/NE headers to detect Windows executables:
|
||||
|
||||
- The game detail page shows a **🪟 Requires Windows 3.1** badge
|
||||
- The **Play** button is disabled with "Unplayable" text
|
||||
- If you click Play anyway, a warning overlay explains the limitation with a **Try anyway** fallback
|
||||
- Setup executables that are Windows-native are also filtered out from the Setup button
|
||||
|
||||
### 📡 IGDB metadata & media
|
||||
DOStalgia integrates with [IGDB](https://www.igdb.com/) (via Twitch OAuth2) to auto-populate game info:
|
||||
|
||||
- **Auto Scrape** — On upload, DOStalgia searches IGDB by title and fills in year, genre, developer, publisher, description, and cover art. If IGDB finds a DOS result, it prioritises it.
|
||||
- **Manual Search** — From the game detail page you can search IGDB by any query, browse results with cover thumbnails and DOS badges, and apply the one you want.
|
||||
- **Media** — Videos (YouTube embeds) and screenshots (1080p) are fetched and displayed in a scrollable media gallery with a preview player.
|
||||
|
||||
**Setup:**
|
||||
|
||||
1. Go to https://dev.twitch.tv/console/apps → **Register Your Application**
|
||||
2. Name: `dostalgia` (or anything), OAuth Redirect URL: `http://localhost`, Category: **Other**
|
||||
3. Copy the **Client ID** and generate a **Client Secret**
|
||||
4. Provide them via environment variables:
|
||||
|
||||
Method | How
|
||||
--- | ---
|
||||
Docker run | `-e TWITCH_CLIENT_ID=xxx -e TWITCH_CLIENT_SECRET=yyy`
|
||||
Docker Compose | Copy `.env.example` to `.env` and fill in the values
|
||||
Local dev | `export TWITCH_CLIENT_ID=xxx TWITCH_CLIENT_SECRET=yyy`
|
||||
|
||||
IGDB features degrade gracefully — if credentials aren't set, uploads and metadata editing still work, just without auto-scrape.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### With Docker Compose (easiest)
|
||||
|
||||
```bash
|
||||
# 1. (Optional) Enable IGDB metadata auto-scrape
|
||||
cp .env.example .env
|
||||
# Edit .env with your Twitch credentials (see IGDB section below)
|
||||
|
||||
# 2. Pull & run
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Open http://localhost:8765
|
||||
|
||||
### With Docker
|
||||
|
||||
```bash
|
||||
docker run -p 8765:8765 -v $(pwd)/data:/data \
|
||||
-e TWITCH_CLIENT_ID=your_id \
|
||||
-e TWITCH_CLIENT_SECRET=your_secret \
|
||||
droideparanoico/dostalgia
|
||||
```
|
||||
|
||||
## Development (without Docker)
|
||||
|
||||
Terminal 1 — Frontend dev server:
|
||||
```bash
|
||||
cd frontend && npm install && npm run dev
|
||||
```
|
||||
|
||||
Terminal 2 — Quarkus dev server:
|
||||
```bash
|
||||
mvn quarkus:dev
|
||||
```
|
||||
|
||||
Open http://localhost:5173 (Vite proxies API calls to Quarkus on port 8765)
|
||||
|
||||
### Production build (local)
|
||||
```bash
|
||||
cd frontend && npm install && npm run build
|
||||
mvn package -DskipTests
|
||||
java -jar target/quarkus-app/quarkus-run.jar
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
- **Backend**: Quarkus (Java 21, JAX-RS) — REST endpoints, zero external database
|
||||
- **Frontend**: Svelte 5 SPA with hash-based routing
|
||||
- **Emulation**: js-dos v8 loaded from CDN, runs DOSBox in WebAssembly
|
||||
- **Storage**: JSON-per-game under `/data/games/{id}/game.json`
|
||||
- **Saves**: Browser localStorage / indexedDB (managed by js-dos)
|
||||
@@ -0,0 +1,14 @@
|
||||
services:
|
||||
dostalgia:
|
||||
image: droideparanoico/dostalgia
|
||||
container_name: dostalgia
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8765:8765"
|
||||
volumes:
|
||||
# Configure here your data directory if you want persistence.
|
||||
- ./data:/data
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- DOSTALGIA_DATA_DIR=/data
|
||||
@@ -0,0 +1,16 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 36 36'><rect x='4' y='6' width='28' height='22' rx='2' fill='%2333FF33' opacity='.15'/><rect x='6' y='8' width='24' height='16' fill='%230A0A0A'/><rect x='8' y='10' width='20' height='10' fill='%2333FF33' opacity='.3'/><rect x='10' y='12' width='6' height='4' fill='%2333FF33' opacity='.6'/><rect x='18' y='12' width='6' height='4' fill='%2333FF33' opacity='.4'/></svg>" />
|
||||
<title>DOStalgia</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap" rel="stylesheet" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+3087
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "dostalgia-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
||||
"@testing-library/svelte": "^5.3.1",
|
||||
"jsdom": "^29.1.1",
|
||||
"svelte": "^5.0.0",
|
||||
"vite": "^5.0.0",
|
||||
"vitest": "^4.1.8"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
<script>
|
||||
import "./app.css";
|
||||
import Header from "./lib/Header.svelte";
|
||||
import Library from "./pages/Library.svelte";
|
||||
import GameDetail from "./pages/GameDetail.svelte";
|
||||
import Play from "./pages/Play.svelte";
|
||||
|
||||
let route = $state("/");
|
||||
let gameId = $state(null);
|
||||
let filterGenre = $state("");
|
||||
let filterYear = $state("");
|
||||
let uploadTriggered = $state(0);
|
||||
|
||||
function parseHash() {
|
||||
const hash = window.location.hash.slice(1) || "/";
|
||||
const [pathPart, qs] = hash.split("?");
|
||||
const parts = pathPart.split("/").filter(Boolean);
|
||||
if (parts.length === 0 || (parts.length === 1 && parts[0] === "")) {
|
||||
route = "/";
|
||||
gameId = null;
|
||||
// Parse filter query params
|
||||
const params = new URLSearchParams(qs || "");
|
||||
filterGenre = params.get("genre") || "";
|
||||
filterYear = params.get("year") || "";
|
||||
} else if (parts[0] === "game" && parts[1]) {
|
||||
route = "/game";
|
||||
gameId = parts[1];
|
||||
} else if (parts[0] === "play" && parts[1]) {
|
||||
route = "/play";
|
||||
gameId = parts[1];
|
||||
} else {
|
||||
route = "/";
|
||||
gameId = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Listen for hash changes and page loads
|
||||
$effect(() => {
|
||||
parseHash();
|
||||
const handler = () => parseHash();
|
||||
window.addEventListener("hashchange", handler);
|
||||
return () => window.removeEventListener("hashchange", handler);
|
||||
});
|
||||
</script>
|
||||
|
||||
<Header onUploadClick={() => uploadTriggered++} />
|
||||
|
||||
<main class="container">
|
||||
{#if route === "/"}
|
||||
<Library genreFilter={filterGenre} yearFilter={filterYear} {uploadTriggered} />
|
||||
{:else if route === "/game" && gameId}
|
||||
<GameDetail id={gameId} />
|
||||
{:else if route === "/play" && gameId}
|
||||
<Play id={gameId} />
|
||||
{:else}
|
||||
<div class="empty">
|
||||
<h2>Page not found</h2>
|
||||
<a href="#/">Back to Library</a>
|
||||
</div>
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
<style>
|
||||
.empty {
|
||||
text-align: center;
|
||||
padding: 80px 20px;
|
||||
}
|
||||
.empty h2 {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,250 @@
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
DOSTALGIA — Phosphor Dark Theme
|
||||
Inspired by classic green-screen CRT monitors (IBM 5151)
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
:root {
|
||||
/* Phosphor greens — from brightest glow to darkest trace */
|
||||
--phosphor: #33ff33;
|
||||
--phosphor-glow: #66ff66;
|
||||
--phosphor-dim: #22aa22;
|
||||
--phosphor-dark: #116611;
|
||||
--phosphor-burn: #0a330a;
|
||||
|
||||
/* Backgrounds */
|
||||
--bg: #0a0a0a;
|
||||
--bg-elevated: #111111;
|
||||
--surface: #1a1a1a;
|
||||
--surface-hover: #222222;
|
||||
--surface-active: #2a2a2a;
|
||||
|
||||
/* Borders */
|
||||
--border: #2a2a2a;
|
||||
--border-glow: #33ff3340;
|
||||
|
||||
/* Text */
|
||||
--text: #cccccc;
|
||||
--text-bright: #e8e8e8;
|
||||
--text-dim: #777777;
|
||||
|
||||
/* Sizing */
|
||||
--max-width: 1200px;
|
||||
--header-height: 64px;
|
||||
--radius: 8px;
|
||||
--radius-sm: 4px;
|
||||
|
||||
/* Typography */
|
||||
--font-mono: "Press Start 2P", monospace;
|
||||
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
RESET & BASE
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html {
|
||||
font-size: 16px;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-sans);
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
min-height: 100vh;
|
||||
line-height: 1.6;
|
||||
overflow-x: hidden;
|
||||
width: 100vw;
|
||||
}
|
||||
|
||||
html {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--phosphor);
|
||||
text-decoration: none;
|
||||
}
|
||||
a:hover {
|
||||
color: var(--phosphor-glow);
|
||||
text-shadow: 0 0 8px var(--phosphor-glow);
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
TYPOGRAPHY
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
h1, h2, h3, h4 {
|
||||
color: var(--text-bright);
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
}
|
||||
h1 { font-size: 2rem; }
|
||||
h2 { font-size: 1.5rem; }
|
||||
h3 { font-size: 1.2rem; }
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
BUTTONS
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 20px;
|
||||
border: 1px solid var(--phosphor-dim);
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--phosphor);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
.btn:hover {
|
||||
background: var(--phosphor-burn);
|
||||
border-color: var(--phosphor);
|
||||
box-shadow: 0 0 12px var(--phosphor-dark);
|
||||
}
|
||||
.btn:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--phosphor-dark);
|
||||
border-color: var(--phosphor);
|
||||
color: var(--phosphor-glow);
|
||||
}
|
||||
.btn-primary:hover {
|
||||
background: var(--phosphor-dim);
|
||||
color: var(--bg);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
border-color: #cc3333;
|
||||
color: #cc3333;
|
||||
}
|
||||
.btn-danger:hover {
|
||||
background: #331111;
|
||||
border-color: #ff4444;
|
||||
color: #ff4444;
|
||||
box-shadow: 0 0 12px #331111;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
INPUTS
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text);
|
||||
padding: 10px 14px;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 0.9rem;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
input:focus,
|
||||
textarea:focus,
|
||||
select:focus {
|
||||
outline: none;
|
||||
border-color: var(--phosphor-dim);
|
||||
box-shadow: 0 0 0 3px var(--border-glow);
|
||||
}
|
||||
input::placeholder {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
LAYOUT UTILITIES
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
.container {
|
||||
max-width: var(--max-width);
|
||||
margin: 0 auto;
|
||||
padding: 0 24px;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.container { padding: 0 12px; }
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
RESPONSIVE TOUCH ADJUSTMENTS
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
@media (max-width: 640px) {
|
||||
h1 { font-size: 1.5rem; }
|
||||
h2 { font-size: 1.25rem; }
|
||||
.btn { padding: 8px 14px; font-size: 0.85rem; min-height: 40px; }
|
||||
input, textarea, select { font-size: 16px; } /* prevents iOS zoom */
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
SCROLLBAR
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--bg);
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--surface-hover);
|
||||
border-radius: 4px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--phosphor-dark);
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
ANIMATIONS
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
@keyframes scanline {
|
||||
0% { transform: translateY(0); }
|
||||
100% { transform: translateY(4px); }
|
||||
}
|
||||
|
||||
@keyframes flicker {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.97; }
|
||||
}
|
||||
|
||||
@keyframes phosphor-pulse {
|
||||
0%, 100% { text-shadow: 0 0 4px var(--phosphor-dim); }
|
||||
50% { text-shadow: 0 0 12px var(--phosphor), 0 0 24px var(--phosphor-dark); }
|
||||
}
|
||||
|
||||
/* Subtle CRT effect on hover cards */
|
||||
.crt-hover {
|
||||
position: relative;
|
||||
}
|
||||
.crt-hover::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: repeating-linear-gradient(
|
||||
0deg,
|
||||
transparent,
|
||||
transparent 2px,
|
||||
rgba(51, 255, 51, 0.015) 2px,
|
||||
rgba(51, 255, 51, 0.015) 4px
|
||||
);
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
.crt-hover:hover::after {
|
||||
opacity: 1;
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
<script>
|
||||
import { push } from "../lib/router.js";
|
||||
|
||||
let { game } = $props();
|
||||
|
||||
function navigate() {
|
||||
push(`/game/${game.id}`);
|
||||
}
|
||||
</script>
|
||||
|
||||
<button class="card crt-hover" onclick={navigate}>
|
||||
<div class="cover">
|
||||
{#if game.has_cover}
|
||||
<img src={`/games/${game.id}/cover.jpg`} alt={game.title} loading="lazy" />
|
||||
{:else}
|
||||
<div class="cover-placeholder">
|
||||
<span class="cover-icon">💾</span>
|
||||
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="info">
|
||||
<h3>{game.title}</h3>
|
||||
{#if game.year}
|
||||
<span class="year">{game.year}</span>
|
||||
{/if}
|
||||
{#if game.genre}
|
||||
<span class="genre">{game.genre}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if game.platform === 'windows'}
|
||||
<div class="badge badge-windows">🪟 Win</div>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<style>
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
.card:hover {
|
||||
border-color: var(--phosphor-dim);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 20px rgba(51, 255, 51, 0.1);
|
||||
}
|
||||
.cover {
|
||||
aspect-ratio: 3/4;
|
||||
background: var(--bg-elevated);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
.cover img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.cover-placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.cover-icon {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
.info {
|
||||
padding: 12px;
|
||||
flex: 1;
|
||||
}
|
||||
.info h3 {
|
||||
font-size: 0.95rem;
|
||||
margin-bottom: 4px;
|
||||
color: var(--text-bright);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.info { padding: 8px; }
|
||||
.info h3 { font-size: 0.8rem; }
|
||||
.year, .genre { font-size: 0.7rem; }
|
||||
.badge { font-size: 0.6rem; padding: 1px 6px; top: 4px; right: 4px; }
|
||||
}
|
||||
.year, .genre {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-dim);
|
||||
display: inline-block;
|
||||
margin-right: 8px;
|
||||
}
|
||||
.badge {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
background: var(--phosphor-dark);
|
||||
color: var(--phosphor);
|
||||
font-size: 0.7rem;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.badge-windows {
|
||||
background: #1a3a5c;
|
||||
color: #6ab0ff;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,72 @@
|
||||
<script>
|
||||
let { onUploadClick = () => {} } = $props();
|
||||
</script>
|
||||
|
||||
<header class="header">
|
||||
<div class="header-inner">
|
||||
<a href="#/" class="logo">
|
||||
<svg class="logo-icon" width="36" height="36" viewBox="0 0 36 36" fill="none">
|
||||
<rect x="4" y="6" width="28" height="22" rx="2" fill="#33FF33" opacity="0.15"/>
|
||||
<rect x="6" y="8" width="24" height="16" fill="#0A0A0A"/>
|
||||
<rect x="8" y="10" width="20" height="10" fill="#33FF33" opacity="0.3"/>
|
||||
<rect x="10" y="12" width="6" height="4" fill="#33FF33" opacity="0.6"/>
|
||||
<rect x="18" y="12" width="6" height="4" fill="#33FF33" opacity="0.4"/>
|
||||
<rect x="10" y="24" width="16" height="3" fill="#33FF33" opacity="0.2"/>
|
||||
<rect x="12" y="29" width="12" height="2" fill="#33FF33" opacity="0.1"/>
|
||||
</svg>
|
||||
<span class="logo-text">DOSTALGIA</span>
|
||||
</a>
|
||||
<nav>
|
||||
<button class="btn" onclick={onUploadClick}>+ Upload</button>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<style>
|
||||
.header {
|
||||
height: var(--header-height);
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--bg-elevated);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
.header-inner {
|
||||
max-width: var(--max-width);
|
||||
margin: 0 auto;
|
||||
padding: 0 24px;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.header-inner { padding: 0 12px; }
|
||||
}
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
.logo-icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.logo-text {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 1.4rem;
|
||||
font-weight: 700;
|
||||
color: var(--phosphor);
|
||||
letter-spacing: 0.15em;
|
||||
animation: phosphor-pulse 3s ease-in-out infinite;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.logo-text { font-size: 1rem; letter-spacing: 0.1em; }
|
||||
.logo-icon { width: 28px; height: 28px; }
|
||||
}
|
||||
nav {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/svelte";
|
||||
import GameCard from "../GameCard.svelte";
|
||||
|
||||
function makeGame(overrides = {}) {
|
||||
return {
|
||||
id: "test-1",
|
||||
title: "Test Game",
|
||||
year: 1995,
|
||||
genre: "Adventure",
|
||||
platform: "dos",
|
||||
has_cover: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("GameCard", () => {
|
||||
it("renders the game title", () => {
|
||||
render(GameCard, { props: { game: makeGame() } });
|
||||
expect(screen.getByText("Test Game")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders the year when provided", () => {
|
||||
render(GameCard, { props: { game: makeGame({ year: 1997 }) } });
|
||||
expect(screen.getByText("1997")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does not render year when absent", () => {
|
||||
render(GameCard, { props: { game: makeGame({ year: null }) } });
|
||||
expect(screen.queryByText("1995")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders the genre when provided", () => {
|
||||
render(GameCard, { props: { game: makeGame({ genre: "RPG" }) } });
|
||||
expect(screen.getByText("RPG")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows a cover image when has_cover is true", () => {
|
||||
const game = makeGame({ has_cover: true });
|
||||
render(GameCard, { props: { game } });
|
||||
const img = screen.getByRole("img");
|
||||
expect(img).toBeTruthy();
|
||||
expect(img.getAttribute("src")).toBe(`/games/${game.id}/cover.jpg`);
|
||||
expect(img.getAttribute("alt")).toBe(game.title);
|
||||
});
|
||||
|
||||
it("shows a placeholder when has_cover is false", () => {
|
||||
render(GameCard, { props: { game: makeGame({ has_cover: false }) } });
|
||||
expect(screen.getByText("💾")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows Windows badge for windows platform", () => {
|
||||
render(GameCard, {
|
||||
props: { game: makeGame({ platform: "windows" }) },
|
||||
});
|
||||
expect(screen.getByText(/🪟/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does not show Windows badge for DOS platform", () => {
|
||||
render(GameCard, { props: { game: makeGame({ platform: "dos" }) } });
|
||||
expect(screen.queryByText(/🪟/)).toBeNull();
|
||||
});
|
||||
|
||||
it("navigates on click", async () => {
|
||||
window.location.hash = "";
|
||||
render(GameCard, { props: { game: makeGame() } });
|
||||
const btn = screen.getByRole("button");
|
||||
btn.click();
|
||||
expect(window.location.hash).toBe("#/game/test-1");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { artworkUrl, bundleUrl, setupBundleUrl } from "../api.js";
|
||||
|
||||
describe("artworkUrl", () => {
|
||||
it("returns null for null input", () => {
|
||||
expect(artworkUrl(null)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for undefined input", () => {
|
||||
expect(artworkUrl(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it("passes through absolute HTTP URLs", () => {
|
||||
expect(artworkUrl("https://example.com/cover.jpg")).toBe(
|
||||
"https://example.com/cover.jpg"
|
||||
);
|
||||
});
|
||||
|
||||
it("proxies relative paths through /artwork/", () => {
|
||||
expect(artworkUrl("images/game.png")).toBe("/artwork/images/game.png");
|
||||
});
|
||||
|
||||
it("preserves nested relative paths", () => {
|
||||
expect(artworkUrl("some/deep/path/file.webp")).toBe(
|
||||
"/artwork/some/deep/path/file.webp"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("bundleUrl", () => {
|
||||
it("returns null for null game", () => {
|
||||
expect(bundleUrl(null)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for undefined game", () => {
|
||||
expect(bundleUrl(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it("builds URL from bundle_file", () => {
|
||||
expect(bundleUrl({ bundle_file: "mygame.zip" })).toBe("/games/mygame.zip");
|
||||
});
|
||||
|
||||
it("preserves subdirectory bundle paths", () => {
|
||||
expect(bundleUrl({ bundle_file: "subdir/game.zip" })).toBe(
|
||||
"/games/subdir/game.zip"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setupBundleUrl", () => {
|
||||
it("returns null for null game", () => {
|
||||
expect(setupBundleUrl(null)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for undefined game", () => {
|
||||
expect(setupBundleUrl(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it("builds API setup URL from game id", () => {
|
||||
expect(setupBundleUrl({ id: "game-1" })).toBe(
|
||||
"/api/games/game-1/setup-bundle"
|
||||
);
|
||||
});
|
||||
|
||||
it("handles games with numeric id", () => {
|
||||
expect(setupBundleUrl({ id: 42 })).toBe("/api/games/42/setup-bundle");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { push, replace } from "../router.js";
|
||||
|
||||
describe("router", () => {
|
||||
describe("push", () => {
|
||||
it("sets window.location.hash with # prefix", () => {
|
||||
window.location.hash = "";
|
||||
push("/game/123");
|
||||
expect(window.location.hash).toBe("#/game/123");
|
||||
});
|
||||
|
||||
it("handles root path", () => {
|
||||
window.location.hash = "";
|
||||
push("/");
|
||||
expect(window.location.hash).toBe("#/");
|
||||
});
|
||||
|
||||
it("replaces hash on subsequent calls", () => {
|
||||
window.location.hash = "";
|
||||
push("/first");
|
||||
push("/second");
|
||||
expect(window.location.hash).toBe("#/second");
|
||||
});
|
||||
});
|
||||
|
||||
describe("replace", () => {
|
||||
it("sets hash via replace (no back-navigation allowed)", () => {
|
||||
window.location.hash = "#/old";
|
||||
replace("/new");
|
||||
expect(window.location.hash).toBe("#/new");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
/** API client for DOStalgia backend. */
|
||||
|
||||
const BASE = import.meta.env.PROD ? "" : ""; // Proxy handles it in dev
|
||||
|
||||
async function request(path, options = {}) {
|
||||
const res = await fetch(`${BASE}${path}`, {
|
||||
headers: { "Content-Type": "application/json", ...options.headers },
|
||||
...options,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ detail: res.statusText }));
|
||||
throw new Error(err.detail || `HTTP ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/** Fetch game list */
|
||||
export async function listGames(params = {}) {
|
||||
const qs = new URLSearchParams();
|
||||
if (params.search) qs.set("search", params.search);
|
||||
if (params.genre) qs.set("genre", params.genre);
|
||||
if (params.limit) qs.set("limit", params.limit);
|
||||
if (params.offset) qs.set("offset", params.offset);
|
||||
return request(`/api/games?${qs}`);
|
||||
}
|
||||
|
||||
/** Fetch single game */
|
||||
export async function getGame(id) {
|
||||
return request(`/api/games/${id}`);
|
||||
}
|
||||
|
||||
/** Update game metadata */
|
||||
export async function updateGame(id, data) {
|
||||
return request(`/api/games/${id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
/** Delete a game */
|
||||
export async function deleteGame(id) {
|
||||
return request(`/api/games/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
/** Upload a game ZIP */
|
||||
export async function uploadGame(file, title, igdbId) {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
if (title) form.append("title", title);
|
||||
if (igdbId) form.append("igdb_id", String(igdbId));
|
||||
|
||||
const res = await fetch(`${BASE}/api/upload`, {
|
||||
method: "POST",
|
||||
body: form,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ detail: res.statusText }));
|
||||
throw new Error(err.detail || `HTTP ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/** Search IGDB for a game title (requires TWITCH_CLIENT_ID/SECRET set) */
|
||||
export async function searchIGDB(query) {
|
||||
const res = await fetch(`${BASE}/api/igdb/search?q=${encodeURIComponent(query)}`);
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: res.statusText }));
|
||||
throw new Error(err.error || `HTTP ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/** Apply IGDB metadata + cover to a game (auto or by igdb_id) */
|
||||
export async function scrapeIGDB(gameId, igdbId) {
|
||||
const body = igdbId ? { igdb_id: igdbId } : {};
|
||||
const res = await fetch(`${BASE}/api/igdb/scrape/${gameId}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: res.statusText }));
|
||||
throw new Error(err.error || `HTTP ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/** Download a game's ZIP bundle */
|
||||
export function downloadGame(id) {
|
||||
window.open(`${BASE}/api/games/${id}/download`, '_blank');
|
||||
}
|
||||
|
||||
/** Check IGDB status */
|
||||
export async function igdbStatus() {
|
||||
const res = await fetch(`${BASE}/api/igdb/status`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/** Get artwork URL for a path — returns proxy URL or placeholder */
|
||||
export function artworkUrl(path) {
|
||||
if (!path) return null;
|
||||
if (path.startsWith("http")) return path;
|
||||
return `/artwork/${path}`;
|
||||
}
|
||||
|
||||
/** Get game bundle URL for normal play */
|
||||
export function bundleUrl(game) {
|
||||
if (!game) return null;
|
||||
// Use the bundle_file path from the backend (handles both old flat layout
|
||||
// and new in-directory layout transparently)
|
||||
return `/games/${game.bundle_file}`;
|
||||
}
|
||||
|
||||
/** Get game bundle URL for setup mode (runs SETUP.EXE instead) */
|
||||
export function setupBundleUrl(game) {
|
||||
if (!game) return null;
|
||||
return `/api/games/${game.id}/setup-bundle`;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/** Simple hash-based router for SPA. */
|
||||
|
||||
export function push(path) {
|
||||
window.location.hash = "#" + path;
|
||||
}
|
||||
|
||||
export function replace(path) {
|
||||
window.location.replace("#" + path);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import App from "./App.svelte";
|
||||
import { mount } from "svelte";
|
||||
|
||||
const app = mount(App, {
|
||||
target: document.getElementById("app"),
|
||||
});
|
||||
|
||||
export default app;
|
||||
@@ -0,0 +1,714 @@
|
||||
<script>
|
||||
import { getGame, deleteGame, updateGame, searchIGDB, scrapeIGDB, downloadGame, bundleUrl, setupBundleUrl, artworkUrl } from "../lib/api.js";
|
||||
import { push } from "../lib/router.js";
|
||||
|
||||
let { id } = $props();
|
||||
|
||||
let game = $state(null);
|
||||
let loading = $state(true);
|
||||
let editing = $state(false);
|
||||
let saving = $state(false);
|
||||
let error = $state("");
|
||||
|
||||
let editTitle = $state("");
|
||||
let editYear = $state(0);
|
||||
let editGenre = $state("");
|
||||
let editDeveloper = $state("");
|
||||
let editPublisher = $state("");
|
||||
let editDescription = $state("");
|
||||
let editPlatform = $state("");
|
||||
let editExecutable = $state("");
|
||||
|
||||
// IGDB scrape state
|
||||
let scraping = $state(false);
|
||||
let scrapeError = $state("");
|
||||
let igdbResults = $state(null);
|
||||
let igdbQuery = $state("");
|
||||
let applying = $state(null); // igdb_id being applied
|
||||
|
||||
// Media selection — tracks which media item is shown in the media container
|
||||
let selectedMedia = $state(null);
|
||||
|
||||
function selectMedia(type, idOrUrl) {
|
||||
if (type === 'video') {
|
||||
selectedMedia = { type: 'video', id: idOrUrl };
|
||||
} else if (type === 'screenshot') {
|
||||
selectedMedia = { type: 'screenshot', url: idOrUrl };
|
||||
}
|
||||
}
|
||||
|
||||
function handleDownload() {
|
||||
downloadGame(id);
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
try {
|
||||
game = await getGame(id);
|
||||
igdbQuery = game.title || "";
|
||||
// Set initial selected media to first available item
|
||||
if (game.videos?.length) {
|
||||
selectedMedia = { type: 'video', id: game.videos[0] };
|
||||
} else if (game.screenshots?.length) {
|
||||
selectedMedia = { type: 'screenshot', url: game.screenshots[0] };
|
||||
}
|
||||
} catch (e) {
|
||||
error = "Game not found";
|
||||
}
|
||||
loading = false;
|
||||
}
|
||||
|
||||
function startEdit() {
|
||||
editTitle = game.title;
|
||||
editYear = game.year || 0;
|
||||
editGenre = game.genre || "";
|
||||
editDeveloper = game.developer || "";
|
||||
editPublisher = game.publisher || "";
|
||||
editDescription = game.description || "";
|
||||
editPlatform = game.platform || "";
|
||||
editExecutable = game.executable || "";
|
||||
editing = true;
|
||||
}
|
||||
|
||||
async function saveEdit() {
|
||||
saving = true;
|
||||
try {
|
||||
game = await updateGame(id, {
|
||||
title: editTitle,
|
||||
year: editYear || undefined,
|
||||
genre: editGenre || undefined,
|
||||
developer: editDeveloper || undefined,
|
||||
publisher: editPublisher || undefined,
|
||||
description: editDescription || undefined,
|
||||
platform: editPlatform || undefined,
|
||||
executable: editExecutable || undefined,
|
||||
});
|
||||
editing = false;
|
||||
} catch (e) {
|
||||
error = e.message;
|
||||
}
|
||||
saving = false;
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!confirm(`Delete "${game.title}"? This cannot be undone.`)) return;
|
||||
try {
|
||||
await deleteGame(id);
|
||||
push("/");
|
||||
} catch (e) {
|
||||
error = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
function handlePlay() {
|
||||
push(`/play/${id}`);
|
||||
}
|
||||
|
||||
function handleSetup() {
|
||||
push(`/play/${id}?setup=1`);
|
||||
}
|
||||
|
||||
// ─── IGDB Scrape ───────────────────────────────────────────
|
||||
|
||||
async function handleSearchIGDB() {
|
||||
const query = igdbQuery.trim() || game.title;
|
||||
if (!query) return;
|
||||
scraping = true;
|
||||
scrapeError = "";
|
||||
igdbResults = null;
|
||||
try {
|
||||
const data = await searchIGDB(query);
|
||||
igdbResults = data.results || [];
|
||||
if (igdbResults.length === 0) {
|
||||
scrapeError = "No results found on IGDB. Try a different title.";
|
||||
}
|
||||
} catch (e) {
|
||||
scrapeError = "IGDB search failed: " + e.message;
|
||||
}
|
||||
scraping = false;
|
||||
}
|
||||
|
||||
async function handleAutoScrape() {
|
||||
scraping = true;
|
||||
scrapeError = "";
|
||||
try {
|
||||
game = await scrapeIGDB(id);
|
||||
scrapeError = game.has_cover || game.year ? "Metadata updated from IGDB!" : "No matching game found on IGDB.";
|
||||
} catch (e) {
|
||||
scrapeError = "IGDB scrape failed: " + e.message;
|
||||
}
|
||||
scraping = false;
|
||||
}
|
||||
|
||||
async function handleApplyScrape(igdbId) {
|
||||
applying = igdbId;
|
||||
scrapeError = "";
|
||||
try {
|
||||
game = await scrapeIGDB(id, igdbId);
|
||||
igdbResults = null; // clear results after applying
|
||||
scrapeError = "Game metadata updated!";
|
||||
} catch (e) {
|
||||
scrapeError = "Failed to apply IGDB data: " + e.message;
|
||||
}
|
||||
applying = null;
|
||||
}
|
||||
|
||||
$effect(() => { load(); });
|
||||
</script>
|
||||
|
||||
{#if loading}
|
||||
<div class="loading">Loading...</div>
|
||||
{:else if error && !game}
|
||||
<div class="error">{error}</div>
|
||||
<a href="#/" class="back-link">← Back to Library</a>
|
||||
{:else if game}
|
||||
<div class="detail">
|
||||
<a href="#/" class="back-link">← Back to Library</a>
|
||||
|
||||
<div class="detail-layout">
|
||||
<!-- Cover + Meta + Developer -->
|
||||
<div class="cover-section">
|
||||
{#if game.has_cover}
|
||||
<img src={`/games/${game.id}/cover.jpg`} alt={game.title} class="cover-img" />
|
||||
{:else}
|
||||
<div class="cover-placeholder">💾</div>
|
||||
{/if}
|
||||
{#if !editing}
|
||||
{#if game.year || game.genre}
|
||||
<div class="meta">
|
||||
{#if game.year}
|
||||
<a href="#/?year={game.year}" class="meta-item meta-link">{game.year}</a>
|
||||
{/if}
|
||||
{#if game.genre}
|
||||
<a href="#/?genre={encodeURIComponent(game.genre)}" class="meta-item meta-link">{game.genre}</a>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if game.developer}
|
||||
<p class="dev">by {game.developer}{game.publisher ? ` · ${game.publisher}` : ""}</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Info -->
|
||||
<div class="info-section">
|
||||
{#if editing}
|
||||
<input type="text" bind:value={editTitle} placeholder="Title" class="edit-title" />
|
||||
<div class="edit-row">
|
||||
<input type="number" bind:value={editYear} placeholder="Year" style="width:100px" />
|
||||
<input type="text" bind:value={editGenre} placeholder="Genre" style="flex:1" />
|
||||
</div>
|
||||
<div class="edit-row">
|
||||
<input type="text" bind:value={editDeveloper} placeholder="Developer" style="flex:1" />
|
||||
<input type="text" bind:value={editPublisher} placeholder="Publisher" style="flex:1" />
|
||||
</div>
|
||||
<textarea bind:value={editDescription} placeholder="Description" rows="4"></textarea>
|
||||
<div class="edit-row">
|
||||
<select bind:value={editPlatform} style="flex:1">
|
||||
<option value="">Auto (unknown)</option>
|
||||
<option value="dos">DOS</option>
|
||||
<option value="windows">Windows</option>
|
||||
</select>
|
||||
</div>
|
||||
{#if game.executables && game.executables.length > 0}
|
||||
<div class="edit-exec-section">
|
||||
<span class="edit-exec-label">Main executable:</span>
|
||||
<div class="edit-exec-list">
|
||||
{#each game.executables as exe}
|
||||
<label class="edit-exec-option" class:selected={editExecutable === exe}>
|
||||
<input
|
||||
type="radio"
|
||||
name="executable"
|
||||
value={exe}
|
||||
checked={editExecutable === exe}
|
||||
onchange={() => editExecutable = exe}
|
||||
/>
|
||||
<span class="edit-exec-path">{exe}</span>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="edit-actions">
|
||||
<button class="btn btn-primary" onclick={saveEdit} disabled={saving}>
|
||||
{saving ? "Saving..." : "Save"}
|
||||
</button>
|
||||
<button class="btn" onclick={() => editing = false}>Cancel</button>
|
||||
</div>
|
||||
{:else}
|
||||
<h1>{game.title}</h1>
|
||||
{#if game.platform}
|
||||
<div class="platform-badge" class:platform-win={game.platform === 'windows'}>
|
||||
{#if game.platform === 'windows'}
|
||||
🪟 Requires Windows 3.1 — may not work in browser
|
||||
{:else}
|
||||
💾 DOS
|
||||
{#if game.bundle_size}
|
||||
<span class="size-badge">· {game.bundle_size >= 1073741824
|
||||
? (game.bundle_size / 1073741824).toFixed(1) + ' GB'
|
||||
: game.bundle_size >= 1048576
|
||||
? Math.round(game.bundle_size / 1048576) + ' MB'
|
||||
: Math.round(game.bundle_size / 1024) + ' KB'}</span>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if game.description}
|
||||
<p class="description">{game.description}</p>
|
||||
{/if}
|
||||
|
||||
<div class="actions">
|
||||
{#if game.ready && (game.platform !== 'windows')}
|
||||
<button class="btn btn-primary" onclick={handlePlay}>▶ Play</button>
|
||||
{:else if game.ready && game.platform === 'windows'}
|
||||
<button class="btn btn-primary btn-disabled" disabled title="Windows games need Windows 3.1 emulation (not yet supported)">
|
||||
▶ Unplayable
|
||||
</button>
|
||||
{/if}
|
||||
{#if game.has_setup && game.platform !== 'windows'}
|
||||
<button class="btn btn-setup" onclick={handleSetup}>🛠 Setup</button>
|
||||
{/if}
|
||||
<button class="btn" onclick={startEdit}>✏ Edit</button>
|
||||
<button class="btn" onclick={handleDownload}>⬇ Download</button>
|
||||
<button class="btn btn-danger" onclick={handleDelete}>✕ Delete</button>
|
||||
</div>
|
||||
|
||||
<!-- Media Section — container + clickable row for all videos & screenshots -->
|
||||
{#if selectedMedia}
|
||||
<div class="media-section">
|
||||
<span class="media-label">📺 Media</span>
|
||||
<div class="media-container">
|
||||
{#if selectedMedia.type === 'video'}
|
||||
<iframe
|
||||
src="https://www.youtube.com/embed/{selectedMedia.id}"
|
||||
title="Gameplay video"
|
||||
allowfullscreen
|
||||
frameborder="0"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
></iframe>
|
||||
{:else if selectedMedia.type === 'screenshot'}
|
||||
<img src={selectedMedia.url} alt="Screenshot" class="media-main-img" />
|
||||
{/if}
|
||||
</div>
|
||||
<div class="media-row">
|
||||
{#each game.videos ?? [] as video, i}
|
||||
<button
|
||||
class="media-thumb"
|
||||
class:active={selectedMedia.type === 'video' && selectedMedia.id === video}
|
||||
onclick={() => selectMedia('video', video)}
|
||||
title="Video {i + 1}"
|
||||
>
|
||||
<img src="https://img.youtube.com/vi/{video}/mqdefault.jpg" alt="Video {i + 1}" loading="lazy" />
|
||||
<span class="media-thumb-icon">▶</span>
|
||||
</button>
|
||||
{/each}
|
||||
{#each game.screenshots ?? [] as shot}
|
||||
<button
|
||||
class="media-thumb"
|
||||
class:active={selectedMedia.type === 'screenshot' && selectedMedia.url === shot}
|
||||
onclick={() => selectMedia('screenshot', shot)}
|
||||
title="Screenshot"
|
||||
>
|
||||
<img src={shot} alt="Screenshot" loading="lazy" />
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- IGDB Scrape Section -->
|
||||
<div class="scrape-section">
|
||||
<div class="scrape-header">
|
||||
<span class="scrape-label">📡 IGDB Metadata</span>
|
||||
<div class="scrape-buttons">
|
||||
<button class="btn btn-sm" onclick={handleAutoScrape} disabled={scraping}>
|
||||
{scraping ? "Searching..." : "Auto Scrape"}
|
||||
</button>
|
||||
<button class="btn btn-sm" onclick={handleSearchIGDB} disabled={scraping}>
|
||||
Search
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if igdbResults !== null}
|
||||
<div class="igdb-query-row">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={igdbQuery}
|
||||
placeholder="Search IGDB..."
|
||||
onkeydown={(e) => e.key === "Enter" && handleSearchIGDB()}
|
||||
/>
|
||||
</div>
|
||||
{#if igdbResults.length > 0}
|
||||
<div class="igdb-results">
|
||||
{#each igdbResults as result (result.igdb_id)}
|
||||
<button
|
||||
class="igdb-result"
|
||||
onclick={() => handleApplyScrape(result.igdb_id)}
|
||||
disabled={applying === result.igdb_id}
|
||||
>
|
||||
{#if result.cover_url}
|
||||
<img
|
||||
src={result.cover_url}
|
||||
alt={result.name}
|
||||
class="igdb-thumb"
|
||||
loading="lazy"
|
||||
/>
|
||||
{:else}
|
||||
<div class="igdb-thumb-placeholder">🎮</div>
|
||||
{/if}
|
||||
<div class="igdb-info">
|
||||
<strong>{result.name}</strong>
|
||||
<span class="igdb-meta">
|
||||
{result.year || "—"}
|
||||
{#if result.is_dos}<span class="dos-badge">DOS</span>{/if}
|
||||
{#if result.genres?.length}
|
||||
· {result.genres.slice(0, 2).join(", ")}
|
||||
{/if}
|
||||
</span>
|
||||
{#if result.developer}
|
||||
<span class="igdb-dev">{result.developer}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if scrapeError}
|
||||
<div class="scrape-status" class:scrape-ok={scrapeError.includes("updated")}>
|
||||
{scrapeError}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.detail { padding: 32px 0; }
|
||||
.loading { text-align: center; padding: 60px; color: var(--text-dim); }
|
||||
.error { background: #331111; border: 1px solid #cc3333; color: #ff6666; padding: 12px; border-radius: var(--radius-sm); margin-bottom: 16px; }
|
||||
.back-link { display: inline-block; margin-bottom: 24px; color: var(--text-dim); }
|
||||
.back-link:hover { color: var(--phosphor); }
|
||||
.detail-layout { display: grid; grid-template-columns: 300px 1fr; gap: 32px; align-items: start; }
|
||||
.detail-layout > * { min-width: 0; } /* prevent grid overflow from wide content */
|
||||
@media (max-width: 640px) { .detail-layout { grid-template-columns: 1fr; } }
|
||||
.cover-section { border-radius: var(--radius); overflow: hidden; }
|
||||
.cover-section .meta { margin-top: 12px; }
|
||||
.cover-section .dev { margin-top: 8px; }
|
||||
.cover-img { width: 100%; border-radius: var(--radius); }
|
||||
.cover-placeholder { aspect-ratio: 3/4; background: var(--surface); display: flex; align-items: center; justify-content: center; font-size: 4rem; border-radius: var(--radius); }
|
||||
.info-section h1 { color: var(--phosphor); font-family: var(--font-mono); margin-bottom: 8px; word-break: break-word; }
|
||||
.platform-badge {
|
||||
display: inline-block;
|
||||
font-size: 0.8rem;
|
||||
padding: 3px 10px;
|
||||
border-radius: 10px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
background: var(--phosphor-dark);
|
||||
color: var(--phosphor);
|
||||
}
|
||||
.platform-badge.platform-win {
|
||||
background: #1a3a5c;
|
||||
color: #6ab0ff;
|
||||
}
|
||||
.size-badge {
|
||||
font-weight: 400;
|
||||
opacity: 0.8;
|
||||
}
|
||||
.meta { display: flex; gap: 8px; margin-bottom: 8px; }
|
||||
.meta-item { background: var(--surface-hover); color: var(--phosphor-dim); padding: 4px 12px; border-radius: 12px; font-size: 0.85rem; }
|
||||
.meta-link { cursor: pointer; text-decoration: none; transition: all 0.15s; display: inline-block; }
|
||||
.meta-link:hover { background: var(--phosphor-dark); color: var(--phosphor); }
|
||||
.dev { color: var(--text-dim); margin-bottom: 16px; }
|
||||
.description { color: var(--text); line-height: 1.7; margin-bottom: 24px; overflow-wrap: break-word; word-break: break-word; }
|
||||
.actions { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 24px; }
|
||||
.edit-title { font-size: 1.5rem; margin-bottom: 12px; width: 100%; }
|
||||
.edit-row { display: flex; gap: 8px; margin-bottom: 8px; }
|
||||
.edit-actions { display: flex; gap: 8px; margin-top: 12px; }
|
||||
textarea { width: 100%; min-height: 100px; }
|
||||
|
||||
/* Media Section — container + clickable thumbnail row */
|
||||
.media-section {
|
||||
margin-top: 24px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.media-label {
|
||||
display: block;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-dim);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.media-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.media-container iframe {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.media-main-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
background: #000;
|
||||
}
|
||||
.media-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
.media-thumb {
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
width: 200px;
|
||||
height: 113px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 2px solid var(--border);
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
background: none;
|
||||
transition: border-color 0.15s, opacity 0.15s;
|
||||
}
|
||||
.media-thumb:hover {
|
||||
border-color: var(--phosphor-dim);
|
||||
opacity: 0.9;
|
||||
}
|
||||
.media-thumb.active {
|
||||
border-color: var(--phosphor);
|
||||
}
|
||||
.media-thumb img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.media-thumb-icon {
|
||||
position: absolute;
|
||||
bottom: 4px;
|
||||
left: 4px;
|
||||
background: rgba(0,0,0,0.7);
|
||||
color: #fff;
|
||||
font-size: 0.7rem;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* IGDB Scrape */
|
||||
.scrape-section {
|
||||
margin-top: 24px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.scrape-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.scrape-label {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-dim);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.scrape-buttons {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
.btn-sm {
|
||||
padding: 4px 12px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.igdb-query-row {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.igdb-query-row input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.igdb-results {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.igdb-result {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 8px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: border-color 0.15s;
|
||||
width: 100%;
|
||||
}
|
||||
.igdb-result:hover {
|
||||
border-color: var(--phosphor-dim);
|
||||
}
|
||||
.igdb-result:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: wait;
|
||||
}
|
||||
.igdb-thumb {
|
||||
width: 48px;
|
||||
height: 64px;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.igdb-thumb-placeholder {
|
||||
width: 48px;
|
||||
height: 64px;
|
||||
background: var(--surface-hover);
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.2rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.igdb-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.igdb-info strong {
|
||||
display: block;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-bright);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.igdb-meta {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.dos-badge {
|
||||
background: var(--phosphor-dark);
|
||||
color: var(--phosphor);
|
||||
padding: 1px 6px;
|
||||
border-radius: 6px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.igdb-dev {
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-dim);
|
||||
margin-top: 2px;
|
||||
}
|
||||
.scrape-status {
|
||||
margin-top: 8px;
|
||||
padding: 6px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.85rem;
|
||||
background: #221100;
|
||||
color: #ff9933;
|
||||
}
|
||||
.scrape-status.scrape-ok {
|
||||
background: #112211;
|
||||
color: #66ff66;
|
||||
}
|
||||
|
||||
/* ─── Executable Picker ────────────────────── */
|
||||
.edit-exec-section {
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.edit-exec-label {
|
||||
display: block;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-dim);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.edit-exec-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
max-height: 180px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.edit-exec-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
font-family: var(--font-mono);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
}
|
||||
.edit-exec-option:hover {
|
||||
border-color: var(--phosphor-dim);
|
||||
background: var(--surface-hover);
|
||||
}
|
||||
.edit-exec-option.selected {
|
||||
border-color: var(--phosphor);
|
||||
background: var(--phosphor-burn);
|
||||
}
|
||||
.edit-exec-option input[type="radio"] {
|
||||
accent-color: var(--phosphor);
|
||||
margin: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.edit-exec-path {
|
||||
word-break: break-all;
|
||||
color: var(--text);
|
||||
}
|
||||
.edit-exec-option.selected .edit-exec-path {
|
||||
color: var(--phosphor-glow);
|
||||
}
|
||||
|
||||
/* ─── Responsive ────────────────────────────────── */
|
||||
@media (max-width: 640px) {
|
||||
.detail { padding: 16px 0; }
|
||||
.detail-layout { gap: 16px; }
|
||||
.cover-section { max-width: 200px; }
|
||||
.cover-placeholder { font-size: 3rem; }
|
||||
.media-thumb { width: 160px; height: 90px; }
|
||||
.scrape-header { flex-direction: column; align-items: flex-start; gap: 8px; }
|
||||
.scrape-buttons { width: 100%; }
|
||||
.scrape-buttons .btn { flex: 1; justify-content: center; }
|
||||
.edit-row { flex-direction: column; }
|
||||
.edit-row input { width: 100% !important; }
|
||||
}
|
||||
.btn-setup {
|
||||
border-color: var(--phosphor-dark);
|
||||
color: var(--phosphor);
|
||||
}
|
||||
.btn-setup:hover {
|
||||
background: var(--phosphor-burn);
|
||||
box-shadow: 0 0 12px var(--phosphor-dark);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,850 @@
|
||||
<script>
|
||||
import { listGames, uploadGame, searchIGDB } from "../lib/api.js";
|
||||
import GameCard from "../lib/GameCard.svelte";
|
||||
|
||||
let { genreFilter = "", yearFilter = "", uploadTriggered = 0 } = $props();
|
||||
|
||||
let games = $state([]);
|
||||
let loading = $state(true);
|
||||
let search = $state("");
|
||||
let uploading = $state(false);
|
||||
let uploadError = $state("");
|
||||
|
||||
// Post-selection upload dialog
|
||||
let pendingFile = $state(null);
|
||||
let pendingFileName = $state("");
|
||||
let pendingTitle = $state("");
|
||||
let searching = $state(false);
|
||||
let searchError = $state("");
|
||||
let igdbResults = $state(null);
|
||||
let manualSearched = $state(false);
|
||||
let duplicateError = $state("");
|
||||
|
||||
// Filter state
|
||||
let selectedGenres = $state(new Set());
|
||||
let selectedYears = $state(new Set());
|
||||
let showAllGenres = $state(false);
|
||||
let showAllYears = $state(false);
|
||||
|
||||
let fileInput;
|
||||
|
||||
// When header upload button is clicked, trigger the hidden file input.
|
||||
// Uses a previous-value guard to prevent re-firing on component remount.
|
||||
let prevUploadTrigger = $state(uploadTriggered);
|
||||
$effect(() => {
|
||||
if (uploadTriggered > 0 && uploadTriggered !== prevUploadTrigger) {
|
||||
prevUploadTrigger = uploadTriggered;
|
||||
fileInput?.click();
|
||||
}
|
||||
});
|
||||
|
||||
// Derive filter options from the loaded games
|
||||
let filterOptions = $derived.by(() => {
|
||||
const years = {};
|
||||
const genres = {};
|
||||
for (const g of games) {
|
||||
if (g.year) {
|
||||
const y = String(g.year);
|
||||
years[y] = (years[y] || 0) + 1;
|
||||
}
|
||||
if (g.genre) {
|
||||
genres[g.genre] = (genres[g.genre] || 0) + 1;
|
||||
}
|
||||
}
|
||||
return {
|
||||
years: Object.entries(years)
|
||||
.map(([label, count]) => ({ label, count }))
|
||||
.sort((a, b) => Number(b.label) - Number(a.label)),
|
||||
genres: Object.entries(genres)
|
||||
.map(([label, count]) => ({ label, count }))
|
||||
.sort((a, b) => b.count - a.count),
|
||||
};
|
||||
});
|
||||
|
||||
// Apply initial filter values from URL params
|
||||
$effect(() => {
|
||||
if (genreFilter) selectedGenres = new Set([genreFilter]);
|
||||
if (yearFilter) selectedYears = new Set([yearFilter]);
|
||||
});
|
||||
|
||||
// Update URL hash when filters change (but not on initial load)
|
||||
let initialFilter = true;
|
||||
$effect(() => {
|
||||
if (initialFilter) { initialFilter = false; return; }
|
||||
const params = new URLSearchParams();
|
||||
if (selectedGenres.size === 1) params.set("genre", [...selectedGenres][0]);
|
||||
if (selectedYears.size === 1) params.set("year", [...selectedYears][0]);
|
||||
const qs = params.toString();
|
||||
const base = window.location.hash.split("?")[0] || "#/";
|
||||
const newHash = qs ? base + "?" + qs : base;
|
||||
if (window.location.hash !== newHash) {
|
||||
window.location.hash = newHash;
|
||||
}
|
||||
});
|
||||
|
||||
// Filtered games
|
||||
let filteredGames = $derived.by(() => {
|
||||
let list = games;
|
||||
if (selectedGenres.size > 0) {
|
||||
list = list.filter(g => g.genre && selectedGenres.has(g.genre));
|
||||
}
|
||||
if (selectedYears.size > 0) {
|
||||
list = list.filter(g => g.year && selectedYears.has(String(g.year)));
|
||||
}
|
||||
return list;
|
||||
});
|
||||
|
||||
function toggleFilter(set, value) {
|
||||
const next = new Set(set);
|
||||
if (next.has(value)) next.delete(value);
|
||||
else next.add(value);
|
||||
return next;
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
selectedGenres = new Set();
|
||||
selectedYears = new Set();
|
||||
showAllGenres = false;
|
||||
showAllYears = false;
|
||||
window.location.hash = "#/";
|
||||
}
|
||||
|
||||
function hasActiveFilters() {
|
||||
return selectedGenres.size > 0 || selectedYears.size > 0;
|
||||
}
|
||||
|
||||
async function loadGames() {
|
||||
loading = true;
|
||||
try {
|
||||
const data = await listGames(search ? { search } : {});
|
||||
games = data.games || [];
|
||||
} catch (e) {
|
||||
console.error("Failed to load games:", e);
|
||||
}
|
||||
loading = false;
|
||||
}
|
||||
|
||||
function handleFileSelected(e) {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const name = file.name.replace(/\.zip$/i, "");
|
||||
pendingFile = file;
|
||||
pendingFileName = name;
|
||||
pendingTitle = name;
|
||||
igdbResults = null;
|
||||
searching = true;
|
||||
searchError = "";
|
||||
manualSearched = false;
|
||||
duplicateError = "";
|
||||
fileInput.value = "";
|
||||
searchIGDB(name).then(data => {
|
||||
igdbResults = data.results || [];
|
||||
}).catch(err => {
|
||||
searchError = "IGDB search failed: " + err.message;
|
||||
igdbResults = [];
|
||||
}).finally(() => {
|
||||
searching = false;
|
||||
});
|
||||
}
|
||||
|
||||
async function handleManualSearch() {
|
||||
const q = pendingTitle.trim();
|
||||
if (!q) return;
|
||||
manualSearched = true;
|
||||
searching = true;
|
||||
searchError = "";
|
||||
igdbResults = null;
|
||||
try {
|
||||
const data = await searchIGDB(q);
|
||||
igdbResults = data.results || [];
|
||||
if (igdbResults.length === 0) {
|
||||
searchError = 'No matches found for "' + q + '" on IGDB.';
|
||||
}
|
||||
} catch (err) {
|
||||
searchError = "IGDB search failed: " + err.message;
|
||||
igdbResults = [];
|
||||
}
|
||||
searching = false;
|
||||
}
|
||||
|
||||
function cancelUpload() {
|
||||
pendingFile = null;
|
||||
pendingFileName = "";
|
||||
pendingTitle = "";
|
||||
igdbResults = null;
|
||||
searchError = "";
|
||||
manualSearched = false;
|
||||
duplicateError = "";
|
||||
}
|
||||
|
||||
async function doUpload(title, igdbId) {
|
||||
if (!pendingFile) return;
|
||||
|
||||
const finalTitle = title || pendingFileName;
|
||||
if (finalTitle) {
|
||||
const dup = games.find(g => g.title?.toLowerCase() === finalTitle.toLowerCase());
|
||||
if (dup) {
|
||||
duplicateError = `"${dup.title}" is already in your library`;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
uploading = true;
|
||||
uploadError = "";
|
||||
duplicateError = "";
|
||||
try {
|
||||
await uploadGame(pendingFile, title || undefined, igdbId);
|
||||
cancelUpload();
|
||||
await loadGames();
|
||||
} catch (err) {
|
||||
uploadError = err.message;
|
||||
}
|
||||
uploading = false;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
loadGames();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="library-layout">
|
||||
<!-- ─── Filter Sidebar ─────────────────────────── -->
|
||||
<aside class="sidebar" class:sidebar-active={hasActiveFilters()}>
|
||||
|
||||
<!-- Search box -->
|
||||
<div class="sidebar-search">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search games..."
|
||||
bind:value={search}
|
||||
oninput={() => loadGames()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Year filter group -->
|
||||
<div class="filter-group">
|
||||
<h3 class="filter-label">Year</h3>
|
||||
{#if filterOptions.years.length === 0}
|
||||
<p class="filter-empty">—</p>
|
||||
{:else}
|
||||
{#each (showAllYears ? filterOptions.years : filterOptions.years.slice(0, 5)) as opt}
|
||||
<label class="filter-option">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedYears.has(opt.label)}
|
||||
onchange={() => selectedYears = toggleFilter(selectedYears, opt.label)}
|
||||
/>
|
||||
<span class="filter-text">{opt.label}</span>
|
||||
<span class="filter-count">{opt.count}</span>
|
||||
</label>
|
||||
{/each}
|
||||
{#if filterOptions.years.length > 5}
|
||||
<button class="btn btn-link" onclick={() => showAllYears = !showAllYears}>
|
||||
{showAllYears ? "△ Show less" : `▾ Show all (${filterOptions.years.length})`}
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Genre filter group -->
|
||||
<div class="filter-group">
|
||||
<h3 class="filter-label">Genre</h3>
|
||||
{#if filterOptions.genres.length === 0}
|
||||
<p class="filter-empty">—</p>
|
||||
{:else}
|
||||
{#each (showAllGenres ? filterOptions.genres : filterOptions.genres.slice(0, 5)) as opt}
|
||||
<label class="filter-option">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedGenres.has(opt.label)}
|
||||
onchange={() => selectedGenres = toggleFilter(selectedGenres, opt.label)}
|
||||
/>
|
||||
<span class="filter-text">{opt.label}</span>
|
||||
<span class="filter-count">{opt.count}</span>
|
||||
</label>
|
||||
{/each}
|
||||
{#if filterOptions.genres.length > 5}
|
||||
<button class="btn btn-link" onclick={() => showAllGenres = !showAllGenres}>
|
||||
{showAllGenres ? "△ Show less" : `▾ Show all (${filterOptions.genres.length})`}
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if hasActiveFilters()}
|
||||
<div class="sidebar-reset">
|
||||
<button class="btn btn-sm btn-reset" onclick={resetFilters}>✕ Reset</button>
|
||||
</div>
|
||||
{/if}
|
||||
</aside>
|
||||
|
||||
<!-- ─── Main Content ──────────────────────────── -->
|
||||
<div class="library-main">
|
||||
|
||||
<!-- Active filter chips -->
|
||||
{#if hasActiveFilters()}
|
||||
<div class="active-filters">
|
||||
{#each [...selectedYears] as y}
|
||||
<button class="filter-chip" onclick={() => selectedYears = toggleFilter(selectedYears, y)}>
|
||||
{y} ✕
|
||||
</button>
|
||||
{/each}
|
||||
{#each [...selectedGenres] as g}
|
||||
<button class="filter-chip" onclick={() => selectedGenres = toggleFilter(selectedGenres, g)}>
|
||||
{g} ✕
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if pendingFile}
|
||||
<div class="upload-overlay" onclick={cancelUpload} role="presentation">
|
||||
<div class="upload-dialog" onclick={(e) => e.stopPropagation()} role="dialog">
|
||||
<h3>Upload Game</h3>
|
||||
<p class="upload-file-name">{pendingFileName}.zip</p>
|
||||
|
||||
<div class="upload-dialog-body">
|
||||
{#if uploading}
|
||||
<div class="upload-progress">
|
||||
<div class="upload-progress-spinner"></div>
|
||||
<p class="upload-progress-text">Uploading and processing</p>
|
||||
<p class="upload-progress-sub">{pendingFileName}.zip</p>
|
||||
<p class="upload-progress-note">This may take a while for large games</p>
|
||||
</div>
|
||||
{:else if duplicateError}
|
||||
<div class="status-msg error-msg">
|
||||
⚠️ {duplicateError}
|
||||
</div>
|
||||
{:else if searching}
|
||||
<div class="status-msg">Searching IGDB for "{pendingFileName}"...</div>
|
||||
{:else if igdbResults !== null && igdbResults.length > 0}
|
||||
<p class="match-label">Select a match or enter a custom title:</p>
|
||||
<div class="upload-igdb-results">
|
||||
{#each igdbResults as result}
|
||||
<button
|
||||
class="igdb-result"
|
||||
data-name={result.name}
|
||||
data-igdb-id={result.igdb_id}
|
||||
onclick={(e) => doUpload(e.currentTarget.dataset.name, Number(e.currentTarget.dataset.igdbId))}
|
||||
disabled={uploading}
|
||||
>
|
||||
{#if result.cover_url}
|
||||
<img src={result.cover_url} alt={result.name} class="igdb-thumb" loading="lazy" />
|
||||
{:else}
|
||||
<div class="igdb-thumb-placeholder">🎮</div>
|
||||
{/if}
|
||||
<div class="igdb-info">
|
||||
<strong>{result.name}</strong>
|
||||
<span class="igdb-meta">
|
||||
{result.year || "—"}
|
||||
{#if result.genres?.length} · {result.genres.slice(0, 2).join(", ")}{/if}
|
||||
</span>
|
||||
{#if result.developer}
|
||||
<span class="igdb-dev">{result.developer}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="upload-custom-row">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Or type a different title..."
|
||||
bind:value={pendingTitle}
|
||||
disabled={uploading}
|
||||
onkeydown={(e) => e.key === "Enter" && handleManualSearch()}
|
||||
/>
|
||||
<button class="btn" onclick={handleManualSearch} disabled={uploading || !pendingTitle.trim()}>
|
||||
Search
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
{#if searchError}
|
||||
<div class="status-msg error-msg">
|
||||
{searchError}
|
||||
{#if manualSearched}
|
||||
<button class="btn btn-sm btn-upload-anyway" onclick={() => doUpload(pendingTitle.trim())} disabled={uploading}>
|
||||
{uploading ? "Uploading..." : 'Upload anyway as "' + pendingTitle.trim() + '"'}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="match-label">No matches found. Edit the title below and search IGDB:</p>
|
||||
{/if}
|
||||
<div class="upload-custom-row">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Edit title..."
|
||||
bind:value={pendingTitle}
|
||||
disabled={uploading || searching}
|
||||
onkeydown={(e) => e.key === "Enter" && handleManualSearch()}
|
||||
/>
|
||||
<button class="btn" onclick={handleManualSearch} disabled={uploading || searching || !pendingTitle.trim()}>
|
||||
{uploading ? "Uploading..." : "Search"}
|
||||
</button>
|
||||
</div>
|
||||
<button class="btn btn-secondary" onclick={() => doUpload(pendingFileName)} disabled={uploading}>
|
||||
Use filename: {pendingFileName}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<button class="btn btn-cancel" onclick={cancelUpload} disabled={uploading}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if uploadError}
|
||||
<div class="error">{uploadError}</div>
|
||||
{/if}
|
||||
|
||||
{#if loading}
|
||||
<div class="loading">Scanning library...</div>
|
||||
{:else if filteredGames.length === 0}
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">💾</div>
|
||||
<h2>{hasActiveFilters() ? "No matching games" : "No games yet"}</h2>
|
||||
<p>{hasActiveFilters() ? "Try changing your filters" : "Upload a DOS game ZIP to get started"}</p>
|
||||
{#if hasActiveFilters()}
|
||||
<button class="btn btn-primary" style="margin-top: 16px;" onclick={resetFilters}>✕ Clear filters</button>
|
||||
{:else}
|
||||
<label class="btn btn-primary" style="margin-top: 16px;">
|
||||
Upload your first game
|
||||
<input type="file" accept=".zip" class="hidden-input" onchange={handleFileSelected} />
|
||||
</label>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid">
|
||||
{#each filteredGames as game (game.id)}
|
||||
<GameCard {game} />
|
||||
{/each}
|
||||
</div>
|
||||
<div class="count">{filteredGames.length} game{filteredGames.length !== 1 ? "s" : ""}</div>
|
||||
{/if}
|
||||
|
||||
<!-- Hidden file input triggered by header upload button -->
|
||||
<input
|
||||
bind:this={fileInput}
|
||||
type="file"
|
||||
accept=".zip"
|
||||
class="hidden-input"
|
||||
onchange={handleFileSelected}
|
||||
disabled={uploading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* ─── Layout ────────────────────────────────── */
|
||||
.library-layout {
|
||||
display: flex;
|
||||
gap: 28px;
|
||||
padding: 32px 0;
|
||||
align-items: flex-start;
|
||||
}
|
||||
@media (max-width: 800px) {
|
||||
.library-layout { flex-direction: column; padding: 16px 0; gap: 16px; }
|
||||
}
|
||||
|
||||
/* ─── Sidebar ────────────────────────────────── */
|
||||
.sidebar {
|
||||
width: 220px;
|
||||
flex-shrink: 0;
|
||||
position: sticky;
|
||||
top: 24px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px;
|
||||
max-height: calc(100vh - 120px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
@media (max-width: 800px) {
|
||||
.sidebar {
|
||||
width: 100%;
|
||||
position: static;
|
||||
max-height: none;
|
||||
}
|
||||
}
|
||||
.sidebar-search {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.sidebar-search input {
|
||||
width: 100%;
|
||||
}
|
||||
.sidebar-reset {
|
||||
margin-top: 20px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.sidebar-reset .btn-reset {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
.filter-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.filter-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.filter-empty {
|
||||
color: var(--text-dim);
|
||||
font-size: 0.8rem;
|
||||
font-style: italic;
|
||||
}
|
||||
.filter-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 3px 0;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text);
|
||||
transition: color 0.1s;
|
||||
}
|
||||
.filter-option:hover {
|
||||
color: var(--phosphor);
|
||||
}
|
||||
.filter-option input[type="checkbox"] {
|
||||
accent-color: var(--phosphor);
|
||||
margin: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.filter-text {
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.filter-count {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-dim);
|
||||
background: var(--surface-hover);
|
||||
padding: 0 6px;
|
||||
border-radius: 8px;
|
||||
min-width: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
.btn-link {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--phosphor-dim);
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
padding: 4px 0;
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
.btn-link:hover {
|
||||
color: var(--phosphor);
|
||||
}
|
||||
.btn-reset {
|
||||
border-color: #883333;
|
||||
color: #ff6666;
|
||||
font-size: 0.75rem;
|
||||
padding: 2px 10px;
|
||||
}
|
||||
.btn-reset:hover {
|
||||
background: #331111;
|
||||
border-color: #ff4444;
|
||||
}
|
||||
|
||||
/* ─── Active filter chips ────────────────────── */
|
||||
.active-filters {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.filter-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
background: var(--phosphor-dark);
|
||||
color: var(--phosphor);
|
||||
border: 1px solid transparent;
|
||||
border-radius: 14px;
|
||||
padding: 3px 12px;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
font-family: var(--font-sans);
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.filter-chip:hover {
|
||||
background: #1a4422;
|
||||
border-color: var(--phosphor-dim);
|
||||
}
|
||||
|
||||
/* ─── Main content ───────────────────────────── */
|
||||
.library-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.btn-link {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--phosphor-dim);
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
padding: 4px 0;
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
.btn-link:hover {
|
||||
color: var(--phosphor);
|
||||
}
|
||||
|
||||
.hidden-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.grid { grid-template-columns: repeat(2, 1fr); gap: 12px; }
|
||||
}
|
||||
|
||||
.error {
|
||||
background: #331111;
|
||||
border: 1px solid #cc3333;
|
||||
color: #ff6666;
|
||||
padding: 12px 16px;
|
||||
border-radius: var(--radius-sm);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: var(--text-dim);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 80px 20px;
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.empty-icon {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.empty-state h2 {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.empty-state p {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.count {
|
||||
margin-top: 24px;
|
||||
text-align: center;
|
||||
color: var(--text-dim);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* ─── Upload Dialog ─────────────────────────────── */
|
||||
.upload-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 200;
|
||||
padding: 16px;
|
||||
}
|
||||
.upload-dialog {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 24px;
|
||||
max-width: 480px;
|
||||
width: 100%;
|
||||
max-height: 90vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.upload-dialog h3 {
|
||||
margin-bottom: 4px;
|
||||
color: var(--phosphor);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.upload-file-name {
|
||||
color: var(--text-dim);
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 4px;
|
||||
word-break: break-all;
|
||||
}
|
||||
.upload-dialog-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
margin: 16px 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.match-label {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.status-msg {
|
||||
padding: 12px;
|
||||
text-align: center;
|
||||
color: var(--text-dim);
|
||||
font-style: italic;
|
||||
}
|
||||
.status-msg.error-msg {
|
||||
color: #ff6666;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
/* IGDB results list inside dialog */
|
||||
.upload-igdb-results {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.igdb-result {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
background: var(--surface-hover);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 8px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: border-color 0.15s;
|
||||
width: 100%;
|
||||
}
|
||||
.igdb-result:hover {
|
||||
border-color: var(--phosphor-dim);
|
||||
}
|
||||
.igdb-result:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: wait;
|
||||
}
|
||||
.igdb-thumb {
|
||||
width: 42px;
|
||||
height: 56px;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.igdb-thumb-placeholder {
|
||||
width: 42px;
|
||||
height: 56px;
|
||||
background: var(--surface);
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.2rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.igdb-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.igdb-info strong {
|
||||
display: block;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-bright);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.igdb-meta {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.igdb-dev {
|
||||
display: block;
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-dim);
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.upload-custom-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.upload-custom-row input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.btn-cancel {
|
||||
border-color: var(--border);
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.btn-cancel:hover {
|
||||
border-color: var(--text-dim);
|
||||
color: var(--text);
|
||||
}
|
||||
.btn-secondary {
|
||||
border-color: var(--border);
|
||||
color: var(--text);
|
||||
}
|
||||
.btn-secondary:hover {
|
||||
border-color: var(--text-dim);
|
||||
}
|
||||
.btn-upload-anyway {
|
||||
margin-top: 8px;
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
border-color: #cc8833;
|
||||
color: #ffaa44;
|
||||
}
|
||||
.btn-upload-anyway:hover {
|
||||
background: #332211;
|
||||
border-color: #ffaa44;
|
||||
}
|
||||
|
||||
/* Upload Progress spinner */
|
||||
.upload-progress {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 40px 20px;
|
||||
text-align: center;
|
||||
}
|
||||
.upload-progress-spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid var(--border);
|
||||
border-top: 3px solid var(--phosphor);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
.upload-progress-text {
|
||||
color: var(--text);
|
||||
font-size: 1rem;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.upload-progress-sub {
|
||||
color: var(--text-dim);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.upload-progress-note {
|
||||
color: var(--text-dim);
|
||||
font-size: 0.75rem;
|
||||
margin-top: 8px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,300 @@
|
||||
<script>
|
||||
import { getGame, bundleUrl, setupBundleUrl } from "../lib/api.js";
|
||||
import { push } from "../lib/router.js";
|
||||
|
||||
let { id } = $props();
|
||||
|
||||
// Read query params from the hash — window.location is available after mount
|
||||
let isSetup = $state(false);
|
||||
|
||||
let game = $state(null);
|
||||
let loading = $state(true);
|
||||
let error = $state("");
|
||||
let booting = $state(false);
|
||||
let running = $state(false);
|
||||
let unsupported = $state(false);
|
||||
let started = false;
|
||||
|
||||
let dosContainer;
|
||||
let dosCI = null;
|
||||
let injectedLink = null;
|
||||
let injectedScript = null;
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
// Detect ?setup=1 from the hash-based URL
|
||||
isSetup = window.location.hash.includes("setup=1");
|
||||
try {
|
||||
game = await getGame(id);
|
||||
// Windows games can't run in pure DOSBox — flag it
|
||||
if (game.platform === 'windows') {
|
||||
unsupported = true;
|
||||
}
|
||||
} catch (e) {
|
||||
error = "Game not found";
|
||||
}
|
||||
loading = false;
|
||||
}
|
||||
|
||||
async function startEmulator() {
|
||||
if (started || !game?.ready || !dosContainer) return;
|
||||
started = true;
|
||||
booting = true;
|
||||
|
||||
// Load js-dos CSS
|
||||
const link = document.createElement("link");
|
||||
link.rel = "stylesheet";
|
||||
link.href = "https://v8.js-dos.com/latest/js-dos.css";
|
||||
document.head.appendChild(link);
|
||||
injectedLink = link;
|
||||
|
||||
// Load js-dos script from CDN
|
||||
if (!window.Dos) {
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
const s = document.createElement("script");
|
||||
s.src = "https://v8.js-dos.com/latest/js-dos.js";
|
||||
s.onload = resolve;
|
||||
s.onerror = reject;
|
||||
document.head.appendChild(s);
|
||||
injectedScript = s;
|
||||
});
|
||||
} catch {
|
||||
error = "Failed to load emulator. Check your internet connection.";
|
||||
booting = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Start the DOS emulator
|
||||
try {
|
||||
const url = isSetup ? setupBundleUrl(game) : bundleUrl(game);
|
||||
if (!url || !window.Dos) throw new Error("Failed to resolve game bundle");
|
||||
dosCI = await window.Dos(dosContainer, { url });
|
||||
running = true;
|
||||
} catch (e) {
|
||||
error = e.message;
|
||||
}
|
||||
booting = false;
|
||||
}
|
||||
|
||||
function stopEmulator() {
|
||||
// Force full page reload which terminates all workers, AudioContexts
|
||||
// and WASM threads. Hash is preserved so the SPA routes correctly.
|
||||
window.location.hash = `#/game/${game.id}`;
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
function handleTryAnyway() {
|
||||
unsupported = false;
|
||||
startEmulator();
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
push(`/game/${id}`);
|
||||
}
|
||||
|
||||
$effect(() => { load(); });
|
||||
|
||||
// Auto-start when both game data and the container exist
|
||||
$effect(() => {
|
||||
if (game?.ready && dosContainer && !started && !unsupported) {
|
||||
startEmulator();
|
||||
}
|
||||
});
|
||||
|
||||
// Cleanup emulator on component unmount (browser back, nav away)
|
||||
$effect(() => {
|
||||
return () => {
|
||||
if (dosCI) {
|
||||
try { dosCI.exit(); } catch (_) {}
|
||||
dosCI = null;
|
||||
}
|
||||
if (dosContainer) {
|
||||
dosContainer.innerHTML = "";
|
||||
}
|
||||
// Remove injected js-dos CSS so it doesn't corrupt other pages
|
||||
if (injectedLink && injectedLink.parentNode) {
|
||||
injectedLink.parentNode.removeChild(injectedLink);
|
||||
injectedLink = null;
|
||||
}
|
||||
if (injectedScript && injectedScript.parentNode) {
|
||||
injectedScript.parentNode.removeChild(injectedScript);
|
||||
injectedScript = null;
|
||||
}
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="play-page">
|
||||
<!-- Top bar: shown when game is loaded -->
|
||||
<div class="play-header" class:active={game}>
|
||||
{#if game}
|
||||
<button class="link-button" onclick={stopEmulator}>
|
||||
← {isSetup ? "Back" : game.title}
|
||||
</button>
|
||||
{#if running}
|
||||
<button class="btn stop-btn" onclick={stopEmulator}>⏹ Stop</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Error state -->
|
||||
{#if error && !booting}
|
||||
<div class="error-box">{error}</div>
|
||||
{/if}
|
||||
|
||||
<!-- Unsupported platform warning -->
|
||||
{#if unsupported}
|
||||
<div class="overlay">
|
||||
<div class="overlay-icon">🪟</div>
|
||||
<h2>Windows game</h2>
|
||||
<p><strong>{game.title}</strong> is a Windows application and won't run in the browser DOS emulator without Windows 3.1 installed.</p>
|
||||
<div class="unsupported-actions">
|
||||
<button class="btn btn-secondary" onclick={goBack}>← Back</button>
|
||||
<button class="btn" onclick={handleTryAnyway}>Try anyway</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Loading / booting overlay (covers the canvas while loading) -->
|
||||
{#if !running}
|
||||
<div class="overlay">
|
||||
{#if loading}
|
||||
<div class="overlay-icon">⏳</div>
|
||||
<p>Loading game data...</p>
|
||||
{:else if booting}
|
||||
<div class="overlay-icon">⚙️</div>
|
||||
<h2>{game?.title || ""}</h2>
|
||||
{#if isSetup}
|
||||
<p>Starting setup utility...</p>
|
||||
<p class="save-hint">⚙️ Configure controls, then exit SETUP to save</p>
|
||||
{:else}
|
||||
<p>Starting emulator...</p>
|
||||
<p class="save-hint">💾 Game saves are stored automatically</p>
|
||||
{/if}
|
||||
{:else if error}
|
||||
<div class="overlay-icon">⚠️</div>
|
||||
<h2>Failed to start</h2>
|
||||
<p>{error}</p>
|
||||
{:else if !game?.ready}
|
||||
<div class="overlay-icon">🕹️</div>
|
||||
<h2>Not ready</h2>
|
||||
<p>This game hasn't been processed yet.</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- dos-container is always in the DOM so bind:this works on first render -->
|
||||
<div bind:this={dosContainer} class="dos-container"></div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.play-page { padding: 16px 0; position: relative; }
|
||||
@media (max-width: 640px) { .play-page { padding: 8px 0; } }
|
||||
.play-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
.play-header.active { opacity: 1; }
|
||||
.link-button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-dim);
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
.link-button:hover { color: var(--phosphor); }
|
||||
.stop-btn {
|
||||
border-color: #cc3333;
|
||||
color: #ff4444;
|
||||
}
|
||||
.stop-btn:hover {
|
||||
background: #331111;
|
||||
box-shadow: 0 0 12px #331111;
|
||||
}
|
||||
|
||||
/* Emulator canvas — always mounted */
|
||||
.dos-container {
|
||||
width: 100%;
|
||||
aspect-ratio: 4/3;
|
||||
max-height: 80vh;
|
||||
background: #000;
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.dos-container { aspect-ratio: 4/3; max-height: 50vh; border-radius: var(--radius-sm); }
|
||||
}
|
||||
.dos-container :global(canvas) {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
/* Overlay shown while loading / booting, covers the empty container */
|
||||
.overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
top: 50px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
background: var(--bg);
|
||||
z-index: 10;
|
||||
border-radius: var(--radius);
|
||||
border: 1px dashed var(--border);
|
||||
margin-top: 4px;
|
||||
padding: 20px;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.overlay { top: 44px; border-radius: var(--radius-sm); padding: 16px; }
|
||||
.overlay-icon { font-size: 2rem; }
|
||||
.overlay h2 { font-size: 1rem; }
|
||||
.overlay p { font-size: 0.85rem; }
|
||||
}
|
||||
.overlay-icon { font-size: 3rem; margin-bottom: 12px; }
|
||||
.overlay h2 {
|
||||
color: var(--phosphor);
|
||||
font-family: var(--font-mono);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.overlay p { color: var(--text-dim); }
|
||||
.save-hint {
|
||||
display: inline-block;
|
||||
margin-top: 16px;
|
||||
padding: 6px 16px;
|
||||
border-radius: 12px;
|
||||
background: var(--phosphor-burn);
|
||||
color: var(--phosphor-dim);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.error-box {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
top: 50px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: 4px;
|
||||
background: #331111;
|
||||
border: 1px solid #cc3333;
|
||||
color: #ff6666;
|
||||
border-radius: var(--radius);
|
||||
z-index: 10;
|
||||
}
|
||||
.unsupported-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,185 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/svelte";
|
||||
import GameDetail from "../GameDetail.svelte";
|
||||
|
||||
// Mock api.js
|
||||
vi.mock("../../lib/api.js", () => ({
|
||||
getGame: vi.fn(),
|
||||
deleteGame: vi.fn(),
|
||||
updateGame: vi.fn(),
|
||||
searchIGDB: vi.fn(),
|
||||
scrapeIGDB: vi.fn(),
|
||||
downloadGame: vi.fn(),
|
||||
bundleUrl: vi.fn(),
|
||||
setupBundleUrl: vi.fn(),
|
||||
artworkUrl: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock router
|
||||
vi.mock("../../lib/router.js", () => ({
|
||||
push: vi.fn(),
|
||||
}));
|
||||
|
||||
import * as api from "../../lib/api.js";
|
||||
|
||||
function makeGame(overrides = {}) {
|
||||
return {
|
||||
id: "game-1",
|
||||
title: "Commander Keen",
|
||||
year: 1991,
|
||||
genre: "Platformer",
|
||||
developer: "id Software",
|
||||
publisher: "Apogee",
|
||||
description: "A classic DOS platformer.",
|
||||
platform: "dos",
|
||||
ready: true,
|
||||
has_cover: false,
|
||||
has_setup: false,
|
||||
bundle_file: "commander-keen.zip",
|
||||
bundle_size: 512000,
|
||||
executables: ["keen.exe"],
|
||||
executable: null,
|
||||
videos: [],
|
||||
screenshots: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("GameDetail", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("shows loading state initially", () => {
|
||||
api.getGame.mockReturnValue(new Promise(() => {}));
|
||||
render(GameDetail, { props: { id: "game-1" } });
|
||||
expect(screen.getByText("Loading...")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows error when game is not found", async () => {
|
||||
api.getGame.mockRejectedValue(new Error("Game not found"));
|
||||
render(GameDetail, { props: { id: "missing" } });
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByText("Game not found")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders game title", async () => {
|
||||
api.getGame.mockResolvedValue(makeGame());
|
||||
render(GameDetail, { props: { id: "game-1" } });
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByText("Commander Keen")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders developer and publisher", async () => {
|
||||
api.getGame.mockResolvedValue(
|
||||
makeGame({ developer: "id Software", publisher: "Apogee" })
|
||||
);
|
||||
render(GameDetail, { props: { id: "game-1" } });
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByText(/id Software/)).toBeTruthy();
|
||||
expect(screen.getByText(/Apogee/)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders year and genre as links", async () => {
|
||||
api.getGame.mockResolvedValue(
|
||||
makeGame({ year: 1991, genre: "Platformer" })
|
||||
);
|
||||
render(GameDetail, { props: { id: "game-1" } });
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByText("1991").closest("a")).toBeTruthy();
|
||||
expect(screen.getByText("Platformer").closest("a")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows DOS badge for dos platform", async () => {
|
||||
api.getGame.mockResolvedValue(makeGame({ platform: "dos" }));
|
||||
render(GameDetail, { props: { id: "game-1" } });
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByText("💾 DOS")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows Windows badge for windows platform", async () => {
|
||||
api.getGame.mockResolvedValue(makeGame({ platform: "windows" }));
|
||||
render(GameDetail, { props: { id: "game-1" } });
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByText(/Windows 3\.1/)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows Play button for ready DOS games", async () => {
|
||||
api.getGame.mockResolvedValue(
|
||||
makeGame({ ready: true, platform: "dos" })
|
||||
);
|
||||
render(GameDetail, { props: { id: "game-1" } });
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByText("▶ Play")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows disabled Play button for Windows games", async () => {
|
||||
api.getGame.mockResolvedValue(
|
||||
makeGame({ ready: true, platform: "windows" })
|
||||
);
|
||||
render(GameDetail, { props: { id: "game-1" } });
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByText("▶ Unplayable")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows Setup button when has_setup is true", async () => {
|
||||
api.getGame.mockResolvedValue(
|
||||
makeGame({ has_setup: true })
|
||||
);
|
||||
render(GameDetail, { props: { id: "game-1" } });
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByText("🛠 Setup")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows Edit, Download, Delete buttons", async () => {
|
||||
api.getGame.mockResolvedValue(makeGame());
|
||||
render(GameDetail, { props: { id: "game-1" } });
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByText("✏ Edit")).toBeTruthy();
|
||||
expect(screen.getByText("⬇ Download")).toBeTruthy();
|
||||
expect(screen.getByText("✕ Delete")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("enters edit mode when Edit button is clicked", async () => {
|
||||
api.getGame.mockResolvedValue(makeGame());
|
||||
render(GameDetail, { props: { id: "game-1" } });
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByText("✏ Edit")).toBeTruthy();
|
||||
});
|
||||
screen.getByText("✏ Edit").click();
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByText("Save")).toBeTruthy();
|
||||
expect(screen.getByText("Cancel")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows cover placeholder when no cover", async () => {
|
||||
api.getGame.mockResolvedValue(makeGame({ has_cover: false }));
|
||||
render(GameDetail, { props: { id: "game-1" } });
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByText("💾")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows description when provided", async () => {
|
||||
api.getGame.mockResolvedValue(
|
||||
makeGame({ description: "A classic DOS platformer." })
|
||||
);
|
||||
render(GameDetail, { props: { id: "game-1" } });
|
||||
await vi.waitFor(() => {
|
||||
expect(
|
||||
screen.getByText("A classic DOS platformer.")
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/svelte";
|
||||
import Library from "../Library.svelte";
|
||||
|
||||
// Mock the API module that Library imports
|
||||
vi.mock("../../lib/api.js", () => ({
|
||||
listGames: vi.fn(),
|
||||
uploadGame: vi.fn(),
|
||||
searchIGDB: vi.fn(),
|
||||
}));
|
||||
|
||||
import * as api from "../../lib/api.js";
|
||||
|
||||
function makeGame(overrides = {}) {
|
||||
return {
|
||||
id: "g1",
|
||||
title: "Test Game",
|
||||
year: 1995,
|
||||
genre: "Adventure",
|
||||
platform: "dos",
|
||||
has_cover: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("Library", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
window.location.hash = "#/";
|
||||
});
|
||||
|
||||
it("shows loading state initially", () => {
|
||||
api.listGames.mockReturnValue(new Promise(() => {})); // never resolves
|
||||
render(Library);
|
||||
expect(screen.getByText("Scanning library...")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows empty state when no games", async () => {
|
||||
api.listGames.mockResolvedValue({ games: [] });
|
||||
render(Library);
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByText("No games yet")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders game cards when games load", async () => {
|
||||
api.listGames.mockResolvedValue({
|
||||
games: [
|
||||
makeGame({ id: "g1", title: "Game One" }),
|
||||
makeGame({ id: "g2", title: "Game Two" }),
|
||||
],
|
||||
});
|
||||
render(Library);
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByText("Game One")).toBeTruthy();
|
||||
expect(screen.getByText("Game Two")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows game count", async () => {
|
||||
api.listGames.mockResolvedValue({
|
||||
games: [
|
||||
makeGame({ id: "g1", title: "One" }),
|
||||
makeGame({ id: "g2", title: "Two" }),
|
||||
],
|
||||
});
|
||||
render(Library);
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByText("2 games")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows singular game count for one game", async () => {
|
||||
api.listGames.mockResolvedValue({
|
||||
games: [makeGame({ id: "g1", title: "Only" })],
|
||||
});
|
||||
render(Library);
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByText("1 game")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("filters", () => {
|
||||
it("shows filter options derived from loaded games", async () => {
|
||||
api.listGames.mockResolvedValue({
|
||||
games: [
|
||||
makeGame({ id: "g1", genre: "RPG", year: 1997 }),
|
||||
makeGame({ id: "g2", genre: "RPG", year: 1998 }),
|
||||
makeGame({ id: "g3", genre: "Adventure", year: 1995 }),
|
||||
],
|
||||
});
|
||||
render(Library);
|
||||
await vi.waitFor(() => {
|
||||
// Year labels appear both as filter options and in game cards
|
||||
expect(screen.getAllByText("1995").length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText("1997").length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText("1998").length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("applies genre filter from props", async () => {
|
||||
api.listGames.mockResolvedValue({
|
||||
games: [
|
||||
makeGame({ id: "g1", title: "RPG Game", genre: "RPG" }),
|
||||
makeGame({ id: "g2", title: "Adventure Game", genre: "Adventure" }),
|
||||
],
|
||||
});
|
||||
render(Library, { props: { genreFilter: "RPG" } });
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByText("RPG Game")).toBeTruthy();
|
||||
expect(screen.queryByText("Adventure Game")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("applies year filter from props", async () => {
|
||||
api.listGames.mockResolvedValue({
|
||||
games: [
|
||||
makeGame({ id: "g1", title: "Old Game", year: 1993 }),
|
||||
makeGame({ id: "g2", title: "New Game", year: 1997 }),
|
||||
],
|
||||
});
|
||||
render(Library, { props: { yearFilter: "1997" } });
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByText("New Game")).toBeTruthy();
|
||||
expect(screen.queryByText("Old Game")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows filter-empty message when no filter options exist", async () => {
|
||||
api.listGames.mockResolvedValue({
|
||||
games: [
|
||||
makeGame({ id: "g1", genre: null, year: null }),
|
||||
makeGame({ id: "g2", genre: null, year: null }),
|
||||
],
|
||||
});
|
||||
render(Library);
|
||||
await vi.waitFor(() => {
|
||||
const dashes = screen.getAllByText("—");
|
||||
expect(dashes.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/svelte";
|
||||
import Play from "../Play.svelte";
|
||||
|
||||
// Mock the API module that Play imports
|
||||
vi.mock("../../lib/api.js", () => ({
|
||||
getGame: vi.fn(),
|
||||
bundleUrl: vi.fn(),
|
||||
setupBundleUrl: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock router
|
||||
vi.mock("../../lib/router.js", () => ({
|
||||
push: vi.fn(),
|
||||
}));
|
||||
|
||||
import * as api from "../../lib/api.js";
|
||||
|
||||
function makeGame(overrides = {}) {
|
||||
return {
|
||||
id: "game-1",
|
||||
title: "Commander Keen",
|
||||
platform: "dos",
|
||||
ready: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("Play", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("shows loading state initially", () => {
|
||||
api.getGame.mockReturnValue(new Promise(() => {}));
|
||||
render(Play, { props: { id: "game-1" } });
|
||||
expect(screen.getByText("Loading game data...")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows not-ready state when game is not processed", async () => {
|
||||
api.getGame.mockResolvedValue(makeGame({ ready: false }));
|
||||
render(Play, { props: { id: "game-1" } });
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByText("Not ready")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows unsupported warning for Windows games", async () => {
|
||||
api.getGame.mockResolvedValue(
|
||||
makeGame({ platform: "windows", ready: true })
|
||||
);
|
||||
render(Play, { props: { id: "game-1" } });
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByText("Windows game")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows error box when game fetch fails", async () => {
|
||||
api.getGame.mockRejectedValue(new Error("Game not found"));
|
||||
render(Play, { props: { id: "missing" } });
|
||||
// The error should appear in the overlay as well
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByText("⚠️")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows emulator booting state for ready DOS games", async () => {
|
||||
api.getGame.mockResolvedValue(
|
||||
makeGame({ platform: "dos", ready: true })
|
||||
);
|
||||
api.bundleUrl.mockReturnValue("/games/game-1.jsdos");
|
||||
render(Play, { props: { id: "game-1" } });
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByText("Starting emulator...")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
export default {
|
||||
compilerOptions: {
|
||||
runes: true,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
/// <reference types="vitest" />
|
||||
import { defineConfig } from "vite";
|
||||
import { svelte } from "@sveltejs/vite-plugin-svelte";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [svelte()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
"/api": "http://localhost:8765",
|
||||
"/games": "http://localhost:8765",
|
||||
"/artwork": "http://localhost:8765",
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: "dist",
|
||||
emptyOutDir: true,
|
||||
},
|
||||
resolve: {
|
||||
conditions: process.env.VITEST ? ["browser", "module", "import"] : [],
|
||||
},
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
globals: true,
|
||||
include: ["src/**/*.test.js"],
|
||||
},
|
||||
});
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 262 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 314 KiB |
@@ -0,0 +1,155 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<groupId>org.dostalgia</groupId>
|
||||
<artifactId>dostalgia</artifactId>
|
||||
<version>0.1.0</version>
|
||||
|
||||
<properties>
|
||||
<maven.compiler.release>21</maven.compiler.release>
|
||||
<maven.compiler.source>21</maven.compiler.source>
|
||||
<maven.compiler.target>21</maven.compiler.target>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
<quarkus.platform.version>3.19.2</quarkus.platform.version>
|
||||
<quarkus.platform.group-id>io.quarkus.platform</quarkus.platform.group-id>
|
||||
<!-- Set to true to skip all frontend npm tasks (install/build/test) -->
|
||||
<skipFrontend>false</skipFrontend>
|
||||
<!-- Set to true to skip only frontend tests (backend tests still run) -->
|
||||
<skipFrontendTests>false</skipFrontendTests>
|
||||
</properties>
|
||||
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>${quarkus.platform.group-id}</groupId>
|
||||
<artifactId>quarkus-bom</artifactId>
|
||||
<version>${quarkus.platform.version}</version>
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</dependencyManagement>
|
||||
|
||||
<dependencies>
|
||||
<!-- REST API (JAX-RS with Reactive) -->
|
||||
<dependency>
|
||||
<groupId>io.quarkus</groupId>
|
||||
<artifactId>quarkus-rest</artifactId>
|
||||
</dependency>
|
||||
<!-- JSON serialization -->
|
||||
<dependency>
|
||||
<groupId>io.quarkus</groupId>
|
||||
<artifactId>quarkus-rest-jackson</artifactId>
|
||||
</dependency>
|
||||
<!-- Multipart support is built into quarkus-rest -->
|
||||
<!-- Serve static files from classpath -->
|
||||
<dependency>
|
||||
<groupId>io.quarkus</groupId>
|
||||
<artifactId>quarkus-vertx-http</artifactId>
|
||||
</dependency>
|
||||
<!-- Arc CDI (included, needed for @Singleton etc) -->
|
||||
<dependency>
|
||||
<groupId>io.quarkus</groupId>
|
||||
<artifactId>quarkus-arc</artifactId>
|
||||
</dependency>
|
||||
<!-- Test -->
|
||||
<dependency>
|
||||
<groupId>io.quarkus</groupId>
|
||||
<artifactId>quarkus-junit5</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>${quarkus.platform.group-id}</groupId>
|
||||
<artifactId>quarkus-maven-plugin</artifactId>
|
||||
<version>${quarkus.platform.version}</version>
|
||||
<extensions>true</extensions>
|
||||
<executions>
|
||||
<execution>
|
||||
<goals>
|
||||
<goal>build</goal>
|
||||
<goal>generate-code</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
<!-- Copy built frontend into classpath resources -->
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-resources-plugin</artifactId>
|
||||
<version>3.3.1</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>copy-frontend</id>
|
||||
<phase>generate-resources</phase>
|
||||
<goals><goal>copy-resources</goal></goals>
|
||||
<configuration>
|
||||
<outputDirectory>${project.build.directory}/classes/META-INF/resources</outputDirectory>
|
||||
<resources>
|
||||
<resource>
|
||||
<directory>frontend/dist</directory>
|
||||
<filtering>false</filtering>
|
||||
</resource>
|
||||
</resources>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
<!-- Run frontend npm tasks during the Maven lifecycle -->
|
||||
<plugin>
|
||||
<groupId>org.codehaus.mojo</groupId>
|
||||
<artifactId>exec-maven-plugin</artifactId>
|
||||
<version>3.5.0</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>npm-install</id>
|
||||
<phase>initialize</phase>
|
||||
<goals><goal>exec</goal></goals>
|
||||
<configuration>
|
||||
<workingDirectory>frontend</workingDirectory>
|
||||
<executable>npm</executable>
|
||||
<arguments>
|
||||
<argument>ci</argument>
|
||||
<argument>--prefer-offline</argument>
|
||||
</arguments>
|
||||
<skip>${skipFrontend}</skip>
|
||||
</configuration>
|
||||
</execution>
|
||||
<execution>
|
||||
<id>frontend-build</id>
|
||||
<phase>generate-resources</phase>
|
||||
<goals><goal>exec</goal></goals>
|
||||
<configuration>
|
||||
<workingDirectory>frontend</workingDirectory>
|
||||
<executable>npm</executable>
|
||||
<arguments>
|
||||
<argument>run</argument>
|
||||
<argument>build</argument>
|
||||
</arguments>
|
||||
<skip>${skipFrontend}</skip>
|
||||
</configuration>
|
||||
</execution>
|
||||
<execution>
|
||||
<id>frontend-test</id>
|
||||
<phase>test</phase>
|
||||
<goals><goal>exec</goal></goals>
|
||||
<configuration>
|
||||
<workingDirectory>frontend</workingDirectory>
|
||||
<executable>npm</executable>
|
||||
<arguments>
|
||||
<argument>test</argument>
|
||||
</arguments>
|
||||
<skip>${skipFrontendTests}</skip>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
@@ -0,0 +1,106 @@
|
||||
package org.dostalgia;
|
||||
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
/**
|
||||
* CD image utilities: CUE sheet repair and CD image discovery in directories and ZIPs.
|
||||
*/
|
||||
@ApplicationScoped
|
||||
public class CdImageService {
|
||||
|
||||
private static final Logger LOG = Logger.getLogger(CdImageService.class.getName());
|
||||
private static final java.util.Set<String> CD_EXT = java.util.Set.of(
|
||||
".iso", ".cue", ".img", ".ccd", ".bin"
|
||||
);
|
||||
|
||||
/**
|
||||
* Fix .cue files that reference non-existent data files.
|
||||
* CloneCD rips often generate a .cue referencing a .bin, but the actual file is .img.
|
||||
*/
|
||||
public void fixCueReferences(Path extractDir) throws IOException {
|
||||
try (var walk = Files.walk(extractDir)) {
|
||||
walk.filter(Files::isRegularFile)
|
||||
.filter(f -> f.getFileName().toString().toLowerCase().endsWith(".cue"))
|
||||
.forEach(f -> {
|
||||
try { fixOneCue(f); }
|
||||
catch (IOException e) {
|
||||
LOG.warning("Failed to fix cue file " + f + ": " + e.getMessage());
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private void fixOneCue(Path cueFile) throws IOException {
|
||||
String content = Files.readString(cueFile);
|
||||
Path dir = cueFile.getParent();
|
||||
|
||||
var m = java.util.regex.Pattern
|
||||
.compile("FILE\\s+\"([^\"]+)\"", java.util.regex.Pattern.CASE_INSENSITIVE)
|
||||
.matcher(content);
|
||||
if (!m.find()) return;
|
||||
|
||||
String referenced = m.group(1);
|
||||
Path refPath = dir.resolve(referenced);
|
||||
if (Files.exists(refPath)) return;
|
||||
|
||||
Path replacement = findDataFile(dir);
|
||||
if (replacement == null) return;
|
||||
|
||||
String actualName = replacement.getFileName().toString();
|
||||
content = content.substring(0, m.start(1)) + actualName + content.substring(m.end(1));
|
||||
Files.writeString(cueFile, content);
|
||||
LOG.info("Fixed cue " + cueFile.getFileName() + ": '" + referenced + "' → '" + actualName + "'");
|
||||
}
|
||||
|
||||
private Path findDataFile(Path dir) throws IOException {
|
||||
try (var files = Files.list(dir)) {
|
||||
return files.filter(Files::isRegularFile)
|
||||
.filter(f -> {
|
||||
String n = f.getFileName().toString().toLowerCase();
|
||||
return n.endsWith(".img") || n.endsWith(".bin");
|
||||
})
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
}
|
||||
}
|
||||
|
||||
/** Find mountable CD image files in a directory. Returns sorted relative paths. */
|
||||
public java.util.List<String> findInDirectory(Path dir) throws IOException {
|
||||
java.util.List<String> cds = new java.util.ArrayList<>();
|
||||
try (var walk = Files.walk(dir)) {
|
||||
walk.filter(Files::isRegularFile).forEach(f -> {
|
||||
String name = f.getFileName().toString().toLowerCase();
|
||||
int dot = name.lastIndexOf('.');
|
||||
if (dot >= 0 && CD_EXT.contains(name.substring(dot))) {
|
||||
cds.add(dir.relativize(f).toString().replace('\\', '/'));
|
||||
}
|
||||
});
|
||||
}
|
||||
cds.sort(String::compareTo);
|
||||
return cds;
|
||||
}
|
||||
|
||||
/** Find CD image files inside a .jsdos ZIP bundle. */
|
||||
public java.util.List<String> findInBundle(Path bundlePath) throws IOException {
|
||||
java.util.List<String> cds = new java.util.ArrayList<>();
|
||||
try (var zis = new java.util.zip.ZipInputStream(Files.newInputStream(bundlePath))) {
|
||||
java.util.zip.ZipEntry entry;
|
||||
while ((entry = zis.getNextEntry()) != null) {
|
||||
if (entry.isDirectory()) continue;
|
||||
String name = entry.getName().toLowerCase();
|
||||
int dot = name.lastIndexOf('.');
|
||||
if (dot >= 0 && CD_EXT.contains(name.substring(dot))) {
|
||||
cds.add(entry.getName());
|
||||
}
|
||||
zis.closeEntry();
|
||||
}
|
||||
}
|
||||
cds.sort(String::compareTo);
|
||||
return cds;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
package org.dostalgia;
|
||||
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Single source of truth for all DOSBox configuration generation.
|
||||
*/
|
||||
@ApplicationScoped
|
||||
public class ConfigBuilder {
|
||||
|
||||
/**
|
||||
* Build a dosbox.conf for a game with a known executable.
|
||||
* @param relExe relative path to the executable inside the ZIP (e.g. "FALLOUT.EXE" or "FALLOUT/FALLOUT.EXE")
|
||||
* @param cdImages relative paths to CD image files inside the ZIP
|
||||
*/
|
||||
public String buildDosboxConf(String relExe, List<String> cdImages) {
|
||||
var parts = splitExePath(relExe);
|
||||
String dir = parts.dir();
|
||||
String exe = parts.exe();
|
||||
|
||||
return DOSBOX_CONF_TEMPLATE
|
||||
.replace("${CD_MOUNTS}", buildCdMounts(cdImages))
|
||||
.replace("${EXEC_PATH}", buildExecPath(dir, exe));
|
||||
}
|
||||
|
||||
/** Build dosbox.conf as UTF-8 bytes (for ZIP stream operations). */
|
||||
public byte[] buildDosboxConfBytes(String relExe, List<String> cdImages) {
|
||||
return buildDosboxConf(relExe, cdImages).getBytes(StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build jsdos.json for js-dos v8 with the given executable path.
|
||||
* js-dos v8 uses jsdos.json autoexec.script instead of the dosbox.conf [autoexec] section.
|
||||
*/
|
||||
public byte[] buildJsdosJson(String relExe, List<String> cdImages) {
|
||||
var parts = splitExePath(relExe);
|
||||
String script = buildAutoexecScript(parts.dir(), parts.exe(), cdImages);
|
||||
return buildJsdosJsonRaw(script);
|
||||
}
|
||||
|
||||
/** Build dosbox.conf for a CD-only game (no filesystem executables). */
|
||||
public String buildCdOnlyDosboxConf(List<String> cdImages) {
|
||||
return CD_ONLY_DOSBOX_CONF_TEMPLATE
|
||||
.replace("${CD_MOUNTS}", buildCdMounts(cdImages));
|
||||
}
|
||||
|
||||
/** Build jsdos.json for a CD-only game. */
|
||||
public byte[] buildCdOnlyJsdosJson(List<String> cdImages) {
|
||||
String script = buildCdMountScript(cdImages) + "c:\n";
|
||||
return buildJsdosJsonRaw(script);
|
||||
}
|
||||
|
||||
/** Split "dir/file.exe" into (dir, exe). Returns (null, file) if no directory separator. */
|
||||
public static ExeParts splitExePath(String relExe) {
|
||||
if (relExe == null || relExe.isBlank()) {
|
||||
return new ExeParts(null, "");
|
||||
}
|
||||
int idx = relExe.replace('\\', '/').lastIndexOf('/');
|
||||
if (idx >= 0) {
|
||||
return new ExeParts(relExe.substring(0, idx), relExe.substring(idx + 1));
|
||||
}
|
||||
return new ExeParts(null, relExe);
|
||||
}
|
||||
|
||||
/** Escape a string for embedding inside a JSON string value. */
|
||||
public static String escapeJson(String s) {
|
||||
return s.replace("\\", "\\\\").replace("\"", "\\\"");
|
||||
}
|
||||
|
||||
public record ExeParts(String dir, String exe) {}
|
||||
|
||||
private static final String DOSBOX_CONF_TEMPLATE = """
|
||||
[sdl]
|
||||
autolock=true
|
||||
usescancodes=true
|
||||
output=surface
|
||||
|
||||
[cpu]
|
||||
core=auto
|
||||
cycles=auto
|
||||
|
||||
[mixer]
|
||||
nosound=false
|
||||
rate=44100
|
||||
|
||||
[sblaster]
|
||||
sbtype=sb16
|
||||
irq=7
|
||||
dma=1
|
||||
hdma=5
|
||||
|
||||
[dos]
|
||||
xms=true
|
||||
ems=true
|
||||
umb=true
|
||||
|
||||
[dosbox]
|
||||
memsize=64
|
||||
|
||||
[autoexec]
|
||||
@echo off
|
||||
mount c .
|
||||
${CD_MOUNTS}c:
|
||||
${EXEC_PATH}
|
||||
""";
|
||||
|
||||
private static final String CD_ONLY_DOSBOX_CONF_TEMPLATE = """
|
||||
[sdl]
|
||||
autolock=true
|
||||
usescancodes=true
|
||||
output=surface
|
||||
|
||||
[cpu]
|
||||
core=auto
|
||||
cycles=auto
|
||||
|
||||
[mixer]
|
||||
nosound=false
|
||||
rate=44100
|
||||
|
||||
[sblaster]
|
||||
sbtype=sb16
|
||||
irq=7
|
||||
dma=1
|
||||
hdma=5
|
||||
|
||||
[dos]
|
||||
xms=true
|
||||
ems=true
|
||||
umb=true
|
||||
|
||||
[dosbox]
|
||||
memsize=64
|
||||
|
||||
[autoexec]
|
||||
@echo off
|
||||
mount c .
|
||||
${CD_MOUNTS}c:
|
||||
echo.
|
||||
echo This game runs from the CD-ROM.
|
||||
echo Type D: and press Enter, then look for the game executable (e.g. DOTT.EXE).
|
||||
""";
|
||||
|
||||
/**
|
||||
* Build the CD mount lines for the autoexec section.
|
||||
* Mounts ALL CD images, deduplicating by parent directory.
|
||||
* Skips descriptor formats (.cue, .ccd) when a data format
|
||||
* (.img, .iso, .bin) exists in the same directory.
|
||||
*/
|
||||
private static String buildCdMounts(List<String> cdImages) {
|
||||
if (cdImages == null || cdImages.isEmpty()) return "";
|
||||
// Deduplicate by parent directory — prefer data formats over descriptors
|
||||
Set<String> seenDirs = new HashSet<>();
|
||||
StringBuilder sb = new StringBuilder();
|
||||
char driveLetter = 'D';
|
||||
for (String img : cdImages) {
|
||||
int slashIdx = img.lastIndexOf('/');
|
||||
String parentDir = slashIdx >= 0 ? img.substring(0, slashIdx) : "";
|
||||
if (seenDirs.contains(parentDir)) continue;
|
||||
String imgLower = img.toLowerCase();
|
||||
// Skip descriptor formats if a data format exists in the same directory
|
||||
if (imgLower.endsWith(".cue") || imgLower.endsWith(".ccd")) {
|
||||
boolean hasData = cdImages.stream().anyMatch(i -> {
|
||||
int is = i.lastIndexOf('/');
|
||||
String ip = is >= 0 ? i.substring(0, is) : "";
|
||||
String il = i.toLowerCase();
|
||||
return ip.equals(parentDir)
|
||||
&& (il.endsWith(".img") || il.endsWith(".iso") || il.endsWith(".bin"));
|
||||
});
|
||||
if (hasData) continue;
|
||||
}
|
||||
seenDirs.add(parentDir);
|
||||
String flags = imgLower.endsWith(".bin") ? " -t cdrom -fs iso" : " -t cdrom";
|
||||
sb.append("imgmount ").append(driveLetter).append(" \"")
|
||||
.append(img).append("\"").append(flags).append("\n");
|
||||
driveLetter++;
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private static String buildExecPath(String dir, String exe) {
|
||||
if (dir == null) return exe + "\n";
|
||||
return "path=c:\\\ncd " + dir + "\n" + exe + "\n";
|
||||
}
|
||||
|
||||
private static String buildCdMountScript(List<String> cdImages) {
|
||||
StringBuilder script = new StringBuilder();
|
||||
script.append("mount c .\n");
|
||||
char driveLetter = 'D';
|
||||
for (String img : cdImages) {
|
||||
String imgLower = img.toLowerCase();
|
||||
String flags = imgLower.endsWith(".bin") && !imgLower.endsWith(".cue")
|
||||
? " -t cdrom -fs iso" : " -t cdrom";
|
||||
script.append("imgmount ").append(driveLetter).append(" \"")
|
||||
.append(img).append("\"").append(flags).append("\n");
|
||||
driveLetter++;
|
||||
}
|
||||
return script.toString();
|
||||
}
|
||||
|
||||
private static String buildAutoexecScript(String dir, String exe, List<String> cdImages) {
|
||||
StringBuilder script = new StringBuilder(buildCdMountScript(cdImages));
|
||||
script.append("c:\n");
|
||||
if (dir != null) {
|
||||
script.append("path=c:\\\n");
|
||||
script.append("cd ").append(dir).append("\n");
|
||||
}
|
||||
script.append(exe);
|
||||
return script.toString();
|
||||
}
|
||||
|
||||
private static byte[] buildJsdosJsonRaw(String script) {
|
||||
String json = "{\"autoexec\":{\"options\":{\"script\":{\"value\":\""
|
||||
+ escapeJson(script) + "\"}}},"
|
||||
+ "\"output\":{\"options\":{\"autolock\":{\"value\":true}}}}";
|
||||
return json.getBytes(StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package org.dostalgia;
|
||||
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
import java.util.logging.Logger;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Detects and fixes hardcoded absolute DOS paths in game config files.
|
||||
* Handles the common case where game archives contain paths like
|
||||
* C:\\FALLOUT1\\MASTER.DAT that break after directory flattening.
|
||||
*/
|
||||
@ApplicationScoped
|
||||
public class ConfigPatcher {
|
||||
|
||||
private static final Logger LOG = Logger.getLogger(ConfigPatcher.class.getName());
|
||||
private static final Pattern DRIVE_PATH = Pattern.compile("[A-Za-z]:([\\\\/])[^\\s\"\\r\\n]+");
|
||||
|
||||
/** Scan and fix absolute paths in all text files in the given directory. */
|
||||
public void fixAbsolutePaths(Path extractDir) throws IOException {
|
||||
try (var walk = Files.walk(extractDir)) {
|
||||
walk.filter(Files::isRegularFile)
|
||||
.filter(f -> {
|
||||
try { return isSmallTextFile(f); }
|
||||
catch (IOException e) { return false; }
|
||||
})
|
||||
.forEach(f -> {
|
||||
try {
|
||||
String text = Files.readString(f, StandardCharsets.ISO_8859_1);
|
||||
String patched = patchText(text, extractDir);
|
||||
if (!patched.equals(text)) {
|
||||
Files.writeString(f, patched, StandardCharsets.ISO_8859_1);
|
||||
LOG.info("Patched absolute paths in " + f.getFileName());
|
||||
}
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Failed to patch " + f.getFileName() + ": " + e.getMessage());
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isSmallTextFile(Path f) throws IOException {
|
||||
long size = Files.size(f);
|
||||
if (size > 100 * 1024 || size == 0) return false;
|
||||
try (var is = Files.newInputStream(f)) {
|
||||
byte[] head = new byte[(int) Math.min(size, 4096)];
|
||||
int read = is.readNBytes(head, 0, head.length);
|
||||
for (int i = 0; i < read; i++) {
|
||||
if (head[i] == 0) return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static String patchText(String text, Path extractDir) {
|
||||
var m = DRIVE_PATH.matcher(text);
|
||||
var sb = new StringBuilder();
|
||||
int lastEnd = 0;
|
||||
Set<String> seenPrefixes = new HashSet<>();
|
||||
|
||||
while (m.find()) {
|
||||
int start = m.start();
|
||||
sb.append(text, lastEnd, start);
|
||||
|
||||
String fullPath = m.group();
|
||||
String drive = fullPath.substring(0, 2);
|
||||
if (!"C:".equalsIgnoreCase(drive)) {
|
||||
sb.append(fullPath);
|
||||
lastEnd = m.end();
|
||||
continue;
|
||||
}
|
||||
|
||||
String replacement = findReplacement(fullPath, extractDir, seenPrefixes);
|
||||
sb.append(replacement);
|
||||
lastEnd = m.end();
|
||||
}
|
||||
sb.append(text.substring(lastEnd));
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private static String findReplacement(String fullPath, Path extractDir, Set<String> seenPrefixes) {
|
||||
String drivePrefix = fullPath.substring(0, 2);
|
||||
char sep = fullPath.charAt(2);
|
||||
String splitRegex = sep == '\\' ? "\\\\" : "/";
|
||||
String relPath = fullPath.substring(3);
|
||||
String[] parts = relPath.split(splitRegex, -1);
|
||||
|
||||
for (int skip = 0; skip < parts.length; skip++) {
|
||||
StringBuilder suffix = new StringBuilder();
|
||||
for (int j = skip; j < parts.length; j++) {
|
||||
if (j > skip) suffix.append(sep);
|
||||
suffix.append(parts[j]);
|
||||
}
|
||||
String suffixStr = suffix.toString();
|
||||
if (suffixStr.isEmpty()) continue;
|
||||
|
||||
if (existsIgnoreCase(extractDir, suffixStr)) {
|
||||
if (skip == 0) return fullPath;
|
||||
|
||||
StringBuilder stale = new StringBuilder(drivePrefix + sep);
|
||||
for (int k = 0; k < skip; k++) {
|
||||
if (k > 0) stale.append(sep);
|
||||
stale.append(parts[k]);
|
||||
}
|
||||
stale.append(sep);
|
||||
|
||||
String staleStr = stale.toString();
|
||||
if (seenPrefixes.contains(staleStr)) {
|
||||
return "." + sep + suffixStr;
|
||||
}
|
||||
seenPrefixes.add(staleStr);
|
||||
return "." + sep + suffixStr;
|
||||
}
|
||||
}
|
||||
return fullPath;
|
||||
}
|
||||
|
||||
private static boolean existsIgnoreCase(Path base, String relativePath) {
|
||||
char sep = relativePath.contains("\\") ? '\\' : '/';
|
||||
String[] parts = relativePath.split(sep == '\\' ? "\\\\" : "/", -1);
|
||||
Path current = base;
|
||||
try {
|
||||
for (int i = 0; i < parts.length; i++) {
|
||||
String part = parts[i];
|
||||
if (part.isEmpty()) {
|
||||
if (i == parts.length - 1) return Files.isDirectory(current);
|
||||
continue;
|
||||
}
|
||||
Path found = null;
|
||||
try (var dirStream = Files.list(current)) {
|
||||
for (Path entry : dirStream.toList()) {
|
||||
if (entry.getFileName().toString().equalsIgnoreCase(part)) {
|
||||
found = entry;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (found == null) return false;
|
||||
current = found;
|
||||
}
|
||||
} catch (IOException e) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package org.dostalgia;
|
||||
|
||||
import jakarta.annotation.Nonnull;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import jakarta.inject.Inject;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.FileVisitResult;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.SimpleFileVisitor;
|
||||
import java.nio.file.attribute.BasicFileAttributes;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Detects game executables within extracted archives.
|
||||
*/
|
||||
@ApplicationScoped
|
||||
public class ExecutableDetector {
|
||||
|
||||
@Inject
|
||||
PlatformDetector platform;
|
||||
|
||||
private static final Set<String> SKIP_EXE_NAMES = Set.of(
|
||||
"dos4gw", "dos32a", "dos4gw2", "pmode", "pmodew", "cwsdpmi",
|
||||
"emm386", "himem", "himemx", "debug",
|
||||
"uninst", "uninstall", "uninstaller",
|
||||
"pksfx", "pkzip", "pkunzip", "sfx", "makesfx",
|
||||
"unzip", "zip", "zip2exe", "arj", "arj32",
|
||||
"lha", "lha32", "lzh", "rar", "unrar",
|
||||
"ace", "unace", "zoo", "arc", "ha", "cab"
|
||||
);
|
||||
|
||||
public List<String> findAll(Path dir) throws IOException {
|
||||
return FileUtils.findFilesByExtensions(dir, Set.of(".exe", ".com", ".bat"));
|
||||
}
|
||||
|
||||
public String findMain(Path dir) throws IOException {
|
||||
record Candidate(int depth, boolean isInstaller, long size, String path, String platform) {}
|
||||
List<Candidate> candidates = new ArrayList<>();
|
||||
|
||||
Files.walkFileTree(dir, new SimpleFileVisitor<>() {
|
||||
@Override
|
||||
public @Nonnull FileVisitResult visitFile(@Nonnull Path f, @Nonnull BasicFileAttributes a) {
|
||||
String name = f.getFileName().toString().toLowerCase();
|
||||
if (!name.endsWith(".exe") && !name.endsWith(".com") && !name.endsWith(".bat"))
|
||||
return FileVisitResult.CONTINUE;
|
||||
|
||||
int dot = name.lastIndexOf('.');
|
||||
String stem = dot >= 0 ? name.substring(0, dot) : name;
|
||||
if (SKIP_EXE_NAMES.contains(stem))
|
||||
return FileVisitResult.CONTINUE;
|
||||
|
||||
if (isLikelySelfExtractor(f))
|
||||
return FileVisitResult.CONTINUE;
|
||||
|
||||
boolean installer = name.contains("install") || name.contains("setup") || name.contains("config");
|
||||
int depth = f.getNameCount() - dir.getNameCount();
|
||||
String detectedPlatform = platform.detect(f);
|
||||
candidates.add(new Candidate(depth, installer, a.size(), f.toString(), detectedPlatform));
|
||||
return FileVisitResult.CONTINUE;
|
||||
}
|
||||
});
|
||||
|
||||
if (candidates.isEmpty()) return null;
|
||||
|
||||
candidates.sort(Comparator
|
||||
.comparingInt((Candidate c) -> c.isInstaller() ? 1 : 0)
|
||||
.thenComparingInt(c -> {
|
||||
if ("dos".equals(c.platform())) return 0;
|
||||
if (c.platform() == null) return 1;
|
||||
return 2;
|
||||
})
|
||||
.thenComparingLong(c -> -c.size())
|
||||
.thenComparingInt(Candidate::depth));
|
||||
|
||||
return candidates.getFirst().path();
|
||||
}
|
||||
|
||||
public String findSetup(Path dir) throws IOException {
|
||||
record Candidate(int depth, boolean isWindows, long size, String path) {}
|
||||
List<Candidate> candidates = new ArrayList<>();
|
||||
List<String> patterns = List.of("setup", "setmain", "install", "config", "custom");
|
||||
|
||||
Files.walkFileTree(dir, new SimpleFileVisitor<>() {
|
||||
@Override
|
||||
public @Nonnull FileVisitResult visitFile(@Nonnull Path f, @Nonnull BasicFileAttributes a) {
|
||||
String name = f.getFileName().toString().toLowerCase();
|
||||
String stem = name.contains(".") ? name.substring(0, name.lastIndexOf('.')) : name;
|
||||
if (!name.endsWith(".exe") && !name.endsWith(".com") && !name.endsWith(".bat"))
|
||||
return FileVisitResult.CONTINUE;
|
||||
for (String pat : patterns) {
|
||||
if (stem.equals(pat) || stem.startsWith(pat + ".") || stem.startsWith(pat + "-")) {
|
||||
boolean isWin = "windows".equals(platform.detect(f));
|
||||
candidates.add(new Candidate(
|
||||
f.getNameCount() - dir.getNameCount(), isWin, a.size(), f.toString()));
|
||||
break;
|
||||
}
|
||||
}
|
||||
return FileVisitResult.CONTINUE;
|
||||
}
|
||||
});
|
||||
|
||||
if (candidates.isEmpty()) return null;
|
||||
candidates.sort(Comparator
|
||||
.comparingInt((Candidate c) -> c.isWindows() ? 1 : 0) // prefer DOS over Windows
|
||||
.thenComparingInt(Candidate::depth)
|
||||
.thenComparingLong(Candidate::size));
|
||||
return candidates.getFirst().path();
|
||||
}
|
||||
|
||||
public static boolean isLikelySelfExtractor(Path exePath) {
|
||||
try (var fis = Files.newInputStream(exePath)) {
|
||||
byte[] buf = new byte[32768];
|
||||
int read = fis.readNBytes(buf, 0, buf.length);
|
||||
if (read < 0x40) return false;
|
||||
if (buf[0] != 0x4D || buf[1] != 0x5A) return false;
|
||||
|
||||
String content = new String(buf, 0, read, StandardCharsets.ISO_8859_1);
|
||||
if (content.contains("PKZIP") || content.contains("PKSFX")) return true;
|
||||
|
||||
for (int i = 0x40; i < read - 4; i++) {
|
||||
if (buf[i] == 0x50 && (buf[i+1] & 0xFF) == 0x4B) {
|
||||
int sig = (buf[i+2] & 0xFF) | ((buf[i+3] & 0xFF) << 8);
|
||||
if (sig == 0x0403 || sig == 0x0201 || sig == 0x0605) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
} catch (IOException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package org.dostalgia;
|
||||
|
||||
import jakarta.annotation.Nonnull;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.FileVisitResult;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.SimpleFileVisitor;
|
||||
import java.nio.file.attribute.BasicFileAttributes;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
/** Shared file-system utilities. */
|
||||
public final class FileUtils {
|
||||
private FileUtils() {}
|
||||
|
||||
/** Recursively delete a directory tree (walk in reverse order so dirs are empty when deleted). */
|
||||
public static void deleteDirectory(final Path dir) throws IOException {
|
||||
if (!Files.exists(dir)) return;
|
||||
Files.walkFileTree(dir, new SimpleFileVisitor<>() {
|
||||
@Override
|
||||
public @Nonnull FileVisitResult visitFile(@Nonnull final Path f, @Nonnull final BasicFileAttributes a) throws IOException {
|
||||
Files.delete(f);
|
||||
return FileVisitResult.CONTINUE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @Nonnull FileVisitResult postVisitDirectory(@Nonnull final Path d, final IOException e) throws IOException {
|
||||
Files.delete(d);
|
||||
return FileVisitResult.CONTINUE;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk a directory tree and collect relative paths of files whose extension matches
|
||||
* one of the given extensions. Extensions should include the dot, e.g. {@code ".exe"}.
|
||||
* Results are sorted alphabetically.
|
||||
*/
|
||||
public static List<String> findFilesByExtensions(Path dir, Set<String> extensions) throws IOException {
|
||||
List<String> result = new ArrayList<>();
|
||||
Files.walkFileTree(dir, new SimpleFileVisitor<>() {
|
||||
@Override
|
||||
public @Nonnull FileVisitResult visitFile(@Nonnull Path f, @Nonnull BasicFileAttributes a) {
|
||||
String name = f.getFileName().toString().toLowerCase();
|
||||
for (String ext : extensions) {
|
||||
if (name.endsWith(ext)) {
|
||||
result.add(dir.relativize(f).toString().replace('\\', '/'));
|
||||
break;
|
||||
}
|
||||
}
|
||||
return FileVisitResult.CONTINUE;
|
||||
}
|
||||
});
|
||||
result.sort(String::compareTo);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package org.dostalgia;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
/** Represents a single DOS game, stored as data/games/{id}/game.json */
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class Game {
|
||||
private String id;
|
||||
private String title;
|
||||
private Integer year;
|
||||
private String genre;
|
||||
private String developer;
|
||||
private String publisher;
|
||||
private String description;
|
||||
private Double rating;
|
||||
private String bundleFile;
|
||||
private String setupExe;
|
||||
/** Game bundle file size in bytes. Populated dynamically at load time (not persisted). */
|
||||
private Long bundleSize;
|
||||
/** Currently selected main executable (relative path inside the bundle). */
|
||||
private String executable;
|
||||
/** All discoverable executables in the bundle (for the UI picker). */
|
||||
private List<String> executables;
|
||||
/** Platform type: "dos" for pure DOS, "windows" for Windows 3.x/9x+ */
|
||||
private String platform;
|
||||
private Integer igdbId;
|
||||
private String igdbCoverId;
|
||||
private boolean hasCover;
|
||||
private boolean hasScreenshots;
|
||||
private List<String> screenshots;
|
||||
private List<String> videos;
|
||||
private boolean ready;
|
||||
private Instant createdAt;
|
||||
private Instant updatedAt;
|
||||
|
||||
public Game() {}
|
||||
|
||||
public Game(String id, String title) {
|
||||
this.id = id;
|
||||
this.title = title;
|
||||
this.ready = false;
|
||||
this.createdAt = Instant.now();
|
||||
this.updatedAt = Instant.now();
|
||||
}
|
||||
|
||||
public String getId() { return id; }
|
||||
public void setId(String id) { this.id = id; }
|
||||
|
||||
public String getTitle() { return title; }
|
||||
public void setTitle(String title) { this.title = title; }
|
||||
|
||||
public Integer getYear() { return year; }
|
||||
public void setYear(Integer year) { this.year = year; }
|
||||
|
||||
public String getGenre() { return genre; }
|
||||
public void setGenre(String genre) { this.genre = genre; }
|
||||
|
||||
public String getDeveloper() { return developer; }
|
||||
public void setDeveloper(String developer) { this.developer = developer; }
|
||||
|
||||
public String getPublisher() { return publisher; }
|
||||
public void setPublisher(String publisher) { this.publisher = publisher; }
|
||||
|
||||
public String getDescription() { return description; }
|
||||
public void setDescription(String description) { this.description = description; }
|
||||
|
||||
public Double getRating() { return rating; }
|
||||
public void setRating(Double rating) { this.rating = rating; }
|
||||
|
||||
public String getBundleFile() { return bundleFile; }
|
||||
public void setBundleFile(String bundleFile) { this.bundleFile = bundleFile; }
|
||||
|
||||
public String getSetupExe() { return setupExe; }
|
||||
public void setSetupExe(String setupExe) { this.setupExe = setupExe; }
|
||||
|
||||
public Long getBundleSize() { return bundleSize; }
|
||||
public void setBundleSize(Long bundleSize) { this.bundleSize = bundleSize; }
|
||||
|
||||
public String getExecutable() { return executable; }
|
||||
public void setExecutable(String executable) { this.executable = executable; }
|
||||
|
||||
public List<String> getExecutables() { return executables; }
|
||||
public void setExecutables(List<String> executables) { this.executables = executables; }
|
||||
|
||||
/** Returns true if a SETUP.EXE (or variant) was detected in the game archive. */
|
||||
public boolean isHasSetup() { return setupExe != null && !setupExe.isBlank(); }
|
||||
|
||||
public String getPlatform() { return platform; }
|
||||
public void setPlatform(String platform) { this.platform = platform; }
|
||||
|
||||
/** Returns true if the game is a pure DOS executable that can run natively in DOSBox. */
|
||||
public boolean isPlayable() {
|
||||
return ready && (platform == null || "dos".equals(platform));
|
||||
}
|
||||
|
||||
public Integer getIgdbId() { return igdbId; }
|
||||
public void setIgdbId(Integer igdbId) { this.igdbId = igdbId; }
|
||||
|
||||
public String getIgdbCoverId() { return igdbCoverId; }
|
||||
public void setIgdbCoverId(String igdbCoverId) { this.igdbCoverId = igdbCoverId; }
|
||||
|
||||
public boolean isHasCover() { return hasCover; }
|
||||
public void setHasCover(boolean hasCover) { this.hasCover = hasCover; }
|
||||
|
||||
public boolean isHasScreenshots() { return hasScreenshots; }
|
||||
public void setHasScreenshots(boolean hasScreenshots) { this.hasScreenshots = hasScreenshots; }
|
||||
|
||||
public List<String> getScreenshots() { return screenshots; }
|
||||
public void setScreenshots(List<String> screenshots) { this.screenshots = screenshots; }
|
||||
|
||||
public List<String> getVideos() { return videos; }
|
||||
public void setVideos(List<String> videos) { this.videos = videos; }
|
||||
|
||||
public boolean isReady() { return ready; }
|
||||
public void setReady(boolean ready) { this.ready = ready; }
|
||||
|
||||
public Instant getCreatedAt() { return createdAt; }
|
||||
public void setCreatedAt(Instant createdAt) { this.createdAt = createdAt; }
|
||||
|
||||
public Instant getUpdatedAt() { return updatedAt; }
|
||||
public void setUpdatedAt(Instant updatedAt) { this.updatedAt = updatedAt; }
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package org.dostalgia;
|
||||
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.DELETE;
|
||||
import jakarta.ws.rs.DefaultValue;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.PATCH;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.PathParam;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.QueryParam;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import jakarta.ws.rs.core.StreamingOutput;
|
||||
import java.nio.file.NoSuchFileException;
|
||||
import java.nio.file.Files;
|
||||
import java.util.Map;
|
||||
|
||||
@Path("/api/games")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public class GameController {
|
||||
|
||||
@Inject
|
||||
GameService svc;
|
||||
|
||||
@GET
|
||||
public Response list(
|
||||
@QueryParam("search") String search,
|
||||
@QueryParam("genre") String genre,
|
||||
@QueryParam("year") Integer year,
|
||||
@QueryParam("limit") @DefaultValue("50") int limit,
|
||||
@QueryParam("offset") @DefaultValue("0") int offset
|
||||
) {
|
||||
try {
|
||||
var all = svc.list();
|
||||
|
||||
if (search != null && !search.isBlank()) {
|
||||
all = all.stream()
|
||||
.filter(g -> g.getTitle().toLowerCase().contains(search.toLowerCase()))
|
||||
.toList();
|
||||
}
|
||||
if (genre != null && !genre.isBlank()) {
|
||||
all = all.stream()
|
||||
.filter(g -> genre.equalsIgnoreCase(g.getGenre()))
|
||||
.toList();
|
||||
}
|
||||
if (year != null) {
|
||||
all = all.stream()
|
||||
.filter(g -> year.equals(g.getYear()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
int total = all.size();
|
||||
int from = Math.min(offset, total);
|
||||
int to = Math.min(offset + limit, total);
|
||||
var page = all.subList(from, to);
|
||||
|
||||
return Response.ok(Map.of("games", page, "total", total)).build();
|
||||
} catch (Exception e) {
|
||||
return Response.serverError().entity(Map.of("error", e.getMessage())).build();
|
||||
}
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/{id}")
|
||||
public Response get(@PathParam("id") String id) {
|
||||
try {
|
||||
var game = svc.load(id);
|
||||
return Response.ok(game).build();
|
||||
} catch (NoSuchFileException e) {
|
||||
return Response.status(404).entity(Map.of("error", "Game not found")).build();
|
||||
} catch (Exception e) {
|
||||
return Response.serverError().entity(Map.of("error", e.getMessage())).build();
|
||||
}
|
||||
}
|
||||
|
||||
@PATCH
|
||||
@Path("/{id}")
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
public Response update(@PathParam("id") String id, Map<String, Object> updates) {
|
||||
try {
|
||||
var game = svc.load(id);
|
||||
|
||||
if (updates.containsKey("title")) game.setTitle((String) updates.get("title"));
|
||||
if (updates.containsKey("year")) game.setYear(asInt(updates.get("year")));
|
||||
if (updates.containsKey("genre")) game.setGenre((String) updates.get("genre"));
|
||||
if (updates.containsKey("developer")) game.setDeveloper((String) updates.get("developer"));
|
||||
if (updates.containsKey("publisher")) game.setPublisher((String) updates.get("publisher"));
|
||||
if (updates.containsKey("description")) game.setDescription((String) updates.get("description"));
|
||||
if (updates.containsKey("rating")) game.setRating(asDouble(updates.get("rating")));
|
||||
if (updates.containsKey("platform")) game.setPlatform((String) updates.get("platform"));
|
||||
|
||||
// Changing the executable requires patching the .jsdos bundle
|
||||
if (updates.containsKey("executable")) {
|
||||
String newExe = (String) updates.get("executable");
|
||||
if (newExe != null && !newExe.isBlank()) {
|
||||
svc.setExecutable(id, newExe);
|
||||
}
|
||||
// Reload after bundle patch to get updated metadata
|
||||
game = svc.load(id);
|
||||
} else {
|
||||
svc.save(game);
|
||||
}
|
||||
return Response.ok(game).build();
|
||||
} catch (NoSuchFileException e) {
|
||||
return Response.status(404).entity(Map.of("error", "Game not found")).build();
|
||||
} catch (Exception e) {
|
||||
return Response.serverError().entity(Map.of("error", e.getMessage())).build();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream a setup .jsdos bundle on-the-fly. No disk duplication —
|
||||
* reads the main bundle and patches config files to point at the
|
||||
* setup executable (SETUP.EXE, INSTALL.EXE, etc.).
|
||||
*/
|
||||
@GET
|
||||
@Path("/{id}/setup-bundle")
|
||||
@Produces("application/zip")
|
||||
public Response setupBundle(@PathParam("id") String id) {
|
||||
try {
|
||||
var game = svc.load(id);
|
||||
if (game.getSetupExe() == null || game.getSetupExe().isBlank()) {
|
||||
return Response.status(404)
|
||||
.entity(Map.of("error", "No setup executable configured for this game"))
|
||||
.build();
|
||||
}
|
||||
String filename = (game.getTitle() != null ? game.getTitle() : id) + ".setup.jsdos";
|
||||
StreamingOutput stream = output -> svc.streamSetupBundle(id, output);
|
||||
return Response.ok(stream)
|
||||
.header("Content-Disposition", "attachment; filename=\"" + filename + "\"")
|
||||
.build();
|
||||
} catch (NoSuchFileException e) {
|
||||
return Response.status(404).entity(Map.of("error", "Game not found")).build();
|
||||
} catch (Exception e) {
|
||||
return Response.serverError().entity(Map.of("error", e.getMessage())).build();
|
||||
}
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/{id}/download")
|
||||
@Produces("application/zip")
|
||||
public Response download(@PathParam("id") String id) {
|
||||
try {
|
||||
var game = svc.load(id);
|
||||
String filename = (game.getTitle() != null ? game.getTitle() : id) + ".zip";
|
||||
var bundlePath = svc.getGamesDir().resolve(game.getBundleFile());
|
||||
if (!Files.exists(bundlePath)) {
|
||||
return Response.status(404).entity(Map.of("error", "Bundle file not found")).build();
|
||||
}
|
||||
StreamingOutput stream = output -> Files.copy(bundlePath, output);
|
||||
return Response.ok(stream)
|
||||
.header("Content-Disposition", "attachment; filename=\"" + filename + "\"")
|
||||
.build();
|
||||
} catch (NoSuchFileException e) {
|
||||
return Response.status(404).entity(Map.of("error", "Game not found")).build();
|
||||
} catch (Exception e) {
|
||||
return Response.serverError().entity(Map.of("error", e.getMessage())).build();
|
||||
}
|
||||
}
|
||||
|
||||
@DELETE
|
||||
@Path("/{id}")
|
||||
public Response delete(@PathParam("id") String id) {
|
||||
try {
|
||||
svc.delete(id);
|
||||
return Response.ok(Map.of("status", "deleted")).build();
|
||||
} catch (Exception e) {
|
||||
return Response.serverError().entity(Map.of("error", e.getMessage())).build();
|
||||
}
|
||||
}
|
||||
|
||||
private Integer asInt(Object v) {
|
||||
if (v instanceof Number n) return n.intValue();
|
||||
return null;
|
||||
}
|
||||
|
||||
private Double asDouble(Object v) {
|
||||
if (v instanceof Number n) return n.doubleValue();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
package org.dostalgia;
|
||||
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.PropertyNamingStrategies;
|
||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import jakarta.inject.Inject;
|
||||
import org.eclipse.microprofile.config.inject.ConfigProperty;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.NoSuchFileException;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
|
||||
/** Manages game metadata stored as JSON files on disk. */
|
||||
@ApplicationScoped
|
||||
public class GameService implements GameStore {
|
||||
|
||||
private final ObjectMapper mapper = new ObjectMapper()
|
||||
.registerModule(new JavaTimeModule())
|
||||
.setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE)
|
||||
.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
|
||||
.enable(SerializationFeature.INDENT_OUTPUT);
|
||||
|
||||
@Inject
|
||||
ConfigBuilder config;
|
||||
|
||||
@Inject
|
||||
CdImageService cdImageService;
|
||||
|
||||
@ConfigProperty(name = "dostalgia.data.dir", defaultValue = "data")
|
||||
String dataDir;
|
||||
|
||||
Path gamesDir;
|
||||
|
||||
@PostConstruct
|
||||
void init() {
|
||||
gamesDir = Path.of(dataDir, "games");
|
||||
try {
|
||||
Files.createDirectories(gamesDir);
|
||||
Files.createDirectories(Path.of(dataDir, "artwork"));
|
||||
Files.createDirectories(Path.of(dataDir, "saves"));
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException("Cannot create data directories", e);
|
||||
}
|
||||
}
|
||||
|
||||
public Path gameDir(String id) {
|
||||
return gamesDir.resolve(id);
|
||||
}
|
||||
|
||||
public Path gameJsonPath(String id) {
|
||||
return gameDir(id).resolve("game.json");
|
||||
}
|
||||
|
||||
/** Load a game by ID. Throws if not found. */
|
||||
public Game load(String id) throws IOException {
|
||||
Path path = gameJsonPath(id);
|
||||
if (!Files.exists(path)) {
|
||||
throw new NoSuchFileException("game.json not found for: " + id);
|
||||
}
|
||||
Game game = mapper.readValue(path.toFile(), Game.class);
|
||||
// Detect cover file
|
||||
game.setHasCover(
|
||||
Files.exists(gameDir(id).resolve("cover.jpg")) ||
|
||||
Files.exists(gameDir(id).resolve("cover.png")) ||
|
||||
Files.exists(gameDir(id).resolve("cover.webp"))
|
||||
);
|
||||
// Detect bundle size
|
||||
try {
|
||||
String bf = game.getBundleFile();
|
||||
if (bf != null) {
|
||||
Path bundlePath = gamesDir.resolve(bf);
|
||||
if (Files.exists(bundlePath)) {
|
||||
game.setBundleSize(Files.size(bundlePath));
|
||||
}
|
||||
}
|
||||
} catch (IOException ignored) {}
|
||||
return game;
|
||||
}
|
||||
|
||||
/** Save game metadata to disk. */
|
||||
public void save(Game game) throws IOException {
|
||||
game.setUpdatedAt(Instant.now());
|
||||
if (game.getCreatedAt() == null) {
|
||||
game.setCreatedAt(Instant.now());
|
||||
}
|
||||
Files.createDirectories(gameDir(game.getId()));
|
||||
mapper.writeValue(gameJsonPath(game.getId()).toFile(), game);
|
||||
}
|
||||
|
||||
/** List all games, sorted by title. */
|
||||
public List<Game> list() throws IOException {
|
||||
List<Game> games = new ArrayList<>();
|
||||
if (!Files.isDirectory(gamesDir)) return games;
|
||||
|
||||
try (var stream = Files.list(gamesDir)) {
|
||||
for (Path dir : (Iterable<Path>) stream::iterator) {
|
||||
if (!Files.isDirectory(dir)) continue;
|
||||
try {
|
||||
Game g = load(dir.getFileName().toString());
|
||||
games.add(g);
|
||||
} catch (Exception ignored) {
|
||||
// skip broken entries
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
games.sort(Comparator.comparing(Game::getTitle));
|
||||
return games;
|
||||
}
|
||||
|
||||
/** Delete a game and all its files. */
|
||||
public void delete(String id) throws IOException {
|
||||
FileUtils.deleteDirectory(gameDir(id));
|
||||
// Clean up old flat .jsdos locations (pre-game-dir layout — backward compat)
|
||||
Files.deleteIfExists(gamesDir.resolve(id + ".jsdos"));
|
||||
Files.deleteIfExists(gamesDir.resolve(id + ".setup.jsdos"));
|
||||
}
|
||||
|
||||
/** Sanitize a string for use as a game directory ID. */
|
||||
public static String sanitizeId(String s) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (char c : s.toCharArray()) {
|
||||
if (c >= 'a' && c <= 'z') sb.append(c);
|
||||
else if (c >= 'A' && c <= 'Z') sb.append((char)(c + 32));
|
||||
else if (c >= '0' && c <= '9') sb.append(c);
|
||||
else if (c == ' ' || c == '-' || c == '_') sb.append('-');
|
||||
}
|
||||
String result = sb.toString().replaceAll("^-+|-+$", "");
|
||||
return result.isEmpty() ? "unknown" : result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Patch the .jsdos bundle to use a different main executable.
|
||||
* Transactional: patches into a temp file, then atomically replaces on success.
|
||||
*/
|
||||
public void setExecutable(String id, String executable) throws IOException {
|
||||
Game game = load(id);
|
||||
String bundleFile = game.getBundleFile();
|
||||
if (bundleFile == null) {
|
||||
throw new IOException("Game has no bundle file");
|
||||
}
|
||||
|
||||
Path bundlePath = gamesDir.resolve(bundleFile);
|
||||
if (!Files.exists(bundlePath)) {
|
||||
throw new NoSuchFileException("Bundle not found: " + bundleFile);
|
||||
}
|
||||
|
||||
// Build new config entries — preserve any CD images in the bundle
|
||||
List<String> cdImages = cdImageService.findInBundle(bundlePath);
|
||||
byte[] newDosboxConf = config.buildDosboxConfBytes(executable, cdImages);
|
||||
byte[] newJsdosJson = config.buildJsdosJson(executable, cdImages);
|
||||
|
||||
// Transactional patch: write to temp, detect platform, then move
|
||||
Path tmpPath = bundlePath.resolveSibling(bundlePath.getFileName() + ".tmp");
|
||||
try {
|
||||
try (var zis = new java.util.zip.ZipInputStream(Files.newInputStream(bundlePath));
|
||||
var zos = new java.util.zip.ZipOutputStream(Files.newOutputStream(tmpPath))) {
|
||||
|
||||
java.util.zip.ZipEntry entry;
|
||||
while ((entry = zis.getNextEntry()) != null) {
|
||||
String name = entry.getName();
|
||||
if (".jsdos/dosbox.conf".equals(name)) {
|
||||
zos.putNextEntry(new java.util.zip.ZipEntry(name));
|
||||
zos.write(newDosboxConf);
|
||||
zos.closeEntry();
|
||||
} else if (".jsdos/jsdos.json".equals(name)) {
|
||||
zos.putNextEntry(new java.util.zip.ZipEntry(name));
|
||||
zos.write(newJsdosJson);
|
||||
zos.closeEntry();
|
||||
} else {
|
||||
zos.putNextEntry(new java.util.zip.ZipEntry(name));
|
||||
zis.transferTo(zos);
|
||||
zos.closeEntry();
|
||||
}
|
||||
zis.closeEntry();
|
||||
}
|
||||
}
|
||||
|
||||
// Detect platform from the new bundle before committing
|
||||
String detectedPlatform = detectPlatformInBundle(executable, tmpPath);
|
||||
|
||||
// Atomic move on success
|
||||
Files.move(tmpPath, bundlePath, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
|
||||
|
||||
// Update metadata
|
||||
game.setExecutable(executable);
|
||||
game.setPlatform(detectedPlatform);
|
||||
save(game);
|
||||
|
||||
} catch (Exception e) {
|
||||
// Clean up temp file on failure — leave original untouched
|
||||
try { Files.deleteIfExists(tmpPath); } catch (IOException ignored) {}
|
||||
throw e instanceof IOException ioe ? ioe : new IOException(e);
|
||||
}
|
||||
}
|
||||
|
||||
/** Detect platform for an executable inside a .jsdos ZIP bundle. */
|
||||
private String detectPlatformInBundle(String relExe, Path bundlePath) throws IOException {
|
||||
try (var zis = new java.util.zip.ZipInputStream(Files.newInputStream(bundlePath))) {
|
||||
java.util.zip.ZipEntry entry;
|
||||
while ((entry = zis.getNextEntry()) != null) {
|
||||
if (entry.getName().equals(relExe.replace('\\', '/'))) {
|
||||
return PlatformDetector.detectFromBytes(PlatformDetector.readHeaderBytes(zis));
|
||||
}
|
||||
zis.closeEntry();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream a setup .jsdos bundle on-the-fly by reading the main bundle
|
||||
* and replacing config files to point at the setup executable.
|
||||
* No data is written to disk — the modified ZIP is streamed directly.
|
||||
*/
|
||||
public void streamSetupBundle(String id, OutputStream out) throws IOException {
|
||||
Game game = load(id);
|
||||
String setupExe = game.getSetupExe();
|
||||
if (setupExe == null || setupExe.isBlank()) {
|
||||
throw new IOException("No setup executable configured for this game");
|
||||
}
|
||||
|
||||
Path bundlePath = gamesDir.resolve(game.getBundleFile());
|
||||
if (!Files.exists(bundlePath)) {
|
||||
throw new NoSuchFileException("Bundle not found: " + game.getBundleFile());
|
||||
}
|
||||
|
||||
// Build new config entries — preserve any CD images in the bundle
|
||||
List<String> cdImages = cdImageService.findInBundle(bundlePath);
|
||||
byte[] newDosboxConf = config.buildDosboxConfBytes(setupExe, cdImages);
|
||||
byte[] newJsdosJson = config.buildJsdosJson(setupExe, cdImages);
|
||||
|
||||
try (var zis = new java.util.zip.ZipInputStream(Files.newInputStream(bundlePath));
|
||||
var zos = new java.util.zip.ZipOutputStream(out)) {
|
||||
|
||||
java.util.zip.ZipEntry entry;
|
||||
while ((entry = zis.getNextEntry()) != null) {
|
||||
String name = entry.getName();
|
||||
zos.putNextEntry(new java.util.zip.ZipEntry(name));
|
||||
if (".jsdos/dosbox.conf".equals(name)) {
|
||||
zos.write(newDosboxConf);
|
||||
} else if (".jsdos/jsdos.json".equals(name)) {
|
||||
zos.write(newJsdosJson);
|
||||
} else {
|
||||
zis.transferTo(zos);
|
||||
}
|
||||
zos.closeEntry();
|
||||
zis.closeEntry();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public Path getGamesDir() { return gamesDir; }
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package org.dostalgia;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Contract for game metadata persistence.
|
||||
* Decouples consumers (IgdbService, GameController) from the concrete storage implementation.
|
||||
*/
|
||||
public interface GameStore {
|
||||
Game load(String id) throws IOException;
|
||||
void save(Game game) throws IOException;
|
||||
List<Game> list() throws IOException;
|
||||
void delete(String id) throws IOException;
|
||||
Path gameDir(String id);
|
||||
Path gameJsonPath(String id);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package org.dostalgia;
|
||||
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.DefaultValue;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.POST;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.PathParam;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.QueryParam;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import java.util.Map;
|
||||
|
||||
/** IGDB metadata & artwork integration. Enabled when TWITCH_CLIENT_ID and TWITCH_CLIENT_SECRET are set. */
|
||||
@Path("/api/igdb")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public class IgdbController {
|
||||
|
||||
@Inject
|
||||
IgdbService svc;
|
||||
|
||||
@Inject
|
||||
GameService games;
|
||||
|
||||
@GET
|
||||
@Path("/search")
|
||||
public Response search(@QueryParam("q") @DefaultValue("") String query) {
|
||||
if (query.isBlank()) {
|
||||
return Response.status(400).entity(Map.of("error", "Query parameter 'q' is required")).build();
|
||||
}
|
||||
try {
|
||||
var results = svc.search(query);
|
||||
return Response.ok(Map.of("results", results)).build();
|
||||
} catch (Exception e) {
|
||||
return Response.serverError()
|
||||
.entity(Map.of("error", "IGDB search failed: " + e.getMessage()))
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply IGDB metadata + cover to a game.
|
||||
* Body: {"igdb_id": 12345} — applies data from that specific IGDB game entry.
|
||||
* Body: {} — auto-search using the game's title.
|
||||
*/
|
||||
@POST
|
||||
@Path("/scrape/{gameId}")
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
public Response scrape(@PathParam("gameId") String gameId, Map<String, Object> body) {
|
||||
try {
|
||||
var game = games.load(gameId);
|
||||
|
||||
if (body != null && body.containsKey("igdb_id")) {
|
||||
int igdbId = ((Number) body.get("igdb_id")).intValue();
|
||||
svc.applyIgdbId(game, igdbId);
|
||||
} else {
|
||||
svc.autoScrape(game);
|
||||
}
|
||||
|
||||
games.save(game);
|
||||
return Response.ok(game).build();
|
||||
|
||||
} catch (java.nio.file.NoSuchFileException e) {
|
||||
return Response.status(404).entity(Map.of("error", "Game not found")).build();
|
||||
} catch (Exception e) {
|
||||
return Response.serverError()
|
||||
.entity(Map.of("error", "IGDB scrape failed: " + e.getMessage()))
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
/** Check if IGDB credentials are configured. */
|
||||
@GET
|
||||
@Path("/status")
|
||||
public Response status() {
|
||||
boolean configured = svc.isConfigured();
|
||||
return Response.ok(Map.of(
|
||||
"configured", configured,
|
||||
"message", configured ? "IGDB ready" : "Set TWITCH_CLIENT_ID and TWITCH_CLIENT_SECRET env vars"
|
||||
)).build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
package org.dostalgia;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import jakarta.inject.Inject;
|
||||
import org.eclipse.microprofile.config.inject.ConfigProperty;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Calendar;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
/** Client for the IGDB (Internet Game Database) API via Twitch OAuth2. */
|
||||
@ApplicationScoped
|
||||
public class IgdbService {
|
||||
|
||||
private static final Logger LOG = Logger.getLogger(IgdbService.class.getName());
|
||||
private static final String TWITCH_AUTH = "https://id.twitch.tv/oauth2/token";
|
||||
private static final String IGDB_API = "https://api.igdb.com/v4";
|
||||
private static final String IMG_BASE = "https://images.igdb.com/igdb/image/upload";
|
||||
private static final String COVER_SIZE = "t_cover_big";
|
||||
|
||||
private final HttpClient http = HttpClient.newHttpClient();
|
||||
private final ObjectMapper mapper = new ObjectMapper();
|
||||
|
||||
@ConfigProperty(name = "TWITCH_CLIENT_ID")
|
||||
Optional<String> clientId;
|
||||
|
||||
@ConfigProperty(name = "TWITCH_CLIENT_SECRET")
|
||||
Optional<String> clientSecret;
|
||||
|
||||
@Inject
|
||||
GameStore gameStore;
|
||||
|
||||
private String accessToken;
|
||||
private Instant tokenExpiry;
|
||||
|
||||
public boolean isConfigured() {
|
||||
return clientId.isPresent() && clientSecret.isPresent()
|
||||
&& !clientId.get().isBlank() && !clientSecret.get().isBlank();
|
||||
}
|
||||
|
||||
private synchronized String getToken() throws Exception {
|
||||
if (accessToken != null && tokenExpiry != null && Instant.now().isBefore(tokenExpiry)) {
|
||||
return accessToken;
|
||||
}
|
||||
String cid = clientId.orElseThrow(() -> new RuntimeException("TWITCH_CLIENT_ID not configured"));
|
||||
String secret = clientSecret.orElseThrow(() -> new RuntimeException("TWITCH_CLIENT_SECRET not configured"));
|
||||
String body = "client_id=" + cid
|
||||
+ "&client_secret=" + secret
|
||||
+ "&grant_type=client_credentials";
|
||||
|
||||
HttpRequest req = HttpRequest.newBuilder()
|
||||
.uri(URI.create(TWITCH_AUTH))
|
||||
.header("Content-Type", "application/x-www-form-urlencoded")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(body))
|
||||
.build();
|
||||
|
||||
HttpResponse<String> res = http.send(req, HttpResponse.BodyHandlers.ofString());
|
||||
if (res.statusCode() != 200) {
|
||||
throw new RuntimeException("Twitch OAuth2 failed: HTTP " + res.statusCode() + " " + res.body());
|
||||
}
|
||||
|
||||
JsonNode json = mapper.readTree(res.body());
|
||||
accessToken = json.get("access_token").asText();
|
||||
int expiresIn = json.get("expires_in").asInt();
|
||||
tokenExpiry = Instant.now().plusSeconds(expiresIn - 120);
|
||||
LOG.info("IGDB: acquired new Twitch token (expires in " + expiresIn + "s)");
|
||||
return accessToken;
|
||||
}
|
||||
|
||||
private HttpRequest.Builder igdbRequest(String path) throws Exception {
|
||||
String cid = clientId.orElseThrow(() -> new RuntimeException("TWITCH_CLIENT_ID not configured"));
|
||||
return HttpRequest.newBuilder()
|
||||
.uri(URI.create(IGDB_API + path))
|
||||
.header("Client-ID", cid)
|
||||
.header("Authorization", "Bearer " + getToken())
|
||||
.header("Content-Type", "text/plain");
|
||||
}
|
||||
|
||||
/** POST an APQL query to an IGDB endpoint and return the JSON array, or null on failure. */
|
||||
private JsonNode postAndGet(String endpoint, String apql) throws Exception {
|
||||
HttpRequest req = igdbRequest(endpoint)
|
||||
.POST(HttpRequest.BodyPublishers.ofString(apql))
|
||||
.build();
|
||||
HttpResponse<String> res = http.send(req, HttpResponse.BodyHandlers.ofString());
|
||||
if (res.statusCode() != 200) return null;
|
||||
JsonNode results = mapper.readTree(res.body());
|
||||
return results.isArray() ? results : null;
|
||||
}
|
||||
|
||||
public List<Map<String, Object>> search(String query) throws Exception {
|
||||
if (!isConfigured()) return List.of();
|
||||
|
||||
String apql = "search \"" + sanitize(query) + "\";"
|
||||
+ " fields name,first_release_date,genres.name,"
|
||||
+ " involved_companies.company.name,involved_companies.developer,involved_companies.publisher,"
|
||||
+ " summary,cover.url,platforms;"
|
||||
+ " where platforms = [13];"
|
||||
+ " limit 20;";
|
||||
|
||||
JsonNode results = postAndGet("/games", apql);
|
||||
if (results == null || results.isEmpty()) {
|
||||
LOG.info("IGDB search: no results for '" + query + "'");
|
||||
return List.of();
|
||||
}
|
||||
|
||||
List<Map<String, Object>> out = new ArrayList<>();
|
||||
for (JsonNode game : results) {
|
||||
Map<String, Object> entry = new LinkedHashMap<>();
|
||||
entry.put("igdb_id", game.get("id").asInt());
|
||||
entry.put("name", game.has("name") ? game.get("name").asText() : query);
|
||||
|
||||
epochToYear(game).ifPresent(y -> entry.put("year", y));
|
||||
|
||||
List<String> genres = extractGenres(game);
|
||||
if (!genres.isEmpty()) {
|
||||
entry.put("genres", genres);
|
||||
}
|
||||
|
||||
if (game.has("involved_companies") && game.get("involved_companies").isArray()) {
|
||||
String developer = null;
|
||||
String publisher = null;
|
||||
for (JsonNode ic : game.get("involved_companies")) {
|
||||
if (ic.has("company") && ic.get("company").has("name")) {
|
||||
String companyName = ic.get("company").get("name").asText();
|
||||
boolean isDev = ic.has("developer") && ic.get("developer").asBoolean();
|
||||
boolean isPub = ic.has("publisher") && ic.get("publisher").asBoolean();
|
||||
if (isDev) developer = companyName;
|
||||
if (isPub) publisher = companyName;
|
||||
}
|
||||
}
|
||||
if (developer != null) entry.put("developer", developer);
|
||||
if (publisher != null) entry.put("publisher", publisher);
|
||||
}
|
||||
|
||||
if (game.has("summary")) {
|
||||
entry.put("summary", game.get("summary").asText());
|
||||
}
|
||||
|
||||
if (game.has("platforms") && game.get("platforms").isArray()) {
|
||||
List<Integer> platformIds = new ArrayList<>();
|
||||
for (JsonNode p : game.get("platforms")) {
|
||||
platformIds.add(p.asInt());
|
||||
}
|
||||
entry.put("platform_ids", platformIds);
|
||||
entry.put("is_dos", platformIds.contains(13));
|
||||
}
|
||||
|
||||
if (game.has("cover") && game.get("cover").has("url")) {
|
||||
String thumbUrl = game.get("cover").get("url").asText();
|
||||
String coverUrl = "https:" + thumbUrl.replace("t_thumb", COVER_SIZE);
|
||||
entry.put("cover_url", coverUrl);
|
||||
} else if (game.has("cover") && game.get("cover").isInt()) {
|
||||
int coverId = game.get("cover").asInt();
|
||||
String coverUrl = fetchCoverUrl(coverId);
|
||||
if (coverUrl != null) entry.put("cover_url", coverUrl);
|
||||
}
|
||||
|
||||
out.add(entry);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private String fetchCoverUrl(int coverId) throws Exception {
|
||||
JsonNode results = postAndGet("/covers", "fields url; where id = " + coverId + ";");
|
||||
if (results == null || results.isEmpty()) return null;
|
||||
String thumbUrl = results.get(0).get("url").asText();
|
||||
return IMG_BASE + "/" + COVER_SIZE + "/" + thumbUrl.substring(thumbUrl.lastIndexOf('/') + 1);
|
||||
}
|
||||
|
||||
private List<String> fetchVideos(int igdbId) throws Exception {
|
||||
JsonNode results = postAndGet("/game_videos", "fields video_id; where game = " + igdbId + ";");
|
||||
if (results == null) return List.of();
|
||||
List<String> videos = new ArrayList<>();
|
||||
for (JsonNode v : results) {
|
||||
if (v.has("video_id")) videos.add(v.get("video_id").asText());
|
||||
}
|
||||
return videos;
|
||||
}
|
||||
|
||||
private List<String> fetchScreenshots(int igdbId) throws Exception {
|
||||
JsonNode results = postAndGet("/screenshots", "fields url; where game = " + igdbId + ";");
|
||||
if (results == null) return List.of();
|
||||
List<String> screenshots = new ArrayList<>();
|
||||
for (JsonNode s : results) {
|
||||
if (s.has("url")) {
|
||||
String fullUrl = "https:" + s.get("url").asText().replace("t_thumb", "t_1080p");
|
||||
screenshots.add(fullUrl);
|
||||
}
|
||||
}
|
||||
return screenshots;
|
||||
}
|
||||
|
||||
public void autoScrape(Game game) {
|
||||
if (!isConfigured()) return;
|
||||
try {
|
||||
List<Map<String, Object>> results = search(game.getTitle());
|
||||
if (results.isEmpty()) {
|
||||
LOG.info("IGDB auto-scrape: no results for '" + game.getTitle() + "'");
|
||||
return;
|
||||
}
|
||||
Map<String, Object> best = null;
|
||||
for (Map<String, Object> r : results) {
|
||||
if (Boolean.TRUE.equals(r.get("is_dos"))) { best = r; break; }
|
||||
}
|
||||
if (best == null) best = results.getFirst();
|
||||
applyMatch(game, best);
|
||||
LOG.info("IGDB auto-scrape: applied '" + best.get("name") + "' to '" + game.getTitle() + "'");
|
||||
Object igdbIdObj = best.get("igdb_id");
|
||||
if (igdbIdObj instanceof Number igdbIdNum) {
|
||||
int igdbId = igdbIdNum.intValue();
|
||||
try { List<String> v = fetchVideos(igdbId); if (!v.isEmpty()) game.setVideos(v); }
|
||||
catch (Exception e) { LOG.warning("IGDB: failed to fetch videos: " + e.getMessage()); }
|
||||
try { List<String> s = fetchScreenshots(igdbId); if (!s.isEmpty()) { game.setScreenshots(s); game.setHasScreenshots(true); } }
|
||||
catch (Exception e) { LOG.warning("IGDB: failed to fetch screenshots: " + e.getMessage()); }
|
||||
}
|
||||
} catch (Exception e) {
|
||||
LOG.warning("IGDB auto-scrape failed for '" + game.getTitle() + "': " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public void applyIgdbId(Game game, int igdbId) throws Exception {
|
||||
String apql = "fields name,first_release_date,genres.name,"
|
||||
+ " involved_companies.company.name,involved_companies.developer,involved_companies.publisher,"
|
||||
+ " summary,cover.url;"
|
||||
+ " where id = " + igdbId + ";";
|
||||
JsonNode results = postAndGet("/games", apql);
|
||||
if (results == null || results.isEmpty()) {
|
||||
throw new RuntimeException("IGDB: no game found with ID " + igdbId);
|
||||
}
|
||||
applyMatch(game, results.get(0));
|
||||
try { List<String> v = fetchVideos(igdbId); if (!v.isEmpty()) game.setVideos(v); }
|
||||
catch (Exception e) { LOG.warning("IGDB: failed to fetch videos for game " + igdbId + ": " + e.getMessage()); }
|
||||
try { List<String> s = fetchScreenshots(igdbId); if (!s.isEmpty()) { game.setScreenshots(s); game.setHasScreenshots(true); } }
|
||||
catch (Exception e) { LOG.warning("IGDB: failed to fetch screenshots for game " + igdbId + ": " + e.getMessage()); }
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private void applyMatch(Game game, Object matchData) throws Exception {
|
||||
Map<String, Object> data;
|
||||
if (matchData instanceof Map) {
|
||||
data = (Map<String, Object>) matchData;
|
||||
} else if (matchData instanceof JsonNode node) {
|
||||
data = jsonNodeToMap(node);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
if (data.containsKey("name") && (game.getTitle() == null || game.getTitle().isBlank()))
|
||||
game.setTitle((String) data.get("name"));
|
||||
if (data.containsKey("year")) game.setYear((Integer) data.get("year"));
|
||||
if (data.containsKey("developer")) game.setDeveloper((String) data.get("developer"));
|
||||
if (data.containsKey("publisher")) game.setPublisher((String) data.get("publisher"));
|
||||
if (data.containsKey("summary")) game.setDescription((String) data.get("summary"));
|
||||
if (data.containsKey("genres")) {
|
||||
List<String> genres = (List<String>) data.get("genres");
|
||||
if (!genres.isEmpty()) game.setGenre(genres.getFirst());
|
||||
}
|
||||
if (data.containsKey("igdb_id")) game.setIgdbId((Integer) data.get("igdb_id"));
|
||||
String coverUrl = (String) data.get("cover_url");
|
||||
if (coverUrl != null && !coverUrl.isBlank()) downloadCover(game, coverUrl);
|
||||
}
|
||||
|
||||
private void downloadCover(Game game, String coverUrl) throws Exception {
|
||||
String url = coverUrl.startsWith("http") ? coverUrl : "https:" + coverUrl;
|
||||
HttpRequest req = HttpRequest.newBuilder().uri(URI.create(url)).GET().build();
|
||||
HttpResponse<Path> res = http.send(req, HttpResponse.BodyHandlers.ofFile(
|
||||
gameStore.gameDir(game.getId()).resolve("cover.jpg")));
|
||||
if (res.statusCode() == 200) {
|
||||
game.setHasCover(true);
|
||||
LOG.info("IGDB: cover downloaded for '" + game.getTitle() + "'");
|
||||
} else {
|
||||
LOG.warning("IGDB: cover download failed with HTTP " + res.statusCode());
|
||||
}
|
||||
}
|
||||
|
||||
/** Convert IGDB epoch (seconds) to year, from 'first_release_date' field. */
|
||||
private static Optional<Integer> epochToYear(JsonNode node) {
|
||||
if (node.has("first_release_date")) {
|
||||
long epoch = node.get("first_release_date").asLong();
|
||||
Calendar cal = Calendar.getInstance();
|
||||
cal.setTimeInMillis(epoch * 1000);
|
||||
return Optional.of(cal.get(Calendar.YEAR));
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
/** Extract genre names from a JsonNode that may have a 'genres' array. */
|
||||
private static List<String> extractGenres(JsonNode node) {
|
||||
List<String> genres = new ArrayList<>();
|
||||
if (node.has("genres") && node.get("genres").isArray()) {
|
||||
for (JsonNode g : node.get("genres")) {
|
||||
if (g.has("name")) genres.add(g.get("name").asText());
|
||||
}
|
||||
}
|
||||
return genres;
|
||||
}
|
||||
|
||||
private String sanitize(String s) {
|
||||
return s.replace("\\", "\\\\").replace("\"", "\\\"");
|
||||
}
|
||||
|
||||
private Map<String, Object> jsonNodeToMap(JsonNode node) {
|
||||
Map<String, Object> map = new LinkedHashMap<>();
|
||||
map.put("igdb_id", node.has("id") ? node.get("id").asInt() : 0);
|
||||
map.put("name", node.has("name") ? node.get("name").asText() : "");
|
||||
epochToYear(node).ifPresent(y -> map.put("year", y));
|
||||
|
||||
List<String> genres = extractGenres(node);
|
||||
if (!genres.isEmpty()) {
|
||||
map.put("genres", genres);
|
||||
}
|
||||
if (node.has("involved_companies") && node.get("involved_companies").isArray()) {
|
||||
for (JsonNode ic : node.get("involved_companies")) {
|
||||
if (ic.has("company") && ic.get("company").has("name")) {
|
||||
String name = ic.get("company").get("name").asText();
|
||||
boolean isDev = ic.has("developer") && ic.get("developer").asBoolean();
|
||||
boolean isPub = ic.has("publisher") && ic.get("publisher").asBoolean();
|
||||
if (isDev && !map.containsKey("developer")) map.put("developer", name);
|
||||
if (isPub && !map.containsKey("publisher")) map.put("publisher", name);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (node.has("summary")) map.put("summary", node.get("summary").asText());
|
||||
if (node.has("cover")) {
|
||||
JsonNode cover = node.get("cover");
|
||||
try {
|
||||
if (cover.has("url")) {
|
||||
String coverUrl = "https:" + cover.get("url").asText().replace("t_thumb", COVER_SIZE);
|
||||
map.put("cover_url", coverUrl);
|
||||
} else if (cover.isInt() || cover.isLong()) {
|
||||
String coverUrl = fetchCoverUrl(cover.asInt());
|
||||
if (coverUrl != null) map.put("cover_url", coverUrl);
|
||||
} else if (cover.has("id")) {
|
||||
String coverUrl = fetchCoverUrl(cover.get("id").asInt());
|
||||
if (coverUrl != null) map.put("cover_url", coverUrl);
|
||||
}
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package org.dostalgia;
|
||||
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
/**
|
||||
* Detects platform type (DOS vs Windows) by reading executable headers.
|
||||
*/
|
||||
@ApplicationScoped
|
||||
public class PlatformDetector {
|
||||
|
||||
private static final Logger LOG = Logger.getLogger(PlatformDetector.class.getName());
|
||||
|
||||
public String detect(Path exePath) {
|
||||
String name = exePath.getFileName().toString().toLowerCase();
|
||||
if (name.endsWith(".com") || name.endsWith(".bat")) return "dos";
|
||||
|
||||
try (var fis = Files.newInputStream(exePath)) {
|
||||
return detectFromBytes(readHeaderBytes(fis));
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Could not read EXE header for " + exePath + ": " + e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Read enough PE header bytes from any InputStream (shared by Path and ZIP callers). */
|
||||
static byte[] readHeaderBytes(InputStream is) throws IOException {
|
||||
byte[] buf = new byte[0x1000];
|
||||
int read = is.readNBytes(buf, 0, buf.length);
|
||||
return read < buf.length ? java.util.Arrays.copyOf(buf, read) : buf;
|
||||
}
|
||||
|
||||
/** Detect platform from pre-read MZ/PE header bytes. */
|
||||
static String detectFromBytes(byte[] buf) {
|
||||
return detectFromBytes(buf, buf.length);
|
||||
}
|
||||
|
||||
/** Detect platform from pre-read MZ/PE header bytes with explicit read length. */
|
||||
static String detectFromBytes(byte[] buf, int read) {
|
||||
if (read < 2) return null;
|
||||
if (buf[0] != 0x4D || buf[1] != 0x5A) return null;
|
||||
if (read < 0x40) return "dos";
|
||||
|
||||
int peOffset = (buf[0x3C] & 0xFF)
|
||||
| ((buf[0x3D] & 0xFF) << 8)
|
||||
| ((buf[0x3E] & 0xFF) << 16)
|
||||
| ((buf[0x3F] & 0xFF) << 24);
|
||||
|
||||
if (peOffset < 0 || peOffset + 2 > read) return "dos";
|
||||
|
||||
byte sig1 = buf[peOffset];
|
||||
byte sig2 = buf[peOffset + 1];
|
||||
|
||||
if ((sig1 == 0x50 && sig2 == 0x45) // PE
|
||||
|| (sig1 == 0x4E && sig2 == 0x45)) // NE
|
||||
return "windows";
|
||||
|
||||
return "dos";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package org.dostalgia;
|
||||
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.PathParam;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import jakarta.ws.rs.core.StreamingOutput;
|
||||
import org.eclipse.microprofile.config.inject.ConfigProperty;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Map;
|
||||
|
||||
/** Serves game bundles (.jsdos) and artwork from the external data directory. */
|
||||
@jakarta.ws.rs.Path("/")
|
||||
public class StaticResource {
|
||||
|
||||
@ConfigProperty(name = "dostalgia.data.dir", defaultValue = "data")
|
||||
String dataDir;
|
||||
|
||||
/** Serve a file from a subdirectory of the data dir with path traversal protection. */
|
||||
private Response serveFile(Path base, String path, String cacheHeader) {
|
||||
Path file = base.resolve(path).normalize();
|
||||
if (!file.startsWith(base)) {
|
||||
return Response.status(403).build();
|
||||
}
|
||||
if (!Files.exists(file) || Files.isDirectory(file)) {
|
||||
return Response.status(404).build();
|
||||
}
|
||||
String contentType = guessContentType(file.getFileName().toString());
|
||||
var builder = Response.ok(new FileStream(file)).type(contentType);
|
||||
if (cacheHeader != null) {
|
||||
builder.header(cacheHeader.split(":")[0], cacheHeader.split(":", 2)[1].trim());
|
||||
}
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
@GET
|
||||
@jakarta.ws.rs.Path("/games/{path: .*}")
|
||||
public Response getGameFile(@PathParam("path") String path) {
|
||||
return serveFile(Path.of(dataDir, "games"), path, "Accept-Ranges: bytes");
|
||||
}
|
||||
|
||||
@GET
|
||||
@jakarta.ws.rs.Path("/artwork/{path: .*}")
|
||||
public Response getArtwork(@PathParam("path") String path) {
|
||||
return serveFile(Path.of(dataDir, "artwork"), path, "Cache-Control: public, max-age=86400");
|
||||
}
|
||||
|
||||
/** Health check */
|
||||
@GET
|
||||
@jakarta.ws.rs.Path("/api/health")
|
||||
public Response health() {
|
||||
return Response.ok(Map.of("status", "ok", "version", "0.1.0")).build();
|
||||
}
|
||||
|
||||
private String guessContentType(String name) {
|
||||
String n = name.toLowerCase();
|
||||
if (n.endsWith(".jsdos")) return "application/zip";
|
||||
if (n.endsWith(".zip")) return "application/zip";
|
||||
if (n.endsWith(".jpg") || n.endsWith(".jpeg")) return "image/jpeg";
|
||||
if (n.endsWith(".png")) return "image/png";
|
||||
if (n.endsWith(".webp")) return "image/webp";
|
||||
if (n.endsWith(".gif")) return "image/gif";
|
||||
if (n.endsWith(".json")) return "application/json";
|
||||
return MediaType.APPLICATION_OCTET_STREAM;
|
||||
}
|
||||
|
||||
/** Streaming file output that avoids loading the whole file into memory. */
|
||||
private record FileStream(Path file) implements StreamingOutput {
|
||||
@Override
|
||||
public void write(OutputStream output) throws IOException {
|
||||
Files.copy(file, output);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
package org.dostalgia;
|
||||
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.POST;
|
||||
import jakarta.ws.rs.PathParam;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import org.jboss.resteasy.reactive.RestForm;
|
||||
import org.jboss.resteasy.reactive.multipart.FileUpload;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
@jakarta.ws.rs.Path("/api/upload")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public class UploadResource {
|
||||
|
||||
@Inject
|
||||
GameService svc;
|
||||
|
||||
@Inject
|
||||
IgdbService igdb;
|
||||
|
||||
@Inject
|
||||
ZipService zip;
|
||||
|
||||
@Inject
|
||||
ExecutableDetector exeDetector;
|
||||
|
||||
@Inject
|
||||
PlatformDetector platform;
|
||||
|
||||
@Inject
|
||||
CdImageService cdImageService;
|
||||
|
||||
@Inject
|
||||
ConfigPatcher configPatcher;
|
||||
|
||||
private static final Logger LOG = Logger.getLogger(UploadResource.class.getName());
|
||||
|
||||
@POST
|
||||
@Consumes(MediaType.MULTIPART_FORM_DATA)
|
||||
public Response upload(
|
||||
@RestForm("file") FileUpload upload,
|
||||
@RestForm("title") String title,
|
||||
@RestForm("igdb_id") Integer igdbId
|
||||
) throws Exception {
|
||||
if (upload == null) {
|
||||
return Response.status(400).entity(Map.of("error", "No file provided")).build();
|
||||
}
|
||||
|
||||
String filename = upload.fileName();
|
||||
if (title == null || title.isBlank()) {
|
||||
title = filename.endsWith(".zip") ? filename.substring(0, filename.length() - 4) : filename;
|
||||
}
|
||||
|
||||
String gameId = GameService.sanitizeId(title);
|
||||
|
||||
// Check for duplicate by title (case-insensitive)
|
||||
for (var g : svc.list()) {
|
||||
if (g.getTitle() != null && g.getTitle().equalsIgnoreCase(title)) {
|
||||
return Response.status(409)
|
||||
.entity(Map.of("error", "\"" + title + "\" is already in your library"))
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure unique ID
|
||||
String baseId = gameId;
|
||||
int counter = 2;
|
||||
while (Files.exists(svc.gameJsonPath(gameId))) {
|
||||
gameId = baseId + "-" + counter++;
|
||||
}
|
||||
|
||||
// Extract ZIP
|
||||
Path tmpDir = Files.createTempDirectory("dostalgia-");
|
||||
try {
|
||||
Path zipPath = tmpDir.resolve(filename);
|
||||
Files.copy(upload.uploadedFile(), zipPath, StandardCopyOption.REPLACE_EXISTING);
|
||||
|
||||
Path extractDir = tmpDir.resolve("extracted");
|
||||
zip.unzip(zipPath, extractDir);
|
||||
// If the ZIP has a single root directory, flatten it
|
||||
try {
|
||||
zip.flattenSingleDir(extractDir);
|
||||
} catch (Exception e) {
|
||||
LOG.warning("Flatten failed for '" + filename + "': " + e.getMessage()
|
||||
+ " — continuing (cd in autoexec handles subdirs)");
|
||||
}
|
||||
|
||||
// Fix .cue files that reference non-existent data files (e.g. CloneCD)
|
||||
try {
|
||||
cdImageService.fixCueReferences(extractDir);
|
||||
} catch (Exception e) {
|
||||
LOG.warning("Failed to fix cue references: " + e.getMessage());
|
||||
}
|
||||
|
||||
// Patch game config files with hardcoded absolute paths
|
||||
try {
|
||||
configPatcher.fixAbsolutePaths(extractDir);
|
||||
} catch (Exception e) {
|
||||
LOG.warning("Failed to patch config paths: " + e.getMessage());
|
||||
}
|
||||
|
||||
// Find main executable
|
||||
String mainExe = exeDetector.findMain(extractDir);
|
||||
|
||||
// Detect CD images
|
||||
List<String> cdImages = cdImageService.findInDirectory(extractDir);
|
||||
|
||||
if (mainExe == null) {
|
||||
if (cdImages.isEmpty()) {
|
||||
return Response.status(400)
|
||||
.entity(Map.of("error", "No executable (.exe/.com/.bat) found in the archive"))
|
||||
.build();
|
||||
}
|
||||
// CD-only game — proceed without executable
|
||||
}
|
||||
|
||||
// Find setup executable
|
||||
String setupExe = exeDetector.findSetup(extractDir);
|
||||
|
||||
// Create game directory and .jsdos bundle
|
||||
Path gameDir = svc.gameDir(gameId);
|
||||
Files.createDirectories(gameDir);
|
||||
|
||||
String bundleFile = gameId + "/" + gameId + ".jsdos";
|
||||
Path bundlePath = gameDir.resolve(gameId + ".jsdos");
|
||||
zip.createBundle(extractDir, mainExe, cdImages, bundlePath);
|
||||
|
||||
// Detect platform (DOS vs Windows)
|
||||
Path mainExePath = mainExe != null ? Path.of(mainExe) : null;
|
||||
String platformType = mainExe != null ? platform.detect(mainExePath) : "dos";
|
||||
|
||||
// Save metadata
|
||||
Game game = new Game(gameId, title);
|
||||
game.setBundleFile(bundleFile);
|
||||
game.setPlatform(platformType);
|
||||
|
||||
// Store the selected executable and the full list for the UI picker
|
||||
String relMain = mainExe != null
|
||||
? extractDir.relativize(mainExePath).toString().replace('\\', '/')
|
||||
: "";
|
||||
game.setExecutable(relMain);
|
||||
game.setExecutables(exeDetector.findAll(extractDir));
|
||||
if (setupExe != null) {
|
||||
Path setupExePath = Path.of(setupExe);
|
||||
String setupPlatform = platform.detect(setupExePath);
|
||||
if (!"windows".equals(setupPlatform)) {
|
||||
game.setSetupExe(extractDir.relativize(setupExePath).toString());
|
||||
}
|
||||
}
|
||||
game.setReady(true);
|
||||
svc.save(game);
|
||||
|
||||
// Auto-populate from IGDB (use provided igdb_id or auto-search)
|
||||
if (igdb.isConfigured()) {
|
||||
if (igdbId != null) {
|
||||
igdb.applyIgdbId(game, igdbId);
|
||||
} else {
|
||||
igdb.autoScrape(game);
|
||||
}
|
||||
if (game.isHasCover() || game.getYear() != null || game.getGenre() != null
|
||||
|| (game.getVideos() != null && !game.getVideos().isEmpty())
|
||||
|| (game.getScreenshots() != null && !game.getScreenshots().isEmpty())) {
|
||||
svc.save(game);
|
||||
}
|
||||
}
|
||||
|
||||
return Response.status(201).entity(game).build();
|
||||
|
||||
} catch (Exception e) {
|
||||
LOG.severe("Upload failed for '" + filename + "': " + e.getMessage());
|
||||
return Response.serverError()
|
||||
.entity(Map.of("error", "Upload failed: " + e.getMessage()))
|
||||
.build();
|
||||
} finally {
|
||||
deleteDir(tmpDir);
|
||||
}
|
||||
}
|
||||
|
||||
@POST
|
||||
@jakarta.ws.rs.Path("/{id}/cover")
|
||||
@Consumes(MediaType.MULTIPART_FORM_DATA)
|
||||
public Response uploadCover(@PathParam("id") String id, @RestForm("file") FileUpload upload) throws Exception {
|
||||
if (upload == null) {
|
||||
return Response.status(400).entity(Map.of("error", "No file provided")).build();
|
||||
}
|
||||
if (!Files.exists(svc.gameJsonPath(id))) {
|
||||
return Response.status(404).entity(Map.of("error", "Game not found")).build();
|
||||
}
|
||||
|
||||
String fname = upload.fileName().toLowerCase();
|
||||
String ext;
|
||||
if (fname.endsWith(".jpg") || fname.endsWith(".jpeg")) ext = ".jpg";
|
||||
else if (fname.endsWith(".png")) ext = ".png";
|
||||
else if (fname.endsWith(".webp")) ext = ".webp";
|
||||
else {
|
||||
return Response.status(400).entity(Map.of("error", "Only JPG, PNG, or WebP images accepted")).build();
|
||||
}
|
||||
|
||||
Path gameDir = svc.gameDir(id);
|
||||
// Remove old covers
|
||||
for (String old : new String[]{".jpg", ".jpeg", ".png", ".webp"}) {
|
||||
Files.deleteIfExists(gameDir.resolve("cover" + old));
|
||||
}
|
||||
|
||||
Files.copy(upload.uploadedFile(), gameDir.resolve("cover" + ext), StandardCopyOption.REPLACE_EXISTING);
|
||||
return Response.ok(Map.of("status", "ok")).build();
|
||||
}
|
||||
|
||||
private void deleteDir(Path dir) throws IOException {
|
||||
FileUtils.deleteDirectory(dir);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package org.dostalgia;
|
||||
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import jakarta.inject.Inject;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.TreeSet;
|
||||
import java.util.logging.Logger;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipOutputStream;
|
||||
|
||||
/**
|
||||
* ZIP extraction, directory flattening, and .jsdos bundle creation.
|
||||
*/
|
||||
@ApplicationScoped
|
||||
public class ZipService {
|
||||
|
||||
private static final Logger LOG = Logger.getLogger(ZipService.class.getName());
|
||||
|
||||
@Inject
|
||||
ConfigBuilder config;
|
||||
|
||||
private static final Set<String> SKIP_EXT = Set.of(
|
||||
".nrg", ".mdf", ".mds", ".sub", ".dmg"
|
||||
);
|
||||
|
||||
/** Unzip an archive to a destination directory (path traversal safe). */
|
||||
public void unzip(Path zipPath, Path dest) throws IOException {
|
||||
try (var zis = new java.util.zip.ZipInputStream(Files.newInputStream(zipPath))) {
|
||||
java.util.zip.ZipEntry entry;
|
||||
while ((entry = zis.getNextEntry()) != null) {
|
||||
Path target = dest.resolve(entry.getName()).normalize();
|
||||
if (!target.startsWith(dest)) continue;
|
||||
if (entry.isDirectory()) {
|
||||
Files.createDirectories(target);
|
||||
} else {
|
||||
Files.createDirectories(target.getParent());
|
||||
Files.copy(zis, target, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
zis.closeEntry();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* If the extraction directory contains a single subdirectory, move its contents up.
|
||||
*/
|
||||
public void flattenSingleDir(Path dir) throws IOException {
|
||||
Path singleDir = null;
|
||||
try (var files = Files.list(dir)) {
|
||||
for (Path entry : files.toList()) {
|
||||
if (Files.isDirectory(entry)) {
|
||||
if (singleDir != null) return;
|
||||
singleDir = entry;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (singleDir == null) return;
|
||||
|
||||
LOG.info("Flattening root directory: " + singleDir.getFileName());
|
||||
try (var walk = Files.walk(singleDir)) {
|
||||
var list = walk.sorted(Comparator.reverseOrder()).toList();
|
||||
for (Path f : list) {
|
||||
if (f.equals(singleDir)) continue;
|
||||
Path target = dir.resolve(singleDir.relativize(f));
|
||||
if (Files.isDirectory(f)) {
|
||||
Files.createDirectories(target);
|
||||
Files.delete(f);
|
||||
} else {
|
||||
Files.createDirectories(target.getParent());
|
||||
Files.move(f, target, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
}
|
||||
}
|
||||
Files.delete(singleDir);
|
||||
LOG.info("Flattened " + singleDir.getFileName());
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a .jsdos bundle ZIP from the extracted game directory.
|
||||
*/
|
||||
public void createBundle(Path extractDir, String exePath, List<String> cdImages, Path bundlePath) throws IOException {
|
||||
Path jsdos = extractDir.resolve(".jsdos");
|
||||
Files.createDirectories(jsdos);
|
||||
if (exePath != null) {
|
||||
String relExe = extractDir.relativize(Path.of(exePath)).toString();
|
||||
Files.writeString(jsdos.resolve("dosbox.conf"), config.buildDosboxConf(relExe, cdImages));
|
||||
Files.write(jsdos.resolve("jsdos.json"), config.buildJsdosJson(relExe, cdImages));
|
||||
} else {
|
||||
Files.writeString(jsdos.resolve("dosbox.conf"), config.buildCdOnlyDosboxConf(cdImages));
|
||||
Files.write(jsdos.resolve("jsdos.json"), config.buildCdOnlyJsdosJson(cdImages));
|
||||
}
|
||||
|
||||
try (var zos = new ZipOutputStream(Files.newOutputStream(bundlePath))) {
|
||||
|
||||
// Phase 1: Collect all directory paths
|
||||
var allDirs = new TreeSet<String>();
|
||||
try (var walk = Files.walk(extractDir)) {
|
||||
walk.filter(Files::isRegularFile).forEach(f -> {
|
||||
String entryName = extractDir.relativize(f).toString().replace('\\', '/');
|
||||
int idx = entryName.lastIndexOf('/');
|
||||
while (idx >= 0) {
|
||||
allDirs.add(entryName.substring(0, idx + 1));
|
||||
idx = entryName.lastIndexOf('/', idx - 1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Phase 2: Write directory entries first
|
||||
for (String dir : allDirs) {
|
||||
zos.putNextEntry(new ZipEntry(dir));
|
||||
zos.closeEntry();
|
||||
}
|
||||
|
||||
// Phase 3: .jsdos config files (before game files)
|
||||
try (var walk = Files.walk(jsdos)) {
|
||||
walk.filter(Files::isRegularFile).sorted()
|
||||
.forEach(f -> zipEntry(zos, extractDir, f));
|
||||
}
|
||||
|
||||
// Phase 4: Game files (excluding skipped extensions)
|
||||
try (var walk = Files.walk(extractDir)) {
|
||||
walk.filter(Files::isRegularFile)
|
||||
.filter(f -> !f.startsWith(jsdos))
|
||||
.filter(f -> {
|
||||
String n = f.getFileName().toString().toLowerCase();
|
||||
int dot = n.lastIndexOf('.');
|
||||
return dot < 0 || !SKIP_EXT.contains(n.substring(dot));
|
||||
})
|
||||
.sorted()
|
||||
.forEach(f -> zipEntry(zos, extractDir, f));
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up .jsdos config files from the extract dir
|
||||
try (var cleanup = Files.walk(jsdos)) {
|
||||
cleanup.sorted(Comparator.reverseOrder()).forEach(p -> {
|
||||
try { Files.deleteIfExists(p); } catch (IOException ignored) {}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Write a single file into a ZIP output stream, computing entry name relative to extractDir. */
|
||||
private static void zipEntry(ZipOutputStream zos, Path extractDir, Path file) {
|
||||
try {
|
||||
String entryName = extractDir.relativize(file).toString().replace('\\', '/');
|
||||
zos.putNextEntry(new ZipEntry(entryName));
|
||||
Files.copy(file, zos);
|
||||
zos.closeEntry();
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
# ─── Server ──────────────────────────────────────
|
||||
quarkus.http.port=8765
|
||||
quarkus.http.host=0.0.0.0
|
||||
quarkus.http.cors=true
|
||||
|
||||
# Allow large game uploads (DOS games can be 500MB+)
|
||||
quarkus.http.limits.max-body-size=2048M
|
||||
|
||||
# ─── Jackson: snake_case to match frontend expectations ─
|
||||
quarkus.jackson.property-naming-strategy=SNAKE_CASE
|
||||
|
||||
# ─── Static resources (frontend SPA from META-INF/resources/) ──
|
||||
quarkus.http.static-resources.enable=true
|
||||
|
||||
# ─── Data directory (game files, artwork) ────────
|
||||
dostalgia.data.dir=/data
|
||||
@@ -0,0 +1,106 @@
|
||||
package org.dostalgia;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class CdImageServiceTest {
|
||||
|
||||
private final CdImageService service = new CdImageService();
|
||||
|
||||
@Test
|
||||
void findInDirectory_findsAllCdFormats(@TempDir Path dir) throws Exception {
|
||||
Files.writeString(dir.resolve("game.cue"), "FILE \"game.bin\" BINARY");
|
||||
Files.writeString(dir.resolve("game.bin"), "data");
|
||||
Files.writeString(dir.resolve("game.iso"), "iso");
|
||||
Files.writeString(dir.resolve("readme.txt"), "text");
|
||||
|
||||
List<String> result = service.findInDirectory(dir);
|
||||
assertEquals(3, result.size());
|
||||
assertTrue(result.contains("game.bin"));
|
||||
assertTrue(result.contains("game.cue"));
|
||||
assertTrue(result.contains("game.iso"));
|
||||
assertFalse(result.contains("readme.txt"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void findInDirectory_emptyDir(@TempDir Path dir) throws Exception {
|
||||
assertTrue(service.findInDirectory(dir).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void findInDirectory_caseInsensitive(@TempDir Path dir) throws Exception {
|
||||
Files.writeString(dir.resolve("GAME.CUE"), "data");
|
||||
Files.writeString(dir.resolve("GAME.ISO"), "data");
|
||||
List<String> result = service.findInDirectory(dir);
|
||||
assertEquals(2, result.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void fixCueReferences_rewritesNonexistentReference(@TempDir Path dir) throws Exception {
|
||||
// CUE references game.bin, but only game.img exists
|
||||
Files.writeString(dir.resolve("game.cue"), "FILE \"game.bin\" BINARY\n TRACK 01 MODE1/2352\n INDEX 01 00:00:00");
|
||||
Files.writeString(dir.resolve("game.img"), "data");
|
||||
|
||||
service.fixCueReferences(dir);
|
||||
|
||||
String fixed = Files.readString(dir.resolve("game.cue"));
|
||||
assertTrue(fixed.contains("game.img"), "Should reference existing .img file");
|
||||
assertFalse(fixed.contains("game.bin"), "Should no longer reference missing .bin");
|
||||
}
|
||||
|
||||
@Test
|
||||
void fixCueReferences_skipsWhenExists(@TempDir Path dir) throws Exception {
|
||||
// CUE references game.bin — and it exists, so no change
|
||||
String original = "FILE \"game.bin\" BINARY\n TRACK 01 MODE1/2352\n INDEX 01 00:00:00";
|
||||
Files.writeString(dir.resolve("game.cue"), original);
|
||||
Files.writeString(dir.resolve("game.bin"), "data");
|
||||
|
||||
service.fixCueReferences(dir);
|
||||
|
||||
assertEquals(original, Files.readString(dir.resolve("game.cue")).trim());
|
||||
}
|
||||
|
||||
@Test
|
||||
void fixCueReferences_noFileLine_noChange(@TempDir Path dir) throws Exception {
|
||||
String original = "TITLE \"Test Game\"\nTRACK 01 MODE1/2352";
|
||||
Files.writeString(dir.resolve("game.cue"), original);
|
||||
|
||||
service.fixCueReferences(dir);
|
||||
|
||||
assertEquals(original, Files.readString(dir.resolve("game.cue")).trim());
|
||||
}
|
||||
|
||||
@Test
|
||||
void findInBundle_findsCdImages(@TempDir Path dir) throws Exception {
|
||||
// Create a test bundle with CD images and regular files
|
||||
Path bundle = dir.resolve("test.jsdos");
|
||||
try (var zos = new java.util.zip.ZipOutputStream(Files.newOutputStream(bundle))) {
|
||||
zos.putNextEntry(new java.util.zip.ZipEntry("game.cue"));
|
||||
zos.write("cue data".getBytes());
|
||||
zos.closeEntry();
|
||||
zos.putNextEntry(new java.util.zip.ZipEntry("game.iso"));
|
||||
zos.write("iso data".getBytes());
|
||||
zos.closeEntry();
|
||||
zos.putNextEntry(new java.util.zip.ZipEntry("readme.txt"));
|
||||
zos.write("text".getBytes());
|
||||
zos.closeEntry();
|
||||
zos.putNextEntry(new java.util.zip.ZipEntry("subdir/game.img"));
|
||||
zos.write("img".getBytes());
|
||||
zos.closeEntry();
|
||||
}
|
||||
|
||||
List<String> result = service.findInBundle(bundle);
|
||||
assertEquals(3, result.size());
|
||||
assertTrue(result.contains("game.cue"));
|
||||
assertTrue(result.contains("game.iso"));
|
||||
assertTrue(result.contains("subdir/game.img"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package org.dostalgia;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
|
||||
class ConfigBuilderTest {
|
||||
|
||||
private final ConfigBuilder builder = new ConfigBuilder();
|
||||
|
||||
@Test
|
||||
void buildDosboxConf_noCdImages() {
|
||||
String conf = builder.buildDosboxConf("FALLOUT.EXE", List.of());
|
||||
assertTrue(conf.contains("memsize=64"), "Should include memsize");
|
||||
assertTrue(conf.contains("mount c ."), "Should mount C drive");
|
||||
assertTrue(conf.contains("FALLOUT.EXE"), "Should reference executable");
|
||||
assertFalse(conf.contains("imgmount"), "No CD images should produce no imgmount");
|
||||
}
|
||||
|
||||
@Test
|
||||
void buildDosboxConf_withCdImages() {
|
||||
String conf = builder.buildDosboxConf("DOTT.EXE", List.of("game.cue", "game.bin"));
|
||||
assertTrue(conf.contains("imgmount D \"game.bin\" -t cdrom -fs iso"), "Should mount .bin with iso flag");
|
||||
assertFalse(conf.contains("game.cue"), "Should skip .cue when .bin exists");
|
||||
}
|
||||
|
||||
@Test
|
||||
void buildDosboxConf_subdirectory() {
|
||||
String conf = builder.buildDosboxConf("FALLOUT/FALLOUT.EXE", List.of());
|
||||
assertTrue(conf.contains("cd FALLOUT"), "Should cd into subdirectory");
|
||||
assertTrue(conf.contains("path=c:\\"), "Should set path");
|
||||
}
|
||||
|
||||
@Test
|
||||
void buildCdOnlyDosboxConf() {
|
||||
String conf = builder.buildCdOnlyDosboxConf(List.of("game.iso"));
|
||||
assertTrue(conf.contains("runs from the CD-ROM"), "CD-only message");
|
||||
assertTrue(conf.contains("imgmount D"), "Should mount CD");
|
||||
}
|
||||
|
||||
@Test
|
||||
void buildJsdosJson() {
|
||||
byte[] json = builder.buildJsdosJson("GAME.EXE", List.of());
|
||||
String s = new String(json);
|
||||
assertTrue(s.contains("\"script\""), "Should have script key");
|
||||
assertTrue(s.contains("\"autolock\""), "Should have autolock");
|
||||
assertTrue(s.contains("GAME.EXE"), "Should contain executable name");
|
||||
}
|
||||
|
||||
@Test
|
||||
void splitExePath_rootLevel() {
|
||||
var parts = ConfigBuilder.splitExePath("GAME.EXE");
|
||||
assertNull(parts.dir());
|
||||
assertEquals("GAME.EXE", parts.exe());
|
||||
}
|
||||
|
||||
@Test
|
||||
void splitExePath_subdirectory() {
|
||||
var parts = ConfigBuilder.splitExePath("FALLOUT1/FALLOUT.EXE");
|
||||
assertEquals("FALLOUT1", parts.dir());
|
||||
assertEquals("FALLOUT.EXE", parts.exe());
|
||||
}
|
||||
|
||||
@Test
|
||||
void splitExePath_nullOrEmpty() {
|
||||
assertNull(ConfigBuilder.splitExePath(null).dir());
|
||||
assertEquals("", ConfigBuilder.splitExePath("").exe());
|
||||
}
|
||||
|
||||
@Test
|
||||
void escapeJson() {
|
||||
assertEquals("hello", ConfigBuilder.escapeJson("hello"));
|
||||
assertEquals("\\\\n", ConfigBuilder.escapeJson("\\n"));
|
||||
assertEquals("he said \\\"hi\\\"", ConfigBuilder.escapeJson("he said \"hi\""));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package org.dostalgia;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class ConfigPatcherTest {
|
||||
|
||||
private final ConfigPatcher patcher = new ConfigPatcher();
|
||||
|
||||
@Test
|
||||
void fixAbsolutePaths_rewritesStalePrefix(@TempDir Path dir) throws Exception {
|
||||
// Create a file that references C:\FALLOUT1\MASTER.DAT
|
||||
// but after flattening, MASTER.DAT is at the root
|
||||
String content = "master_dat=C:\\FALLOUT1\\MASTER.DAT\ncritter_dat=C:\\FALLOUT1\\CRITTER.DAT\n";
|
||||
Files.writeString(dir.resolve("FALLOUT.CFG"), content, StandardCharsets.ISO_8859_1);
|
||||
|
||||
// The referenced files exist at root
|
||||
Files.writeString(dir.resolve("MASTER.DAT"), "data");
|
||||
Files.writeString(dir.resolve("CRITTER.DAT"), "data");
|
||||
|
||||
patcher.fixAbsolutePaths(dir);
|
||||
|
||||
String fixed = Files.readString(dir.resolve("FALLOUT.CFG"), StandardCharsets.ISO_8859_1);
|
||||
assertTrue(fixed.contains(".\\MASTER.DAT"), "Should rewrite to relative path");
|
||||
assertTrue(fixed.contains(".\\CRITTER.DAT"), "Both files should be rewritten");
|
||||
assertFalse(fixed.contains("FALLOUT1"), "Stale prefix should be removed");
|
||||
}
|
||||
|
||||
@Test
|
||||
void fixAbsolutePaths_leavesNonCDrives(@TempDir Path dir) throws Exception {
|
||||
// D: drive references are CD-ROM — should stay
|
||||
String content = "cd_path=D:\\GAMEDATA\\SOUND.DAT\n";
|
||||
Files.writeString(dir.resolve("config.ini"), content, StandardCharsets.ISO_8859_1);
|
||||
|
||||
patcher.fixAbsolutePaths(dir);
|
||||
|
||||
String fixed = Files.readString(dir.resolve("config.ini"), StandardCharsets.ISO_8859_1);
|
||||
assertEquals(content.trim(), fixed.trim(), "D: references should be preserved");
|
||||
}
|
||||
|
||||
@Test
|
||||
void fixAbsolutePaths_leavesValidPaths(@TempDir Path dir) throws Exception {
|
||||
// Path exists with full structure — should stay as-is
|
||||
Path sub = Files.createDirectory(dir.resolve("VALIDDIR"));
|
||||
Files.writeString(sub.resolve("file.dat"), "data");
|
||||
|
||||
String content = "data=C:\\VALIDDIR\\file.dat\n";
|
||||
Files.writeString(dir.resolve("config.ini"), content, StandardCharsets.ISO_8859_1);
|
||||
|
||||
patcher.fixAbsolutePaths(dir);
|
||||
|
||||
String fixed = Files.readString(dir.resolve("config.ini"), StandardCharsets.ISO_8859_1);
|
||||
assertEquals(content.trim(), fixed.trim(), "Valid full paths should be preserved");
|
||||
}
|
||||
|
||||
@Test
|
||||
void fixAbsolutePaths_skipsNonTextFiles(@TempDir Path dir) throws Exception {
|
||||
// Binary file with null bytes — should not be processed
|
||||
byte[] binary = new byte[200];
|
||||
binary[0] = 'C';
|
||||
binary[1] = ':';
|
||||
binary[2] = '\\';
|
||||
binary[3] = 'D';
|
||||
binary[10] = 0; // null byte makes it "not text"
|
||||
Files.write(dir.resolve("binary.dat"), binary);
|
||||
|
||||
// Should not throw or modify binary files
|
||||
assertDoesNotThrow(() -> patcher.fixAbsolutePaths(dir));
|
||||
|
||||
byte[] result = Files.readAllBytes(dir.resolve("binary.dat"));
|
||||
assertArrayEquals(binary, result, "Binary file should be unchanged");
|
||||
}
|
||||
|
||||
@Test
|
||||
void fixAbsolutePaths_skipsLargeFiles(@TempDir Path dir) throws Exception {
|
||||
// File > 100KB should be skipped
|
||||
byte[] large = new byte[101 * 1024];
|
||||
large[0] = 'C';
|
||||
large[1] = ':';
|
||||
large[2] = '\\';
|
||||
Files.write(dir.resolve("large.cfg"), large);
|
||||
|
||||
// Should not throw
|
||||
assertDoesNotThrow(() -> patcher.fixAbsolutePaths(dir));
|
||||
}
|
||||
|
||||
@Test
|
||||
void fixAbsolutePaths_emptyDir_noError(@TempDir Path dir) {
|
||||
assertDoesNotThrow(() -> patcher.fixAbsolutePaths(dir));
|
||||
}
|
||||
|
||||
@Test
|
||||
void fixAbsolutePaths_unixStylePath(@TempDir Path dir) throws Exception {
|
||||
// Some games use forward slashes: C:/GAME/DATA
|
||||
String content = "path=C:/MYGAME/DATA.DAT\n";
|
||||
Files.writeString(dir.resolve("config.cfg"), content, StandardCharsets.ISO_8859_1);
|
||||
Files.writeString(dir.resolve("DATA.DAT"), "data");
|
||||
|
||||
patcher.fixAbsolutePaths(dir);
|
||||
|
||||
String fixed = Files.readString(dir.resolve("config.cfg"), StandardCharsets.ISO_8859_1);
|
||||
assertTrue(fixed.contains("./DATA.DAT"), "Forward-slash paths should be fixed");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
package org.dostalgia;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class ExecutableDetectorTest {
|
||||
|
||||
private final PlatformDetector platform = new PlatformDetector();
|
||||
private final ExecutableDetector detector = new ExecutableDetector();
|
||||
|
||||
// Manually wire dependencies since we're not in a CDI container
|
||||
{
|
||||
detector.platform = platform;
|
||||
}
|
||||
|
||||
@Test
|
||||
void findAll_findsExeComBat(@TempDir Path dir) throws Exception {
|
||||
Files.writeString(dir.resolve("GAME.EXE"), "MZ");
|
||||
Files.writeString(dir.resolve("helper.com"), "MZ");
|
||||
Files.writeString(dir.resolve("run.bat"), "@echo off");
|
||||
Files.writeString(dir.resolve("readme.txt"), "text"); // should NOT be found
|
||||
|
||||
List<String> result = detector.findAll(dir);
|
||||
assertEquals(3, result.size());
|
||||
assertTrue(result.contains("GAME.EXE"));
|
||||
assertTrue(result.contains("helper.com"));
|
||||
assertTrue(result.contains("run.bat"));
|
||||
assertFalse(result.contains("readme.txt"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void findMain_prefersLargestNonInstaller(@TempDir Path dir) throws Exception {
|
||||
// Small installer
|
||||
Files.write(dir.resolve("INSTALL.EXE"), createMZ(100));
|
||||
// Large real game
|
||||
Files.write(dir.resolve("GAME.EXE"), createMZ(5000));
|
||||
|
||||
String main = detector.findMain(dir);
|
||||
assertEquals(dir.resolve("GAME.EXE").toString(), main);
|
||||
}
|
||||
|
||||
@Test
|
||||
void findMain_skipsExtenders(@TempDir Path dir) throws Exception {
|
||||
Files.write(dir.resolve("DOS4GW.EXE"), createMZ(200));
|
||||
Files.write(dir.resolve("GAME.EXE"), createMZ(1000));
|
||||
|
||||
String main = detector.findMain(dir);
|
||||
assertEquals(dir.resolve("GAME.EXE").toString(), main);
|
||||
}
|
||||
|
||||
@Test
|
||||
void findMain_skipsSelfExtractors(@TempDir Path dir) throws Exception {
|
||||
byte[] sfx = createMZ(500);
|
||||
// Embed PKSFX string
|
||||
String content = new String(sfx);
|
||||
int pos = content.indexOf("MZ") + 2;
|
||||
byte[] withSfx = new byte[sfx.length + 20];
|
||||
System.arraycopy(sfx, 0, withSfx, 0, sfx.length);
|
||||
byte[] pksfx = "PKSFX".getBytes();
|
||||
System.arraycopy(pksfx, 0, withSfx, pos, pksfx.length);
|
||||
Files.write(dir.resolve("GAME.EXE"), withSfx);
|
||||
Files.write(dir.resolve("REAL.EXE"), createMZ(2000));
|
||||
|
||||
String main = detector.findMain(dir);
|
||||
assertEquals(dir.resolve("REAL.EXE").toString(), main);
|
||||
}
|
||||
|
||||
@Test
|
||||
void findMain_emptyDir_returnsNull(@TempDir Path dir) throws Exception {
|
||||
assertNull(detector.findMain(dir));
|
||||
}
|
||||
|
||||
@Test
|
||||
void findMain_subdirectoryDeprioritized(@TempDir Path dir) throws Exception {
|
||||
// Root-level smaller exe
|
||||
Files.write(dir.resolve("GAME.EXE"), createMZ(1000));
|
||||
// Subdirectory larger exe — wins on size before depth is considered
|
||||
Path sub = Files.createDirectory(dir.resolve("SUBDIR"));
|
||||
Files.write(sub.resolve("OTHER.EXE"), createMZ(2000));
|
||||
|
||||
String main = detector.findMain(dir);
|
||||
// Larger file wins even though it's deeper (size sorts before depth)
|
||||
assertEquals(sub.resolve("OTHER.EXE").toString(), main);
|
||||
}
|
||||
|
||||
@Test
|
||||
void findSetup_findsSetupExe(@TempDir Path dir) throws Exception {
|
||||
Files.writeString(dir.resolve("SETUP.EXE"), "MZ");
|
||||
Files.writeString(dir.resolve("GAME.EXE"), "MZ");
|
||||
|
||||
String setup = detector.findSetup(dir);
|
||||
assertEquals(dir.resolve("SETUP.EXE").toString(), setup);
|
||||
}
|
||||
|
||||
@Test
|
||||
void findSetup_findsInstallBat(@TempDir Path dir) throws Exception {
|
||||
Files.writeString(dir.resolve("INSTALL.BAT"), "@echo off");
|
||||
|
||||
String setup = detector.findSetup(dir);
|
||||
assertEquals(dir.resolve("INSTALL.BAT").toString(), setup);
|
||||
}
|
||||
|
||||
@Test
|
||||
void findSetup_noSetup_returnsNull(@TempDir Path dir) throws Exception {
|
||||
Files.writeString(dir.resolve("GAME.EXE"), "MZ");
|
||||
assertNull(detector.findSetup(dir));
|
||||
}
|
||||
|
||||
@Test
|
||||
void isLikelySelfExtractor_detectsPKSFX(@TempDir Path dir) throws Exception {
|
||||
byte[] buf = new byte[1000];
|
||||
buf[0] = 0x4D; // M
|
||||
buf[1] = 0x5A; // Z
|
||||
byte[] pksfx = "PKSFX".getBytes();
|
||||
System.arraycopy(pksfx, 0, buf, 10, pksfx.length);
|
||||
|
||||
Path f = dir.resolve("sfx.exe");
|
||||
Files.write(f, buf);
|
||||
assertTrue(ExecutableDetector.isLikelySelfExtractor(f));
|
||||
}
|
||||
|
||||
@Test
|
||||
void isLikelySelfExtractor_plainExe_returnsFalse(@TempDir Path dir) throws Exception {
|
||||
byte[] buf = createMZ(500);
|
||||
Path f = dir.resolve("game.exe");
|
||||
Files.write(f, buf);
|
||||
assertFalse(ExecutableDetector.isLikelySelfExtractor(f));
|
||||
}
|
||||
|
||||
@Test
|
||||
void findMain_prefersDosOverWindows(@TempDir Path dir) throws Exception {
|
||||
// Windows PE executable: MZ header + PE signature at offset 0x80
|
||||
byte[] windowsExe = new byte[0x100];
|
||||
windowsExe[0] = 0x4D; windowsExe[1] = 0x5A; // MZ
|
||||
windowsExe[0x3C] = (byte) 0x80; // PE offset at 0x80
|
||||
windowsExe[0x80] = 0x50; windowsExe[0x81] = 0x45; // "PE" signature
|
||||
Files.write(dir.resolve("WIN.EXE"), windowsExe);
|
||||
|
||||
// DOS executable: just MZ header, no PE
|
||||
byte[] dosExe = new byte[0x40];
|
||||
dosExe[0] = 0x4D; dosExe[1] = 0x5A; // MZ
|
||||
Files.write(dir.resolve("DOS.EXE"), dosExe);
|
||||
|
||||
String main = detector.findMain(dir);
|
||||
assertNotNull(main);
|
||||
assertTrue(main.endsWith("DOS.EXE"),
|
||||
"DOS executable should be preferred over Windows, but got: " + main);
|
||||
}
|
||||
|
||||
@Test
|
||||
void findMain_returnsNullForNoExecutables(@TempDir Path dir) throws Exception {
|
||||
Files.writeString(dir.resolve("readme.txt"), "text");
|
||||
Files.writeString(dir.resolve("notes.md"), "markdown");
|
||||
assertNull(detector.findMain(dir));
|
||||
}
|
||||
|
||||
@Test
|
||||
void findMain_skipsSelfExtractorsWithPkSignature(@TempDir Path dir) throws Exception {
|
||||
// Self-extractor with PK\x03\x04 signature bytes (ZIP local header)
|
||||
byte[] sfx = new byte[0x80];
|
||||
sfx[0] = 0x4D; sfx[1] = 0x5A; // MZ header
|
||||
sfx[0x60] = 0x50; sfx[0x61] = 0x4B; sfx[0x62] = 0x03; sfx[0x63] = 0x04; // PK\x03\x04
|
||||
Files.write(dir.resolve("SFX.EXE"), sfx);
|
||||
|
||||
// Real game executable
|
||||
Files.write(dir.resolve("GAME.EXE"), createMZ(2000));
|
||||
|
||||
String main = detector.findMain(dir);
|
||||
assertNotNull(main);
|
||||
assertTrue(main.endsWith("GAME.EXE"),
|
||||
"Self-extractor with PK signature bytes should be skipped, but got: " + main);
|
||||
}
|
||||
|
||||
@Test
|
||||
void findMain_handlesMixedContent(@TempDir Path dir) throws Exception {
|
||||
Files.writeString(dir.resolve("readme.txt"), "text");
|
||||
Files.write(dir.resolve("DOS4GW.EXE"), createMZ(200)); // extender, skipped
|
||||
Files.write(dir.resolve("INSTALL.EXE"), createMZ(150)); // installer, deprioritized
|
||||
Files.write(dir.resolve("GAME.EXE"), createMZ(5000)); // real game, should win
|
||||
Files.write(dir.resolve("helper.com"), createMZ(100)); // small .com
|
||||
|
||||
String main = detector.findMain(dir);
|
||||
assertNotNull(main);
|
||||
assertTrue(main.endsWith("GAME.EXE"),
|
||||
"Main game executable should be selected, but got: " + main);
|
||||
}
|
||||
|
||||
@Test
|
||||
void findAll_returnsEmptyForEmptyDir(@TempDir Path dir) throws Exception {
|
||||
assertTrue(detector.findAll(dir).isEmpty());
|
||||
}
|
||||
|
||||
private static byte[] createMZ(int size) {
|
||||
byte[] buf = new byte[size];
|
||||
buf[0] = 0x4D; // M
|
||||
buf[1] = 0x5A; // Z
|
||||
return buf;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package org.dostalgia;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class FileUtilsTest {
|
||||
|
||||
@Test
|
||||
void deleteDirectory_nonExistentDir_doesNotThrow(@TempDir Path dir) {
|
||||
Path nonExistent = dir.resolve("doesnotexist");
|
||||
assertDoesNotThrow(() -> FileUtils.deleteDirectory(nonExistent));
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteDirectory_deletesFilesRecursively(@TempDir Path dir) throws Exception {
|
||||
Files.writeString(dir.resolve("file1.txt"), "hello");
|
||||
Files.writeString(dir.resolve("file2.txt"), "world");
|
||||
Path sub = Files.createDirectories(dir.resolve("sub"));
|
||||
Files.writeString(sub.resolve("nested.txt"), "deep");
|
||||
|
||||
assertTrue(Files.exists(dir.resolve("file1.txt")));
|
||||
FileUtils.deleteDirectory(dir);
|
||||
assertFalse(Files.exists(dir));
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteDirectory_deletesSubdirectories(@TempDir Path dir) throws Exception {
|
||||
Path a = Files.createDirectories(dir.resolve("a/b/c"));
|
||||
Files.writeString(a.resolve("deep.txt"), "deep");
|
||||
Path sub2 = Files.createDirectories(dir.resolve("sub2"));
|
||||
Files.writeString(sub2.resolve("file.txt"), "data");
|
||||
|
||||
FileUtils.deleteDirectory(dir);
|
||||
assertFalse(Files.exists(dir));
|
||||
}
|
||||
|
||||
@Test
|
||||
void findFilesByExtensions_findsExeComBat(@TempDir Path dir) throws Exception {
|
||||
Files.writeString(dir.resolve("game.exe"), "data");
|
||||
Files.writeString(dir.resolve("helper.com"), "data");
|
||||
Files.writeString(dir.resolve("run.bat"), "data");
|
||||
Files.writeString(dir.resolve("readme.txt"), "data");
|
||||
|
||||
List<String> result = FileUtils.findFilesByExtensions(dir, Set.of(".exe", ".com", ".bat"));
|
||||
assertEquals(3, result.size());
|
||||
assertTrue(result.contains("game.exe"));
|
||||
assertTrue(result.contains("helper.com"));
|
||||
assertTrue(result.contains("run.bat"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void findFilesByExtensions_caseInsensitive(@TempDir Path dir) throws Exception {
|
||||
Files.writeString(dir.resolve("GAME.EXE"), "data");
|
||||
Files.writeString(dir.resolve("Helper.Com"), "data");
|
||||
Files.writeString(dir.resolve("RUN.BAT"), "data");
|
||||
|
||||
List<String> result = FileUtils.findFilesByExtensions(dir, Set.of(".exe", ".com", ".bat"));
|
||||
assertEquals(3, result.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void findFilesByExtensions_emptyDir_returnsEmpty(@TempDir Path dir) throws Exception {
|
||||
List<String> result = FileUtils.findFilesByExtensions(dir, Set.of(".exe"));
|
||||
assertTrue(result.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void findFilesByExtensions_noMatches_returnsEmpty(@TempDir Path dir) throws Exception {
|
||||
Files.writeString(dir.resolve("readme.txt"), "data");
|
||||
Files.writeString(dir.resolve("notes.md"), "data");
|
||||
|
||||
List<String> result = FileUtils.findFilesByExtensions(dir, Set.of(".exe", ".com", ".bat"));
|
||||
assertTrue(result.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void findFilesByExtensions_includesSubdirectories(@TempDir Path dir) throws Exception {
|
||||
Path sub = Files.createDirectories(dir.resolve("subdir"));
|
||||
Files.writeString(sub.resolve("game.exe"), "data");
|
||||
Files.writeString(dir.resolve("root.exe"), "data");
|
||||
|
||||
List<String> result = FileUtils.findFilesByExtensions(dir, Set.of(".exe"));
|
||||
assertEquals(2, result.size());
|
||||
assertTrue(result.contains("root.exe"));
|
||||
assertTrue(result.contains("subdir/game.exe"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
package org.dostalgia;
|
||||
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.PropertyNamingStrategies;
|
||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.NoSuchFileException;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class GameServiceTest {
|
||||
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper()
|
||||
.registerModule(new JavaTimeModule())
|
||||
.setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE)
|
||||
.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
|
||||
.enable(SerializationFeature.INDENT_OUTPUT);
|
||||
|
||||
// -- sanitizeId tests --
|
||||
|
||||
@Test
|
||||
void sanitizeId_normalString_kept() {
|
||||
assertEquals("hello", GameService.sanitizeId("hello"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sanitizeId_uppercaseToLowercase() {
|
||||
assertEquals("hello", GameService.sanitizeId("HELLO"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sanitizeId_spacesToHyphens() {
|
||||
assertEquals("hello-world", GameService.sanitizeId("hello world"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sanitizeId_specialCharsStripped() {
|
||||
assertEquals("helloworld", GameService.sanitizeId("hello!@#$world"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sanitizeId_tripleHyphenStripped() {
|
||||
assertEquals("test", GameService.sanitizeId("---test---"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sanitizeId_emptyReturnsUnknown() {
|
||||
assertEquals("unknown", GameService.sanitizeId(""));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sanitizeId_onlySpecialCharsReturnsUnknown() {
|
||||
assertEquals("unknown", GameService.sanitizeId("!@#$%^&*()"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sanitizeId_mixedCaseAndSpaces() {
|
||||
assertEquals("doom-ii-hell-on-earth", GameService.sanitizeId("DOOM II: Hell on Earth"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sanitizeId_underscoresToHyphens() {
|
||||
assertEquals("my-game", GameService.sanitizeId("my_game"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sanitizeId_numbersPreserved() {
|
||||
assertEquals("game2", GameService.sanitizeId("Game2"));
|
||||
}
|
||||
|
||||
// -- gameDir / gameJsonPath tests --
|
||||
|
||||
@Test
|
||||
void gameDir_returnsCorrectPath(@TempDir Path tempDir) throws Exception {
|
||||
GameService svc = new GameService();
|
||||
svc.dataDir = tempDir.toString();
|
||||
svc.init();
|
||||
|
||||
Path dir = svc.gameDir("test-game");
|
||||
assertEquals(tempDir.resolve("games/test-game"), dir);
|
||||
}
|
||||
|
||||
@Test
|
||||
void gameJsonPath_returnsCorrectPath(@TempDir Path tempDir) throws Exception {
|
||||
GameService svc = new GameService();
|
||||
svc.dataDir = tempDir.toString();
|
||||
svc.init();
|
||||
|
||||
Path path = svc.gameJsonPath("test-game");
|
||||
assertEquals(tempDir.resolve("games/test-game/game.json"), path);
|
||||
}
|
||||
|
||||
// -- init tests --
|
||||
|
||||
@Test
|
||||
void init_createsDirectories(@TempDir Path tempDir) throws Exception {
|
||||
GameService svc = new GameService();
|
||||
svc.dataDir = tempDir.toString();
|
||||
svc.init();
|
||||
|
||||
assertTrue(Files.isDirectory(tempDir.resolve("games")));
|
||||
assertTrue(Files.isDirectory(tempDir.resolve("artwork")));
|
||||
assertTrue(Files.isDirectory(tempDir.resolve("saves")));
|
||||
}
|
||||
|
||||
// -- save / load tests --
|
||||
|
||||
@Test
|
||||
void saveAndLoad_roundTrip(@TempDir Path tempDir) throws Exception {
|
||||
GameService svc = new GameService();
|
||||
svc.dataDir = tempDir.toString();
|
||||
svc.init();
|
||||
|
||||
Game game = new Game("test-game", "Test Game");
|
||||
game.setYear(1995);
|
||||
game.setGenre("FPS");
|
||||
svc.save(game);
|
||||
|
||||
Game loaded = svc.load("test-game");
|
||||
assertEquals("test-game", loaded.getId());
|
||||
assertEquals("Test Game", loaded.getTitle());
|
||||
assertEquals(1995, loaded.getYear());
|
||||
assertEquals("FPS", loaded.getGenre());
|
||||
assertNotNull(loaded.getCreatedAt());
|
||||
assertNotNull(loaded.getUpdatedAt());
|
||||
}
|
||||
|
||||
@Test
|
||||
void save_updatesUpdatedAt(@TempDir Path tempDir) throws Exception {
|
||||
GameService svc = new GameService();
|
||||
svc.dataDir = tempDir.toString();
|
||||
svc.init();
|
||||
|
||||
Game game = new Game("game1", "Game One");
|
||||
svc.save(game);
|
||||
Thread.sleep(10); // ensure timestamp advances
|
||||
|
||||
game.setGenre("Adventure");
|
||||
svc.save(game);
|
||||
|
||||
Game loaded = svc.load("game1");
|
||||
assertNotNull(loaded.getUpdatedAt());
|
||||
assertTrue(loaded.getUpdatedAt().isAfter(loaded.getCreatedAt())
|
||||
|| loaded.getUpdatedAt().equals(loaded.getCreatedAt()),
|
||||
"updatedAt should be >= createdAt");
|
||||
}
|
||||
|
||||
@Test
|
||||
void load_nonExistent_throwsNoSuchFileException(@TempDir Path tempDir) throws Exception {
|
||||
GameService svc = new GameService();
|
||||
svc.dataDir = tempDir.toString();
|
||||
svc.init();
|
||||
|
||||
assertThrows(NoSuchFileException.class, () -> svc.load("nonexistent"));
|
||||
}
|
||||
|
||||
// -- list tests --
|
||||
|
||||
@Test
|
||||
void list_emptyDir_returnsEmpty(@TempDir Path tempDir) throws Exception {
|
||||
GameService svc = new GameService();
|
||||
svc.dataDir = tempDir.toString();
|
||||
svc.init();
|
||||
|
||||
List<Game> games = svc.list();
|
||||
assertTrue(games.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void saveAndList_returnsGame(@TempDir Path tempDir) throws Exception {
|
||||
GameService svc = new GameService();
|
||||
svc.dataDir = tempDir.toString();
|
||||
svc.init();
|
||||
|
||||
svc.save(new Game("game1", "Game One"));
|
||||
svc.save(new Game("game2", "Game Two"));
|
||||
|
||||
List<Game> games = svc.list();
|
||||
assertEquals(2, games.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void list_sortsByTitle(@TempDir Path tempDir) throws Exception {
|
||||
GameService svc = new GameService();
|
||||
svc.dataDir = tempDir.toString();
|
||||
svc.init();
|
||||
|
||||
svc.save(new Game("zzz", "Z Game"));
|
||||
svc.save(new Game("aaa", "A Game"));
|
||||
|
||||
List<Game> games = svc.list();
|
||||
assertEquals(2, games.size());
|
||||
assertEquals("A Game", games.get(0).getTitle());
|
||||
assertEquals("Z Game", games.get(1).getTitle());
|
||||
}
|
||||
|
||||
// -- delete tests --
|
||||
|
||||
@Test
|
||||
void delete_removesGameDir(@TempDir Path tempDir) throws Exception {
|
||||
GameService svc = new GameService();
|
||||
svc.dataDir = tempDir.toString();
|
||||
svc.init();
|
||||
|
||||
svc.save(new Game("game1", "Game One"));
|
||||
assertTrue(Files.exists(svc.gameDir("game1")));
|
||||
|
||||
svc.delete("game1");
|
||||
assertFalse(Files.exists(svc.gameDir("game1")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void delete_nonExistent_doesNotThrow(@TempDir Path tempDir) throws Exception {
|
||||
GameService svc = new GameService();
|
||||
svc.dataDir = tempDir.toString();
|
||||
svc.init();
|
||||
|
||||
assertDoesNotThrow(() -> svc.delete("nonexistent"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void delete_removesOldJsdosFiles(@TempDir Path tempDir) throws Exception {
|
||||
GameService svc = new GameService();
|
||||
svc.dataDir = tempDir.toString();
|
||||
svc.init();
|
||||
|
||||
// Create backward-compat files that delete should clean up
|
||||
Files.writeString(tempDir.resolve("games/game1.jsdos"), "old");
|
||||
Files.writeString(tempDir.resolve("games/game1.setup.jsdos"), "old-setup");
|
||||
|
||||
svc.delete("game1");
|
||||
|
||||
assertFalse(Files.exists(tempDir.resolve("games/game1.jsdos")));
|
||||
assertFalse(Files.exists(tempDir.resolve("games/game1.setup.jsdos")));
|
||||
}
|
||||
|
||||
// -- JSON serialization tests --
|
||||
|
||||
@Test
|
||||
void gameSerialization_snakeCase(@TempDir Path tempDir) throws Exception {
|
||||
Game g = new Game("my-game", "My Game");
|
||||
g.setIgdbCoverId("abc123");
|
||||
g.setHasCover(true);
|
||||
g.setHasScreenshots(false);
|
||||
g.setIgdbId(42);
|
||||
g.setBundleSize(1024L);
|
||||
g.setSetupExe("setup.exe");
|
||||
|
||||
String json = MAPPER.writeValueAsString(g);
|
||||
assertTrue(json.contains("\"igdb_cover_id\""));
|
||||
assertTrue(json.contains("\"has_cover\""));
|
||||
assertTrue(json.contains("\"has_screenshots\""));
|
||||
assertTrue(json.contains("\"bundle_size\""));
|
||||
assertTrue(json.contains("\"setup_exe\""));
|
||||
assertFalse(json.contains("igdbCoverId"));
|
||||
assertFalse(json.contains("hasCover"));
|
||||
assertFalse(json.contains("hasScreenshots"));
|
||||
assertFalse(json.contains("bundleSize"));
|
||||
assertFalse(json.contains("setupExe"));
|
||||
|
||||
Game deserialized = MAPPER.readValue(json, Game.class);
|
||||
assertEquals("my-game", deserialized.getId());
|
||||
assertEquals("My Game", deserialized.getTitle());
|
||||
assertEquals("abc123", deserialized.getIgdbCoverId());
|
||||
assertTrue(deserialized.isHasCover());
|
||||
assertFalse(deserialized.isHasScreenshots());
|
||||
assertEquals(Integer.valueOf(42), deserialized.getIgdbId());
|
||||
assertEquals(Long.valueOf(1024L), deserialized.getBundleSize());
|
||||
assertEquals("setup.exe", deserialized.getSetupExe());
|
||||
}
|
||||
|
||||
@Test
|
||||
void gameSerialization_timestamps(@TempDir Path tempDir) throws Exception {
|
||||
Game g = new Game("ts-game", "Timestamp Test");
|
||||
String json = MAPPER.writeValueAsString(g);
|
||||
|
||||
assertTrue(json.contains("\"created_at\""));
|
||||
assertTrue(json.contains("\"updated_at\""));
|
||||
|
||||
Game deserialized = MAPPER.readValue(json, Game.class);
|
||||
assertNotNull(deserialized.getCreatedAt());
|
||||
assertNotNull(deserialized.getUpdatedAt());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package org.dostalgia;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class GameTest {
|
||||
|
||||
@Test
|
||||
void noArgConstructor_setsNothing() {
|
||||
Game g = new Game();
|
||||
assertNull(g.getId());
|
||||
assertNull(g.getTitle());
|
||||
assertNull(g.getYear());
|
||||
assertNull(g.getGenre());
|
||||
assertNull(g.getDeveloper());
|
||||
assertNull(g.getPublisher());
|
||||
assertNull(g.getDescription());
|
||||
assertNull(g.getRating());
|
||||
assertNull(g.getBundleFile());
|
||||
assertNull(g.getSetupExe());
|
||||
assertNull(g.getBundleSize());
|
||||
assertNull(g.getExecutable());
|
||||
assertNull(g.getExecutables());
|
||||
assertNull(g.getPlatform());
|
||||
assertNull(g.getIgdbId());
|
||||
assertNull(g.getIgdbCoverId());
|
||||
assertFalse(g.isHasCover());
|
||||
assertFalse(g.isHasScreenshots());
|
||||
assertNull(g.getScreenshots());
|
||||
assertNull(g.getVideos());
|
||||
assertFalse(g.isReady());
|
||||
assertNull(g.getCreatedAt());
|
||||
assertNull(g.getUpdatedAt());
|
||||
}
|
||||
|
||||
@Test
|
||||
void twoArgConstructor_setsIdTitleAndTimestamps() {
|
||||
Game g = new Game("test-id", "Test Game");
|
||||
assertEquals("test-id", g.getId());
|
||||
assertEquals("Test Game", g.getTitle());
|
||||
assertFalse(g.isReady());
|
||||
assertNotNull(g.getCreatedAt());
|
||||
assertNotNull(g.getUpdatedAt());
|
||||
}
|
||||
|
||||
@Test
|
||||
void settersAndGetters_roundTrip() {
|
||||
Game g = new Game();
|
||||
g.setId("id1");
|
||||
g.setTitle("Title");
|
||||
g.setYear(1995);
|
||||
g.setGenre("Action");
|
||||
g.setDeveloper("DevCo");
|
||||
g.setPublisher("PubCo");
|
||||
g.setDescription("A great game");
|
||||
g.setRating(4.5);
|
||||
g.setBundleFile("game.zip");
|
||||
g.setSetupExe("setup.exe");
|
||||
g.setBundleSize(12345L);
|
||||
g.setExecutable("game.exe");
|
||||
g.setExecutables(List.of("game.exe", "setup.exe"));
|
||||
g.setPlatform("dos");
|
||||
g.setIgdbId(42);
|
||||
g.setIgdbCoverId("abc123");
|
||||
g.setHasCover(true);
|
||||
g.setHasScreenshots(true);
|
||||
g.setScreenshots(List.of("shot1.png"));
|
||||
g.setVideos(List.of("vid1"));
|
||||
g.setReady(true);
|
||||
Instant now = Instant.now();
|
||||
g.setCreatedAt(now);
|
||||
g.setUpdatedAt(now);
|
||||
|
||||
assertEquals("id1", g.getId());
|
||||
assertEquals("Title", g.getTitle());
|
||||
assertEquals(1995, g.getYear());
|
||||
assertEquals("Action", g.getGenre());
|
||||
assertEquals("DevCo", g.getDeveloper());
|
||||
assertEquals("PubCo", g.getPublisher());
|
||||
assertEquals("A great game", g.getDescription());
|
||||
assertEquals(4.5, g.getRating());
|
||||
assertEquals("game.zip", g.getBundleFile());
|
||||
assertEquals("setup.exe", g.getSetupExe());
|
||||
assertEquals(12345L, g.getBundleSize());
|
||||
assertEquals("game.exe", g.getExecutable());
|
||||
assertEquals(List.of("game.exe", "setup.exe"), g.getExecutables());
|
||||
assertEquals("dos", g.getPlatform());
|
||||
assertEquals(42, g.getIgdbId());
|
||||
assertEquals("abc123", g.getIgdbCoverId());
|
||||
assertTrue(g.isHasCover());
|
||||
assertTrue(g.isHasScreenshots());
|
||||
assertEquals(List.of("shot1.png"), g.getScreenshots());
|
||||
assertEquals(List.of("vid1"), g.getVideos());
|
||||
assertTrue(g.isReady());
|
||||
assertEquals(now, g.getCreatedAt());
|
||||
assertEquals(now, g.getUpdatedAt());
|
||||
}
|
||||
|
||||
@Test
|
||||
void isHasSetup_nullSetupExe_returnsFalse() {
|
||||
Game g = new Game();
|
||||
g.setSetupExe(null);
|
||||
assertFalse(g.isHasSetup());
|
||||
}
|
||||
|
||||
@Test
|
||||
void isHasSetup_blankSetupExe_returnsFalse() {
|
||||
Game g = new Game();
|
||||
g.setSetupExe(" ");
|
||||
assertFalse(g.isHasSetup());
|
||||
}
|
||||
|
||||
@Test
|
||||
void isHasSetup_nonBlankSetupExe_returnsTrue() {
|
||||
Game g = new Game();
|
||||
g.setSetupExe("setup.exe");
|
||||
assertTrue(g.isHasSetup());
|
||||
}
|
||||
|
||||
@Test
|
||||
void isPlayable_notReady_returnsFalse() {
|
||||
Game g = new Game();
|
||||
g.setReady(false);
|
||||
assertFalse(g.isPlayable());
|
||||
}
|
||||
|
||||
@Test
|
||||
void isPlayable_readyNullPlatform_returnsTrue() {
|
||||
Game g = new Game();
|
||||
g.setReady(true);
|
||||
g.setPlatform(null);
|
||||
assertTrue(g.isPlayable());
|
||||
}
|
||||
|
||||
@Test
|
||||
void isPlayable_readyDosPlatform_returnsTrue() {
|
||||
Game g = new Game();
|
||||
g.setReady(true);
|
||||
g.setPlatform("dos");
|
||||
assertTrue(g.isPlayable());
|
||||
}
|
||||
|
||||
@Test
|
||||
void isPlayable_readyWindowsPlatform_returnsFalse() {
|
||||
Game g = new Game();
|
||||
g.setReady(true);
|
||||
g.setPlatform("windows");
|
||||
assertFalse(g.isPlayable());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
package org.dostalgia;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class IgdbServiceTest {
|
||||
|
||||
private IgdbService service;
|
||||
private ObjectMapper mapper;
|
||||
|
||||
private Method epochToYearMethod;
|
||||
private Method extractGenresMethod;
|
||||
private Method sanitizeMethod;
|
||||
private Method jsonNodeToMapMethod;
|
||||
|
||||
@BeforeEach
|
||||
@SuppressWarnings("unchecked")
|
||||
void setUp() throws Exception {
|
||||
service = new IgdbService();
|
||||
service.clientId = Optional.of("test-client-id");
|
||||
service.clientSecret = Optional.of("test-client-secret");
|
||||
mapper = new ObjectMapper();
|
||||
|
||||
epochToYearMethod = IgdbService.class.getDeclaredMethod("epochToYear", JsonNode.class);
|
||||
epochToYearMethod.setAccessible(true);
|
||||
|
||||
extractGenresMethod = IgdbService.class.getDeclaredMethod("extractGenres", JsonNode.class);
|
||||
extractGenresMethod.setAccessible(true);
|
||||
|
||||
sanitizeMethod = IgdbService.class.getDeclaredMethod("sanitize", String.class);
|
||||
sanitizeMethod.setAccessible(true);
|
||||
|
||||
jsonNodeToMapMethod = IgdbService.class.getDeclaredMethod("jsonNodeToMap", JsonNode.class);
|
||||
jsonNodeToMapMethod.setAccessible(true);
|
||||
}
|
||||
|
||||
// -- isConfigured tests --
|
||||
|
||||
@Test
|
||||
void isConfigured_returnsTrueWhenBothPresentAndNonBlank() {
|
||||
assertTrue(service.isConfigured());
|
||||
}
|
||||
|
||||
@Test
|
||||
void isConfigured_returnsFalseWhenClientIdEmpty() {
|
||||
service.clientId = Optional.empty();
|
||||
assertFalse(service.isConfigured());
|
||||
}
|
||||
|
||||
@Test
|
||||
void isConfigured_returnsFalseWhenClientSecretEmpty() {
|
||||
service.clientSecret = Optional.empty();
|
||||
assertFalse(service.isConfigured());
|
||||
}
|
||||
|
||||
@Test
|
||||
void isConfigured_returnsFalseWhenClientIdBlank() {
|
||||
service.clientId = Optional.of("");
|
||||
assertFalse(service.isConfigured());
|
||||
}
|
||||
|
||||
@Test
|
||||
void isConfigured_returnsFalseWhenClientSecretBlank() {
|
||||
service.clientSecret = Optional.of("");
|
||||
assertFalse(service.isConfigured());
|
||||
}
|
||||
|
||||
// -- epochToYear tests --
|
||||
|
||||
@Test
|
||||
void epochToYear_withReleaseDate_returnsYear() throws Exception {
|
||||
// 946684800 = 2000-01-01T00:00:00Z
|
||||
JsonNode node = mapper.readTree("{\"first_release_date\": 946684800}");
|
||||
Optional<Integer> year = (Optional<Integer>) epochToYearMethod.invoke(null, node);
|
||||
assertTrue(year.isPresent());
|
||||
int y = year.get();
|
||||
assertTrue(y >= 1999 && y <= 2001, "Year should be around 2000 but was " + y);
|
||||
}
|
||||
|
||||
@Test
|
||||
void epochToYear_withoutReleaseDate_returnsEmpty() throws Exception {
|
||||
JsonNode node = mapper.readTree("{\"name\": \"Test Game\"}");
|
||||
Optional<Integer> year = (Optional<Integer>) epochToYearMethod.invoke(null, node);
|
||||
assertFalse(year.isPresent());
|
||||
}
|
||||
|
||||
@Test
|
||||
void epochToYear_withEpochZero_returnsYear() throws Exception {
|
||||
// epoch 0 = 1970-01-01T00:00:00Z
|
||||
JsonNode node = mapper.readTree("{\"first_release_date\": 0}");
|
||||
Optional<Integer> year = (Optional<Integer>) epochToYearMethod.invoke(null, node);
|
||||
assertTrue(year.isPresent());
|
||||
assertEquals(1970, year.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
void epochToYear_negativeEpoch_returnsYear() throws Exception {
|
||||
// negative epoch is before 1970
|
||||
JsonNode node = mapper.readTree("{\"first_release_date\": -315619200}");
|
||||
Optional<Integer> year = (Optional<Integer>) epochToYearMethod.invoke(null, node);
|
||||
assertTrue(year.isPresent());
|
||||
// Calendar may handle negative values differently; just verify it returns something
|
||||
assertNotNull(year.get());
|
||||
}
|
||||
|
||||
// -- extractGenres tests --
|
||||
|
||||
@Test
|
||||
void extractGenres_withGenresArray_returnsList() throws Exception {
|
||||
JsonNode node = mapper.readTree("{\"genres\": [{\"name\": \"Action\"}, {\"name\": \"Adventure\"}]}");
|
||||
List<String> genres = (List<String>) extractGenresMethod.invoke(null, node);
|
||||
assertEquals(2, genres.size());
|
||||
assertTrue(genres.contains("Action"));
|
||||
assertTrue(genres.contains("Adventure"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void extractGenres_emptyArray_returnsEmptyList() throws Exception {
|
||||
JsonNode node = mapper.readTree("{\"genres\": []}");
|
||||
List<String> genres = (List<String>) extractGenresMethod.invoke(null, node);
|
||||
assertTrue(genres.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void extractGenres_missingField_returnsEmptyList() throws Exception {
|
||||
JsonNode node = mapper.readTree("{\"name\": \"Test\"}");
|
||||
List<String> genres = (List<String>) extractGenresMethod.invoke(null, node);
|
||||
assertTrue(genres.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void extractGenres_genreWithoutName_skipsEntry() throws Exception {
|
||||
JsonNode node = mapper.readTree("{\"genres\": [{\"name\": \"Action\"}, {\"id\": 5}]}");
|
||||
List<String> genres = (List<String>) extractGenresMethod.invoke(null, node);
|
||||
assertEquals(1, genres.size());
|
||||
assertEquals("Action", genres.get(0));
|
||||
}
|
||||
|
||||
// -- sanitize tests --
|
||||
|
||||
@Test
|
||||
void sanitize_escapesBackslashes() throws Exception {
|
||||
String result = (String) sanitizeMethod.invoke(service, "path\\to\\file");
|
||||
assertEquals("path\\\\to\\\\file", result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void sanitize_escapesQuotes() throws Exception {
|
||||
String result = (String) sanitizeMethod.invoke(service, "say \"hello\"");
|
||||
assertEquals("say \\\"hello\\\"", result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void sanitize_escapesBothBackslashesAndQuotes() throws Exception {
|
||||
String result = (String) sanitizeMethod.invoke(service, "\\\"mixed\\\"");
|
||||
assertEquals("\\\\\\\"mixed\\\\\\\"", result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void sanitize_normalString_unchanged() throws Exception {
|
||||
String result = (String) sanitizeMethod.invoke(service, "hello world");
|
||||
assertEquals("hello world", result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void sanitize_emptyString_unchanged() throws Exception {
|
||||
String result = (String) sanitizeMethod.invoke(service, "");
|
||||
assertEquals("", result);
|
||||
}
|
||||
|
||||
// -- jsonNodeToMap tests --
|
||||
|
||||
@Test
|
||||
void jsonNodeToMap_basicFields() throws Exception {
|
||||
JsonNode node = mapper.readTree("{\"id\": 123, \"name\": \"Test Game\"}");
|
||||
Map<String, Object> map = (Map<String, Object>) jsonNodeToMapMethod.invoke(service, node);
|
||||
assertEquals(123, map.get("igdb_id"));
|
||||
assertEquals("Test Game", map.get("name"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void jsonNodeToMap_withGenres() throws Exception {
|
||||
JsonNode node = mapper.readTree("{\"id\": 1, \"name\": \"G\", \"genres\": [{\"name\": \"RPG\"}]}");
|
||||
Map<String, Object> map = (Map<String, Object>) jsonNodeToMapMethod.invoke(service, node);
|
||||
assertTrue(map.containsKey("genres"));
|
||||
assertEquals(List.of("RPG"), map.get("genres"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void jsonNodeToMap_withInvolvedCompanies() throws Exception {
|
||||
JsonNode node = mapper.readTree("""
|
||||
{
|
||||
"id": 1,
|
||||
"name": "G",
|
||||
"involved_companies": [
|
||||
{ "company": { "name": "DevStudio" }, "developer": true, "publisher": false },
|
||||
{ "company": { "name": "PubStudio" }, "developer": false, "publisher": true }
|
||||
]
|
||||
}
|
||||
""");
|
||||
Map<String, Object> map = (Map<String, Object>) jsonNodeToMapMethod.invoke(service, node);
|
||||
assertEquals("DevStudio", map.get("developer"));
|
||||
assertEquals("PubStudio", map.get("publisher"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void jsonNodeToMap_withSummary() throws Exception {
|
||||
JsonNode node = mapper.readTree("{\"id\": 1, \"name\": \"G\", \"summary\": \"A great game\"}");
|
||||
Map<String, Object> map = (Map<String, Object>) jsonNodeToMapMethod.invoke(service, node);
|
||||
assertEquals("A great game", map.get("summary"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void jsonNodeToMap_withCoverUrl() throws Exception {
|
||||
JsonNode node = mapper.readTree("{\"id\": 1, \"name\": \"G\", \"cover\": { \"url\": \"//images.igdb.com/igdb/image/upload/t_thumb/abc.jpg\" }}");
|
||||
Map<String, Object> map = (Map<String, Object>) jsonNodeToMapMethod.invoke(service, node);
|
||||
String coverUrl = (String) map.get("cover_url");
|
||||
assertNotNull(coverUrl);
|
||||
assertTrue(coverUrl.startsWith("https:"));
|
||||
assertTrue(coverUrl.contains("t_cover_big"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void jsonNodeToMap_emptyObject_returnsDefaults() throws Exception {
|
||||
JsonNode node = mapper.readTree("{}");
|
||||
Map<String, Object> map = (Map<String, Object>) jsonNodeToMapMethod.invoke(service, node);
|
||||
assertEquals(0, map.get("igdb_id"));
|
||||
assertEquals("", map.get("name"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void jsonNodeToMap_noInvolvedCompanies_skipsDevPub() throws Exception {
|
||||
JsonNode node = mapper.readTree("{\"id\": 1, \"name\": \"G\"}");
|
||||
Map<String, Object> map = (Map<String, Object>) jsonNodeToMapMethod.invoke(service, node);
|
||||
assertFalse(map.containsKey("developer"));
|
||||
assertFalse(map.containsKey("publisher"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void jsonNodeToMap_firstDevAndPubOnly() throws Exception {
|
||||
// Multiple developers/publishers — only first of each should be recorded
|
||||
JsonNode node = mapper.readTree("""
|
||||
{
|
||||
"id": 1, "name": "G",
|
||||
"involved_companies": [
|
||||
{ "company": { "name": "FirstDev" }, "developer": true, "publisher": false },
|
||||
{ "company": { "name": "SecondDev" }, "developer": true, "publisher": false },
|
||||
{ "company": { "name": "FirstPub" }, "developer": false, "publisher": true }
|
||||
]
|
||||
}
|
||||
""");
|
||||
Map<String, Object> map = (Map<String, Object>) jsonNodeToMapMethod.invoke(service, node);
|
||||
assertEquals("FirstDev", map.get("developer"));
|
||||
assertEquals("FirstPub", map.get("publisher"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package org.dostalgia;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
|
||||
class PlatformDetectorTest {
|
||||
|
||||
private final PlatformDetector detector = new PlatformDetector();
|
||||
|
||||
@Test
|
||||
void detect_comFile_returnsDos(@TempDir Path dir) throws Exception {
|
||||
Path f = dir.resolve("test.com");
|
||||
Files.write(f, new byte[]{0x4D, 0x5A}); // MZ header
|
||||
assertEquals("dos", detector.detect(f));
|
||||
}
|
||||
|
||||
@Test
|
||||
void detect_batFile_returnsDos(@TempDir Path dir) throws Exception {
|
||||
Path f = dir.resolve("test.bat");
|
||||
Files.writeString(f, "@echo off");
|
||||
assertEquals("dos", detector.detect(f));
|
||||
}
|
||||
|
||||
@Test
|
||||
void detect_plainMZ_returnsDos(@TempDir Path dir) throws Exception {
|
||||
byte[] buf = new byte[0x80];
|
||||
buf[0] = 0x4D; // M
|
||||
buf[1] = 0x5A; // Z
|
||||
// e_lfanew points to garbage — should be treated as plain DOS
|
||||
buf[0x3C] = 0x00;
|
||||
buf[0x3D] = 0x00;
|
||||
buf[0x3E] = 0x00;
|
||||
buf[0x3F] = 0x00;
|
||||
|
||||
Path f = dir.resolve("test.exe");
|
||||
Files.write(f, buf);
|
||||
assertEquals("dos", detector.detect(f));
|
||||
}
|
||||
|
||||
@Test
|
||||
void detect_peHeader_returnsWindows(@TempDir Path dir) throws Exception {
|
||||
byte[] buf = new byte[0x100];
|
||||
buf[0] = 0x4D; // M
|
||||
buf[1] = 0x5A; // Z
|
||||
// e_lfanew = 0x80
|
||||
buf[0x3C] = (byte) 0x80;
|
||||
buf[0x3D] = 0x00;
|
||||
buf[0x3E] = 0x00;
|
||||
buf[0x3F] = 0x00;
|
||||
// PE signature at offset 0x80
|
||||
buf[0x80] = 0x50; // P
|
||||
buf[0x81] = 0x45; // E
|
||||
|
||||
Path f = dir.resolve("test.exe");
|
||||
Files.write(f, buf);
|
||||
assertEquals("windows", detector.detect(f));
|
||||
}
|
||||
|
||||
@Test
|
||||
void detect_neHeader_returnsWindows(@TempDir Path dir) throws Exception {
|
||||
byte[] buf = new byte[0x100];
|
||||
buf[0] = 0x4D;
|
||||
buf[1] = 0x5A;
|
||||
buf[0x3C] = (byte) 0x80;
|
||||
buf[0x80] = 0x4E; // N
|
||||
buf[0x81] = 0x45; // E
|
||||
|
||||
Path f = dir.resolve("test.exe");
|
||||
Files.write(f, buf);
|
||||
assertEquals("windows", detector.detect(f));
|
||||
}
|
||||
|
||||
@Test
|
||||
void detect_emptyFile_returnsNull(@TempDir Path dir) throws Exception {
|
||||
Path f = dir.resolve("empty.exe");
|
||||
Files.write(f, new byte[0]);
|
||||
assertNull(detector.detect(f));
|
||||
}
|
||||
|
||||
@Test
|
||||
void detect_noMZ_returnsNull(@TempDir Path dir) throws Exception {
|
||||
Path f = dir.resolve("bad.exe");
|
||||
Files.write(f, new byte[]{0x00, 0x00});
|
||||
assertNull(detector.detect(f));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
package org.dostalgia;
|
||||
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class StaticResourceTest {
|
||||
|
||||
@Test
|
||||
void health_returnsOkStatusAndVersion(@TempDir Path tempDir) {
|
||||
StaticResource sr = new StaticResource();
|
||||
sr.dataDir = tempDir.toString();
|
||||
|
||||
Response resp = sr.health();
|
||||
assertEquals(200, resp.getStatus());
|
||||
Object entity = resp.getEntity();
|
||||
assertInstanceOf(Map.class, entity);
|
||||
Map<?, ?> map = (Map<?, ?>) entity;
|
||||
assertEquals("ok", map.get("status"));
|
||||
assertEquals("0.1.0", map.get("version"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getGameFile_jsdosContentType(@TempDir Path tempDir) throws Exception {
|
||||
Path gamesDir = Files.createDirectories(tempDir.resolve("games"));
|
||||
Files.writeString(gamesDir.resolve("test.jsdos"), "zip content");
|
||||
|
||||
StaticResource sr = new StaticResource();
|
||||
sr.dataDir = tempDir.toString();
|
||||
|
||||
Response resp = sr.getGameFile("test.jsdos");
|
||||
assertEquals(200, resp.getStatus());
|
||||
assertNotNull(resp.getMediaType());
|
||||
assertEquals("application/zip", resp.getMediaType().toString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getGameFile_zipContentType(@TempDir Path tempDir) throws Exception {
|
||||
Path gamesDir = Files.createDirectories(tempDir.resolve("games"));
|
||||
Files.writeString(gamesDir.resolve("archive.zip"), "zip content");
|
||||
|
||||
StaticResource sr = new StaticResource();
|
||||
sr.dataDir = tempDir.toString();
|
||||
|
||||
Response resp = sr.getGameFile("archive.zip");
|
||||
assertEquals(200, resp.getStatus());
|
||||
assertNotNull(resp.getMediaType());
|
||||
assertEquals("application/zip", resp.getMediaType().toString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getGameFile_jpgContentType(@TempDir Path tempDir) throws Exception {
|
||||
Path gamesDir = Files.createDirectories(tempDir.resolve("games"));
|
||||
Files.writeString(gamesDir.resolve("cover.jpg"), "image data");
|
||||
|
||||
StaticResource sr = new StaticResource();
|
||||
sr.dataDir = tempDir.toString();
|
||||
|
||||
Response resp = sr.getGameFile("cover.jpg");
|
||||
assertEquals(200, resp.getStatus());
|
||||
assertNotNull(resp.getMediaType());
|
||||
assertEquals("image/jpeg", resp.getMediaType().toString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getGameFile_jpegContentType(@TempDir Path tempDir) throws Exception {
|
||||
Path gamesDir = Files.createDirectories(tempDir.resolve("games"));
|
||||
Files.writeString(gamesDir.resolve("photo.jpeg"), "image data");
|
||||
|
||||
StaticResource sr = new StaticResource();
|
||||
sr.dataDir = tempDir.toString();
|
||||
|
||||
Response resp = sr.getGameFile("photo.jpeg");
|
||||
assertEquals(200, resp.getStatus());
|
||||
assertNotNull(resp.getMediaType());
|
||||
assertEquals("image/jpeg", resp.getMediaType().toString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getGameFile_pngContentType(@TempDir Path tempDir) throws Exception {
|
||||
Path gamesDir = Files.createDirectories(tempDir.resolve("games"));
|
||||
Files.writeString(gamesDir.resolve("screenshot.png"), "png data");
|
||||
|
||||
StaticResource sr = new StaticResource();
|
||||
sr.dataDir = tempDir.toString();
|
||||
|
||||
Response resp = sr.getGameFile("screenshot.png");
|
||||
assertEquals(200, resp.getStatus());
|
||||
assertNotNull(resp.getMediaType());
|
||||
assertEquals("image/png", resp.getMediaType().toString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getGameFile_webpContentType(@TempDir Path tempDir) throws Exception {
|
||||
Path gamesDir = Files.createDirectories(tempDir.resolve("games"));
|
||||
Files.writeString(gamesDir.resolve("image.webp"), "webp data");
|
||||
|
||||
StaticResource sr = new StaticResource();
|
||||
sr.dataDir = tempDir.toString();
|
||||
|
||||
Response resp = sr.getGameFile("image.webp");
|
||||
assertEquals(200, resp.getStatus());
|
||||
assertNotNull(resp.getMediaType());
|
||||
assertEquals("image/webp", resp.getMediaType().toString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getGameFile_gifContentType(@TempDir Path tempDir) throws Exception {
|
||||
Path gamesDir = Files.createDirectories(tempDir.resolve("games"));
|
||||
Files.writeString(gamesDir.resolve("anim.gif"), "gif data");
|
||||
|
||||
StaticResource sr = new StaticResource();
|
||||
sr.dataDir = tempDir.toString();
|
||||
|
||||
Response resp = sr.getGameFile("anim.gif");
|
||||
assertEquals(200, resp.getStatus());
|
||||
assertNotNull(resp.getMediaType());
|
||||
assertEquals("image/gif", resp.getMediaType().toString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getGameFile_jsonContentType(@TempDir Path tempDir) throws Exception {
|
||||
Path gamesDir = Files.createDirectories(tempDir.resolve("games"));
|
||||
Files.writeString(gamesDir.resolve("data.json"), "{}");
|
||||
|
||||
StaticResource sr = new StaticResource();
|
||||
sr.dataDir = tempDir.toString();
|
||||
|
||||
Response resp = sr.getGameFile("data.json");
|
||||
assertEquals(200, resp.getStatus());
|
||||
assertNotNull(resp.getMediaType());
|
||||
assertEquals("application/json", resp.getMediaType().toString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getGameFile_exeContentType(@TempDir Path tempDir) throws Exception {
|
||||
Path gamesDir = Files.createDirectories(tempDir.resolve("games"));
|
||||
Files.writeString(gamesDir.resolve("game.exe"), "MZ");
|
||||
|
||||
StaticResource sr = new StaticResource();
|
||||
sr.dataDir = tempDir.toString();
|
||||
|
||||
Response resp = sr.getGameFile("game.exe");
|
||||
assertEquals(200, resp.getStatus());
|
||||
assertNotNull(resp.getMediaType());
|
||||
assertEquals("application/octet-stream", resp.getMediaType().toString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getGameFile_pathTraversal_returns403(@TempDir Path tempDir) {
|
||||
StaticResource sr = new StaticResource();
|
||||
sr.dataDir = tempDir.toString();
|
||||
|
||||
Response resp = sr.getGameFile("../etc/passwd");
|
||||
assertEquals(403, resp.getStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getGameFile_nonExistentFile_returns404(@TempDir Path tempDir) throws Exception {
|
||||
Files.createDirectories(tempDir.resolve("games"));
|
||||
|
||||
StaticResource sr = new StaticResource();
|
||||
sr.dataDir = tempDir.toString();
|
||||
|
||||
Response resp = sr.getGameFile("nonexistent.exe");
|
||||
assertEquals(404, resp.getStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getArtwork_contentTypeAndCacheHeader(@TempDir Path tempDir) throws Exception {
|
||||
Path artworkDir = Files.createDirectories(tempDir.resolve("artwork"));
|
||||
Files.writeString(artworkDir.resolve("cover.jpg"), "image data");
|
||||
|
||||
StaticResource sr = new StaticResource();
|
||||
sr.dataDir = tempDir.toString();
|
||||
|
||||
Response resp = sr.getArtwork("cover.jpg");
|
||||
assertEquals(200, resp.getStatus());
|
||||
assertNotNull(resp.getMediaType());
|
||||
assertEquals("image/jpeg", resp.getMediaType().toString());
|
||||
assertTrue(resp.getHeaders().containsKey("Cache-Control"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getArtwork_pathTraversal_returns403(@TempDir Path tempDir) {
|
||||
StaticResource sr = new StaticResource();
|
||||
sr.dataDir = tempDir.toString();
|
||||
|
||||
Response resp = sr.getArtwork("../../etc/passwd");
|
||||
assertEquals(403, resp.getStatus());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
package org.dostalgia;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipInputStream;
|
||||
import java.util.zip.ZipOutputStream;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class ZipServiceTest {
|
||||
|
||||
private final ZipService zip = new ZipService();
|
||||
|
||||
// Wire the config dependency manually (no CDI)
|
||||
{
|
||||
zip.config = new ConfigBuilder();
|
||||
}
|
||||
|
||||
@Test
|
||||
void unzip_extractsFiles(@TempDir Path dir) throws Exception {
|
||||
Path zipFile = dir.resolve("test.zip");
|
||||
Path dest = dir.resolve("output");
|
||||
|
||||
try (ZipOutputStream zos = new ZipOutputStream(Files.newOutputStream(zipFile))) {
|
||||
zos.putNextEntry(new ZipEntry("game.exe"));
|
||||
zos.write("MZ".getBytes());
|
||||
zos.closeEntry();
|
||||
zos.putNextEntry(new ZipEntry("readme.txt"));
|
||||
zos.write("Hello".getBytes());
|
||||
zos.closeEntry();
|
||||
}
|
||||
|
||||
zip.unzip(zipFile, dest);
|
||||
assertTrue(Files.exists(dest.resolve("game.exe")));
|
||||
assertTrue(Files.exists(dest.resolve("readme.txt")));
|
||||
assertEquals("MZ", Files.readString(dest.resolve("game.exe")));
|
||||
assertEquals("Hello", Files.readString(dest.resolve("readme.txt")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void unzip_handlesSubdirectories(@TempDir Path dir) throws Exception {
|
||||
Path zipFile = dir.resolve("test.zip");
|
||||
Path dest = dir.resolve("output");
|
||||
|
||||
try (ZipOutputStream zos = new ZipOutputStream(Files.newOutputStream(zipFile))) {
|
||||
zos.putNextEntry(new ZipEntry("subdir/game.exe"));
|
||||
zos.write("MZ".getBytes());
|
||||
zos.closeEntry();
|
||||
zos.putNextEntry(new ZipEntry("subdir/nested/deep.txt"));
|
||||
zos.write("deep".getBytes());
|
||||
zos.closeEntry();
|
||||
}
|
||||
|
||||
zip.unzip(zipFile, dest);
|
||||
assertTrue(Files.exists(dest.resolve("subdir/game.exe")));
|
||||
assertTrue(Files.exists(dest.resolve("subdir/nested/deep.txt")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void unzip_pathTraversal_skipsMaliciousEntries(@TempDir Path dir) throws Exception {
|
||||
Path zipFile = dir.resolve("test.zip");
|
||||
Path dest = dir.resolve("output");
|
||||
Files.createDirectories(dest);
|
||||
|
||||
try (ZipOutputStream zos = new ZipOutputStream(Files.newOutputStream(zipFile))) {
|
||||
zos.putNextEntry(new ZipEntry("../evil.txt"));
|
||||
zos.write("malicious".getBytes());
|
||||
zos.closeEntry();
|
||||
zos.putNextEntry(new ZipEntry("good.txt"));
|
||||
zos.write("good".getBytes());
|
||||
zos.closeEntry();
|
||||
}
|
||||
|
||||
zip.unzip(zipFile, dest);
|
||||
// Malicious entry should be skipped (path traversal)
|
||||
assertFalse(Files.exists(dir.resolve("evil.txt")));
|
||||
// Good entry should be extracted
|
||||
assertTrue(Files.exists(dest.resolve("good.txt")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void unzip_directoryEntries_createDirs(@TempDir Path dir) throws Exception {
|
||||
Path zipFile = dir.resolve("test.zip");
|
||||
Path dest = dir.resolve("output");
|
||||
|
||||
try (ZipOutputStream zos = new ZipOutputStream(Files.newOutputStream(zipFile))) {
|
||||
zos.putNextEntry(new ZipEntry("adir/"));
|
||||
zos.closeEntry();
|
||||
zos.putNextEntry(new ZipEntry("adir/file.txt"));
|
||||
zos.write("data".getBytes());
|
||||
zos.closeEntry();
|
||||
}
|
||||
|
||||
zip.unzip(zipFile, dest);
|
||||
assertTrue(Files.isDirectory(dest.resolve("adir")));
|
||||
assertTrue(Files.exists(dest.resolve("adir/file.txt")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void flattenSingleDir_noOpWhenMultipleDirs(@TempDir Path dir) throws Exception {
|
||||
Files.createDirectories(dir.resolve("sub1"));
|
||||
Files.createDirectories(dir.resolve("sub2"));
|
||||
Files.writeString(dir.resolve("sub1/file.txt"), "data");
|
||||
|
||||
zip.flattenSingleDir(dir);
|
||||
assertTrue(Files.exists(dir.resolve("sub1/file.txt")));
|
||||
assertTrue(Files.isDirectory(dir.resolve("sub1")));
|
||||
assertTrue(Files.isDirectory(dir.resolve("sub2")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void flattenSingleDir_noOpWhenNoSingleDir(@TempDir Path dir) throws Exception {
|
||||
Files.writeString(dir.resolve("file.txt"), "data");
|
||||
Files.writeString(dir.resolve("other.txt"), "data");
|
||||
|
||||
zip.flattenSingleDir(dir);
|
||||
assertTrue(Files.exists(dir.resolve("file.txt")));
|
||||
assertTrue(Files.exists(dir.resolve("other.txt")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void flattenSingleDir_flattensOneDir(@TempDir Path dir) throws Exception {
|
||||
Path single = Files.createDirectories(dir.resolve("single"));
|
||||
Files.createDirectories(single.resolve("nested"));
|
||||
Files.writeString(single.resolve("file.txt"), "data");
|
||||
Files.writeString(single.resolve("nested/deep.txt"), "deep");
|
||||
|
||||
zip.flattenSingleDir(dir);
|
||||
// Files should now be directly under dir
|
||||
assertTrue(Files.exists(dir.resolve("file.txt")));
|
||||
assertTrue(Files.exists(dir.resolve("nested/deep.txt")));
|
||||
// The intermediate single dir should be gone
|
||||
assertFalse(Files.exists(single));
|
||||
}
|
||||
|
||||
@Test
|
||||
void flattenSingleDir_emptyDir_noOp(@TempDir Path dir) throws Exception {
|
||||
zip.flattenSingleDir(dir);
|
||||
assertTrue(Files.exists(dir));
|
||||
}
|
||||
|
||||
@Test
|
||||
void createBundle_createsValidJsdosZip(@TempDir Path dir) throws Exception {
|
||||
Path extractDir = dir.resolve("extract");
|
||||
Files.createDirectories(extractDir.resolve("sub"));
|
||||
Files.writeString(extractDir.resolve("game.exe"), "MZ");
|
||||
Files.writeString(extractDir.resolve("sub/data.bin"), "binary");
|
||||
Files.writeString(extractDir.resolve("readme.txt"), "info");
|
||||
|
||||
Path bundlePath = dir.resolve("output.jsdos");
|
||||
|
||||
zip.createBundle(extractDir, extractDir.resolve("game.exe").toString(), List.of(), bundlePath);
|
||||
|
||||
assertTrue(Files.exists(bundlePath));
|
||||
assertTrue(Files.size(bundlePath) > 0);
|
||||
|
||||
try (ZipInputStream zis = new ZipInputStream(Files.newInputStream(bundlePath))) {
|
||||
boolean hasGameExe = false;
|
||||
boolean hasJsdosDir = false;
|
||||
boolean hasJsdosConf = false;
|
||||
boolean hasJsdosJson = false;
|
||||
boolean hasDataBin = false;
|
||||
ZipEntry entry;
|
||||
while ((entry = zis.getNextEntry()) != null) {
|
||||
String name = entry.getName();
|
||||
if (name.equals("game.exe")) hasGameExe = true;
|
||||
if (name.startsWith(".jsdos/")) hasJsdosDir = true;
|
||||
if (name.equals(".jsdos/dosbox.conf")) hasJsdosConf = true;
|
||||
if (name.equals(".jsdos/jsdos.json")) hasJsdosJson = true;
|
||||
if (name.equals("sub/data.bin")) hasDataBin = true;
|
||||
zis.closeEntry();
|
||||
}
|
||||
assertTrue(hasGameExe, "Should contain game.exe");
|
||||
assertTrue(hasJsdosDir, "Should contain .jsdos/ entries");
|
||||
assertTrue(hasJsdosConf, "Should contain .jsdos/dosbox.conf");
|
||||
assertTrue(hasJsdosJson, "Should contain .jsdos/jsdos.json");
|
||||
assertTrue(hasDataBin, "Should contain sub/data.bin");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void createBundle_respectsSkipExt(@TempDir Path dir) throws Exception {
|
||||
Path extractDir = dir.resolve("extract");
|
||||
Files.createDirectories(extractDir);
|
||||
Files.writeString(extractDir.resolve("game.exe"), "MZ");
|
||||
Files.writeString(extractDir.resolve("cdimage.nrg"), "image");
|
||||
Files.writeString(extractDir.resolve("cdimage.mdf"), "image");
|
||||
|
||||
Path bundlePath = dir.resolve("output.jsdos");
|
||||
|
||||
zip.createBundle(extractDir, extractDir.resolve("game.exe").toString(), List.of(), bundlePath);
|
||||
|
||||
try (ZipInputStream zis = new ZipInputStream(Files.newInputStream(bundlePath))) {
|
||||
ZipEntry entry;
|
||||
while ((entry = zis.getNextEntry()) != null) {
|
||||
String name = entry.getName();
|
||||
assertNotEquals("cdimage.nrg", name, "Should not contain .nrg");
|
||||
assertNotEquals("cdimage.mdf", name, "Should not contain .mdf");
|
||||
zis.closeEntry();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void createBundle_noExe_createsCdOnlyConfig(@TempDir Path dir) throws Exception {
|
||||
Path extractDir = dir.resolve("extract");
|
||||
Files.createDirectories(extractDir);
|
||||
Files.writeString(extractDir.resolve("somefile.bin"), "data");
|
||||
|
||||
Path bundlePath = dir.resolve("output.jsdos");
|
||||
|
||||
// null exePath triggers CD-only config generation
|
||||
zip.createBundle(extractDir, null, List.of("cd.iso"), bundlePath);
|
||||
|
||||
try (ZipInputStream zis = new ZipInputStream(Files.newInputStream(bundlePath))) {
|
||||
boolean hasJsdosConf = false;
|
||||
ZipEntry entry;
|
||||
while ((entry = zis.getNextEntry()) != null) {
|
||||
if (entry.getName().equals(".jsdos/dosbox.conf")) hasJsdosConf = true;
|
||||
zis.closeEntry();
|
||||
}
|
||||
assertTrue(hasJsdosConf, "CD-only bundle should still have dosbox.conf");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user