Initial commit

This commit is contained in:
2026-06-11 17:28:46 +02:00
commit a61f3a9c33
59 changed files with 10789 additions and 0 deletions
+15
View File
@@ -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
+10
View File
@@ -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
+45
View File
@@ -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
View File
@@ -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
View File
@@ -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"]
+21
View File
@@ -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.
+134
View File
@@ -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
![library](images/library.jpeg "Library")
### Game detail view
![game](images/game.jpeg "Game")
## 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)
+14
View File
@@ -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
+16
View File
@@ -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>
+3087
View File
File diff suppressed because it is too large Load Diff
+21
View File
@@ -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"
}
}
+71
View File
@@ -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>
+250
View File
@@ -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;
}
+118
View File
@@ -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>
+72
View File
@@ -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");
});
});
+68
View File
@@ -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");
});
});
+33
View File
@@ -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");
});
});
});
+118
View File
@@ -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`;
}
+9
View File
@@ -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);
}
+8
View File
@@ -0,0 +1,8 @@
import App from "./App.svelte";
import { mount } from "svelte";
const app = mount(App, {
target: document.getElementById("app"),
});
export default app;
+714
View File
@@ -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>
+850
View File
@@ -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>
+300
View File
@@ -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);
});
});
});
});
+77
View File
@@ -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();
});
});
});
+5
View File
@@ -0,0 +1,5 @@
export default {
compilerOptions: {
runes: true,
},
};
+27
View File
@@ -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"],
},
});
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 262 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 314 KiB

+155
View File
@@ -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;
}
}
+126
View File
@@ -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);
}
}
+161
View File
@@ -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);
}
}
}
+16
View File
@@ -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());
}
}
+154
View File
@@ -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");
}
}
}