diff --git a/.gitignore b/.gitignore
index 3191820..397063d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,8 +1,17 @@
# Dependencies
node_modules/
-frontend/dist/
frontend/node_modules/
+# Built output
+frontend/dist/
+dist/
+
+# Maven / Java
+target/
+*.class
+*.jar
+*.war
+
# Runtime data
data/games/*
data/artwork/*
@@ -11,8 +20,12 @@ data/saves/*
!data/artwork/.gitkeep
!data/saves/.gitkeep
-# Binary
-dostalgia
+# IDE
+.vscode/
+.idea/
+*.iml
+*.ipr
+*.iws
# Test/upload artifacts
*.zip
@@ -20,6 +33,6 @@ dostalgia
*.jsdos
*.JSDOS
-# IDE
-.vscode/
-.idea/
+# OS
+.DS_Store
+Thumbs.db
diff --git a/Dockerfile b/Dockerfile
index b651953..97b7d5f 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,4 +1,4 @@
-# Dostalgia — Multi-stage Docker build
+# Dostalgia — Quarkus multi-stage build
# ─── 1. Build frontend ───────────────────────────
FROM node:20-bookworm AS frontend
@@ -6,22 +6,23 @@ WORKDIR /build
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci 2>/dev/null || npm install --legacy-peer-deps
COPY frontend/ ./
-RUN npx vite build
+RUN npm run build
-# ─── 2. Build Go binary ──────────────────────────
-FROM golang:1.23-bookworm AS gobuild
+# ─── 2. Build Quarkus app ────────────────────────
+FROM maven:3-eclipse-temurin-21 AS build
WORKDIR /build
-COPY go.mod ./
-COPY *.go ./
+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 CGO_ENABLED=0 go build -o /dostalgia .
+RUN mvn package -DskipTests -q
# ─── 3. Runtime ──────────────────────────────────
-FROM debian:bookworm-slim
-RUN apt-get update -qq && apt-get install -y -qq --no-install-recommends ca-certificates tzdata && rm -rf /var/lib/apt/lists/*
-COPY --from=gobuild /dostalgia /dostalgia
-VOLUME /data
+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=/data
-ENV PORT=8765
-ENTRYPOINT ["/dostalgia"]
+ENTRYPOINT ["java", "-jar", "quarkus-run.jar"]
diff --git a/README.md b/README.md
index 5de04a3..8e0a4b2 100644
--- a/README.md
+++ b/README.md
@@ -1,34 +1,44 @@
-# Dostalgia
+# Dostalgia — Quarkus Edition
-A nostalgic DOS game hub — upload, scrape artwork, and play classic DOS games directly in the browser via js-dos.
+A nostalgic DOS game hub. Upload your old DOS games, scrape artwork, and play them directly in the browser via js-dos (WebAssembly DOSBox).
-## Quick Start (Docker)
+## Quick Start
```bash
+# Build and run with Docker
docker build -t dostalgia .
docker run -p 8765:8765 -v $(pwd)/data:/data dostalgia
```
-## Development
+Open http://localhost:8765
-Terminal 1 — Go API:
-```bash
-DOSTALGIA_DEV=1 go run .
-```
+## Development (without Docker)
-Terminal 2 — Frontend dev server:
+Terminal 1 — Frontend dev server:
```bash
cd frontend && npm install && npm run dev
```
-Open http://localhost:5173
+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**: Single Go binary. No database — each game is a directory with a `game.json` file.
-- **Frontend**: Svelte SPA, embedded in the Go binary at build time.
-- **Emulation**: js-dos v8 loaded from CDN, runs DOSBox in WebAssembly.
-- **Storage**: `/data/games/{id}/` — contains `game.json`, `.jsdos` bundle, and cover art.
+- **Backend**: Quarkus (Java 21, JAX-RS) — ~5 REST resource classes, zero 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`
## API
@@ -51,8 +61,6 @@ Open http://localhost:5173
"year": 1993,
"genre": "FPS",
"developer": "id Software",
- "publisher": "id Software",
- "description": "The iconic first-person shooter...",
"bundle_type": "standard",
"bundle_file": "doom.jsdos",
"has_cover": true,
diff --git a/docker-compose.yml b/docker-compose.yml
index a83710e..dbdcdba 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -8,7 +8,6 @@ services:
- ./data:/data
environment:
- DOSTALGIA_DATA=/data
- - PORT=8765
# IGDB credentials (optional):
# - TWITCH_CLIENT_ID=your_client_id
# - TWITCH_CLIENT_SECRET=your_client_secret
diff --git a/game.go b/game.go
deleted file mode 100644
index 9ad0fad..0000000
--- a/game.go
+++ /dev/null
@@ -1,156 +0,0 @@
-package main
-
-import (
- "encoding/json"
- "os"
- "path/filepath"
- "sort"
- "time"
-)
-
-// Game represents the metadata for a single DOS game.
-// Stored as game.json in data/games/{id}/
-type Game struct {
- ID string `json:"id"`
- Title string `json:"title"`
- Year int `json:"year,omitempty"`
- Genre string `json:"genre,omitempty"`
- Developer string `json:"developer,omitempty"`
- Publisher string `json:"publisher,omitempty"`
- Description string `json:"description,omitempty"`
- Rating float64 `json:"rating,omitempty"`
- Platforms []string `json:"platforms,omitempty"`
-
- // Bundle info
- BundleType string `json:"bundle_type,omitempty"` // "standard" or "sockdrive"
- BundleFile string `json:"bundle_file,omitempty"` // path relative to game dir
-
- // IGDB
- IGDBID int `json:"igdb_id,omitempty"`
- IGDBCoverID string `json:"igdb_cover_id,omitempty"`
-
- // Local file hints (cover exists if cover.jpg/png/webp is present)
- HasCover bool `json:"has_cover"`
- HasScreenshot bool `json:"has_screenshots"`
- Screenshots []string `json:"screenshots,omitempty"`
-
- Ready bool `json:"ready"`
- CreatedAt time.Time `json:"created_at"`
- UpdatedAt time.Time `json:"updated_at"`
-}
-
-// gameDir returns the directory for a game by ID.
-func gameDir(id string) string {
- return filepath.Join(dataDir, "games", id)
-}
-
-// gameJSONPath returns the path to a game's game.json.
-func gameJSONPath(id string) string {
- return filepath.Join(gameDir(id), "game.json")
-}
-
-// loadGame reads a game.json file and returns the Game.
-func loadGame(id string) (*Game, error) {
- path := gameJSONPath(id)
- data, err := os.ReadFile(path)
- if err != nil {
- return nil, err
- }
- g := &Game{}
- if err := json.Unmarshal(data, g); err != nil {
- return nil, err
- }
-
- // Detect if cover file exists
- g.HasCover = fileExists(filepath.Join(gameDir(id), "cover.jpg")) ||
- fileExists(filepath.Join(gameDir(id), "cover.png")) ||
- fileExists(filepath.Join(gameDir(id), "cover.webp"))
-
- return g, nil
-}
-
-// saveGame writes a game's metadata to its game.json.
-func saveGame(g *Game) error {
- g.UpdatedAt = time.Now()
- if g.CreatedAt.IsZero() {
- g.CreatedAt = time.Now()
- }
-
- dir := gameDir(g.ID)
- if err := os.MkdirAll(dir, 0755); err != nil {
- return err
- }
-
- data, err := json.MarshalIndent(g, "", " ")
- if err != nil {
- return err
- }
- return os.WriteFile(gameJSONPath(g.ID), data, 0644)
-}
-
-// listGames scans the games directory and returns all games, sorted by title.
-func listGames() ([]*Game, error) {
- gamesDir := filepath.Join(dataDir, "games")
- entries, err := os.ReadDir(gamesDir)
- if err != nil {
- if os.IsNotExist(err) {
- return []*Game{}, nil
- }
- return nil, err
- }
-
- var games []*Game
- if len(entries) > 0 {
- games = make([]*Game, 0, len(entries))
- } else {
- games = make([]*Game, 0)
- }
- for _, e := range entries {
- if !e.IsDir() {
- continue
- }
- g, err := loadGame(e.Name())
- if err != nil {
- continue // skip broken entries
- }
- games = append(games, g)
- }
-
- sort.Slice(games, func(i, j int) bool {
- return games[i].Title < games[j].Title
- })
-
- return games, nil
-}
-
-// fileExists checks if a file exists.
-func fileExists(path string) bool {
- _, err := os.Stat(path)
- return err == nil
-}
-
-// sanitizeID cleans a string for use as a game directory name.
-func sanitizeID(s string) string {
- result := make([]byte, 0, len(s))
- for i := 0; i < len(s); i++ {
- c := s[i]
- if (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') {
- result = append(result, c)
- } else if c >= 'A' && c <= 'Z' {
- result = append(result, c+32) // lowercase
- } else if c == ' ' || c == '-' || c == '_' {
- result = append(result, '-')
- }
- }
- // Trim leading/trailing hyphens
- for len(result) > 0 && result[0] == '-' {
- result = result[1:]
- }
- for len(result) > 0 && result[len(result)-1] == '-' {
- result = result[:len(result)-1]
- }
- if len(result) == 0 {
- return "unknown"
- }
- return string(result)
-}
diff --git a/go.mod b/go.mod
deleted file mode 100644
index aa177a2..0000000
--- a/go.mod
+++ /dev/null
@@ -1,3 +0,0 @@
-module dostalgia
-
-go 1.23.9
diff --git a/igdb.go b/igdb.go
deleted file mode 100644
index 6204a49..0000000
--- a/igdb.go
+++ /dev/null
@@ -1,29 +0,0 @@
-package main
-
-import "net/http"
-
-// handleIGDBSearch handles GET /api/igdb/search — placeholder.
-// Once IGDB credentials are configured, this will query the Twitch API.
-func handleIGDBSearch(w http.ResponseWriter, r *http.Request) {
- query := r.URL.Query().Get("q")
- if query == "" {
- http.Error(w, "Query parameter 'q' required", http.StatusBadRequest)
- return
- }
-
- // TODO: Implement real IGDB search.
- // Requires TWITCH_CLIENT_ID and TWITCH_CLIENT_SECRET environment variables.
- //
- // Flow:
- // 1. POST https://id.twitch.tv/oauth2/token for access token
- // 2. POST https://api.igdb.com/v4/games with body: search "{query}"; fields name,cover.*,genres.*,screenshots.*;
- // 3. Resolve cover URLs → https://images.igdb.com/igdb/image/upload/t_cover_big/{image_id}.jpg
- // 4. Return matches
-
- writeJSON(w, http.StatusOK, []map[string]string{
- {
- "message": "IGDB integration not yet configured. Set TWITCH_CLIENT_ID and TWITCH_CLIENT_SECRET env vars.",
- "query": query,
- },
- })
-}
diff --git a/main.go b/main.go
deleted file mode 100644
index 288eb13..0000000
--- a/main.go
+++ /dev/null
@@ -1,310 +0,0 @@
-package main
-
-import (
- "embed"
- "encoding/json"
- "io/fs"
- "log"
- "net/http"
- "os"
- "path/filepath"
- "strings"
-)
-
-//go:embed all:frontend/dist
-var frontendFS embed.FS
-
-var (
- dataDir string
- devMode bool
-)
-
-func main() {
- // Determine data directory
- dataDir = os.Getenv("DOSTALGIA_DATA")
- if dataDir == "" {
- dataDir = "data"
- }
- devMode = os.Getenv("DOSTALGIA_DEV") == "1"
-
- // Ensure data directories exist
- for _, d := range []string{"games", "artwork", "saves"} {
- os.MkdirAll(filepath.Join(dataDir, d), 0755)
- }
-
- mux := http.NewServeMux()
-
- // API routes
- mux.HandleFunc("/api/health", handleHealth)
- mux.HandleFunc("/api/games", handleGames)
- mux.HandleFunc("/api/games/", handleGameByID)
- mux.HandleFunc("/api/upload", handleUploadGame)
- mux.HandleFunc("/api/igdb/search", handleIGDBSearch)
-
- // Serve game bundles and artwork as static files
- gameFS := http.FileServer(http.Dir(filepath.Join(dataDir, "games")))
- mux.Handle("/games/", http.StripPrefix("/games/", gameFS))
-
- artFS := http.FileServer(http.Dir(filepath.Join(dataDir, "artwork")))
- mux.Handle("/artwork/", http.StripPrefix("/artwork/", artFS))
-
- // Sockdrive file server
- mux.HandleFunc("/api/sockdrive/", handleSockdrive)
-
- // Frontend SPA — serve embedded files, fall back to index.html
- mux.HandleFunc("/", handleFrontend)
-
- port := os.Getenv("PORT")
- if port == "" {
- port = "8765"
- }
-
- log.Printf("🎮 Dostalgia starting on :%s (data: %s)", port, dataDir)
- if devMode {
- log.Println("⚠️ DEV mode — run Vite dev server alongside for hot reload")
- }
-
- if err := http.ListenAndServe(":"+port, mux); err != nil {
- log.Fatal(err)
- }
-}
-
-// ─── API Handlers ────────────────────────────────────────────────
-
-func handleHealth(w http.ResponseWriter, r *http.Request) {
- writeJSON(w, http.StatusOK, map[string]string{
- "status": "ok",
- "version": "0.1.0",
- })
-}
-
-func handleGames(w http.ResponseWriter, r *http.Request) {
- switch r.Method {
- case http.MethodGet:
- games, err := listGames()
- if err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- return
- }
- writeJSON(w, http.StatusOK, map[string]interface{}{
- "games": games,
- "total": len(games),
- })
-
- case http.MethodPost:
- // Create a game manually (without upload)
- var g Game
- if err := json.NewDecoder(r.Body).Decode(&g); err != nil {
- http.Error(w, "Invalid JSON", http.StatusBadRequest)
- return
- }
- if g.Title == "" {
- http.Error(w, "Title is required", http.StatusBadRequest)
- return
- }
- if g.ID == "" {
- g.ID = sanitizeID(g.Title)
- }
- if err := saveGame(&g); err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- return
- }
- writeJSON(w, http.StatusCreated, g)
-
- default:
- http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
- }
-}
-
-func handleGameByID(w http.ResponseWriter, r *http.Request) {
- // Extract ID from /api/games/{id} or /api/games/{id}/...
- path := strings.TrimPrefix(r.URL.Path, "/api/games/")
- parts := strings.SplitN(path, "/", 2)
- id := parts[0]
-
- if id == "" {
- http.Error(w, "Game ID required", http.StatusBadRequest)
- return
- }
-
- // Cover upload sub-endpoint
- if len(parts) > 1 && parts[1] == "cover" {
- handleUploadCover(w, r)
- return
- }
-
- switch r.Method {
- case http.MethodGet:
- g, err := loadGame(id)
- if err != nil {
- http.Error(w, "Game not found", http.StatusNotFound)
- return
- }
- writeJSON(w, http.StatusOK, g)
-
- case http.MethodPatch:
- g, err := loadGame(id)
- if err != nil {
- http.Error(w, "Game not found", http.StatusNotFound)
- return
- }
- var updates map[string]interface{}
- if err := json.NewDecoder(r.Body).Decode(&updates); err != nil {
- http.Error(w, "Invalid JSON", http.StatusBadRequest)
- return
- }
- // Apply simple string/number fields
- if v, ok := updates["title"].(string); ok {
- g.Title = v
- }
- if v, ok := updates["year"].(float64); ok {
- g.Year = int(v)
- }
- if v, ok := updates["genre"].(string); ok {
- g.Genre = v
- }
- if v, ok := updates["developer"].(string); ok {
- g.Developer = v
- }
- if v, ok := updates["publisher"].(string); ok {
- g.Publisher = v
- }
- if v, ok := updates["description"].(string); ok {
- g.Description = v
- }
- if v, ok := updates["rating"].(float64); ok {
- g.Rating = v
- }
- if err := saveGame(g); err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- return
- }
- writeJSON(w, http.StatusOK, g)
-
- case http.MethodDelete:
- g, err := loadGame(id)
- if err != nil {
- http.Error(w, "Game not found", http.StatusNotFound)
- return
- }
- // Remove the game directory (game.json, cover, sockdrive files)
- os.RemoveAll(gameDir(g.ID))
- // Also remove the flat .jsdos bundle if it exists
- bundlePath := filepath.Join(dataDir, "games", g.ID+".jsdos")
- os.Remove(bundlePath)
- writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
-
- default:
- http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
- }
-}
-
-// ─── Sockdrive File Server ──────────────────────────────────────
-
-func handleSockdrive(w http.ResponseWriter, r *http.Request) {
- // /api/sockdrive/{slug}/file?path=...
- path := strings.TrimPrefix(r.URL.Path, "/api/sockdrive/")
- parts := strings.SplitN(path, "/", 2)
- if len(parts) < 2 {
- http.Error(w, "Invalid path", http.StatusBadRequest)
- return
- }
- slug := parts[0]
-
- if parts[1] == "file" {
- filePath := r.URL.Query().Get("path")
- if filePath == "" {
- http.Error(w, "path query parameter required", http.StatusBadRequest)
- return
- }
- fullPath := filepath.Join(gameDir(slug), filePath)
- // Prevent traversal
- fullPath = filepath.Clean(fullPath)
- if !strings.HasPrefix(fullPath, filepath.Clean(gameDir(slug))) {
- http.Error(w, "Forbidden", http.StatusForbidden)
- return
- }
- http.ServeFile(w, r, fullPath)
- return
- }
-
- if parts[1] == "files" {
- // List all files in game directory
- var files []map[string]interface{}
- gameDirPath := gameDir(slug)
- filepath.WalkDir(gameDirPath, func(path string, d fs.DirEntry, err error) error {
- if err != nil || d.IsDir() {
- return nil
- }
- info, _ := d.Info()
- rel, _ := filepath.Rel(gameDirPath, path)
- files = append(files, map[string]interface{}{
- "path": strings.ReplaceAll(rel, "\\", "/"),
- "size": info.Size(),
- })
- return nil
- })
- writeJSON(w, http.StatusOK, map[string]interface{}{
- "files": files,
- "total": len(files),
- })
- return
- }
-
- http.Error(w, "Not found", http.StatusNotFound)
-}
-
-// ─── Frontend SPA ───────────────────────────────────────────────
-
-func handleFrontend(w http.ResponseWriter, r *http.Request) {
- // Don't intercept API or static routes
- if strings.HasPrefix(r.URL.Path, "/api/") ||
- strings.HasPrefix(r.URL.Path, "/games/") ||
- strings.HasPrefix(r.URL.Path, "/artwork/") {
- http.NotFound(w, r)
- return
- }
-
- var content fs.FS
- var err error
-
- if devMode {
- content = os.DirFS("frontend/dist")
- } else {
- content, err = fs.Sub(frontendFS, "frontend/dist")
- if err != nil {
- http.Error(w, "Frontend not built", http.StatusServiceUnavailable)
- return
- }
- }
-
- // Try to serve the exact file
- filePath := strings.TrimPrefix(r.URL.Path, "/")
- if filePath == "" {
- filePath = "index.html"
- }
-
- f, err := content.Open(filePath)
- if err == nil {
- f.Close()
- http.FileServer(http.FS(content)).ServeHTTP(w, r)
- return
- }
-
- // Fallback to index.html for SPA routing
- indexData, err := fs.ReadFile(content, "index.html")
- if err != nil {
- http.Error(w, "Frontend not built. Run 'cd frontend && npm run build'", http.StatusServiceUnavailable)
- return
- }
- w.Header().Set("Content-Type", "text/html; charset=utf-8")
- w.Write(indexData)
-}
-
-// ─── Helpers ────────────────────────────────────────────────────
-
-func writeJSON(w http.ResponseWriter, status int, data interface{}) {
- w.Header().Set("Content-Type", "application/json")
- w.WriteHeader(status)
- json.NewEncoder(w).Encode(data)
-}
diff --git a/pom.xml b/pom.xml
new file mode 100644
index 0000000..596518a
--- /dev/null
+++ b/pom.xml
@@ -0,0 +1,99 @@
+
+
+ 4.0.0
+
+ com.dostalgia
+ dostalgia
+ 0.1.0
+
+
+ 21
+ 21
+ 21
+ UTF-8
+ 3.19.2
+ io.quarkus.platform
+
+
+
+
+
+ ${quarkus.platform.group-id}
+ quarkus-bom
+ ${quarkus.platform.version}
+ pom
+ import
+
+
+
+
+
+
+
+ io.quarkus
+ quarkus-rest
+
+
+
+ io.quarkus
+ quarkus-rest-jackson
+
+
+
+ io.quarkus
+ quarkus-rest-multipart
+
+
+
+ io.quarkus
+ quarkus-vertx-http
+
+
+
+ io.quarkus
+ quarkus-arc
+
+
+
+
+
+
+ ${quarkus.platform.group-id}
+ quarkus-maven-plugin
+ ${quarkus.platform.version}
+ true
+
+
+
+ build
+ generate-code
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-resources-plugin
+
+
+ copy-frontend
+ generate-resources
+ copy-resources
+
+ ${project.build.directory}/classes/META-INF/resources
+
+
+ frontend/dist
+ false
+
+
+
+
+
+
+
+
+
diff --git a/src/main/java/com/dostalgia/Game.java b/src/main/java/com/dostalgia/Game.java
new file mode 100644
index 0000000..f69a0c2
--- /dev/null
+++ b/src/main/java/com/dostalgia/Game.java
@@ -0,0 +1,106 @@
+package com.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 List platforms;
+
+ private String bundleType; // "standard" or "sockdrive"
+ private String bundleFile; // relative path within game dir
+
+ private Integer igdbId;
+ private String igdbCoverId;
+
+ private boolean hasCover;
+ private boolean hasScreenshots;
+ private List screenshots;
+
+ private boolean ready;
+ private Instant createdAt;
+ private Instant updatedAt;
+
+ // ─── Constructors ────────────────────────────────────────────
+
+ 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();
+ }
+
+ // ─── Getters & Setters ──────────────────────────────────────
+
+ 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 List getPlatforms() { return platforms; }
+ public void setPlatforms(List platforms) { this.platforms = platforms; }
+
+ public String getBundleType() { return bundleType; }
+ public void setBundleType(String bundleType) { this.bundleType = bundleType; }
+
+ public String getBundleFile() { return bundleFile; }
+ public void setBundleFile(String bundleFile) { this.bundleFile = bundleFile; }
+
+ 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 getScreenshots() { return screenshots; }
+ public void setScreenshots(List screenshots) { this.screenshots = screenshots; }
+
+ 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; }
+}
diff --git a/src/main/java/com/dostalgia/GameResource.java b/src/main/java/com/dostalgia/GameResource.java
new file mode 100644
index 0000000..94e7e12
--- /dev/null
+++ b/src/main/java/com/dostalgia/GameResource.java
@@ -0,0 +1,113 @@
+package com.dostalgia;
+
+import jakarta.inject.Inject;
+import jakarta.ws.rs.*;
+import jakarta.ws.rs.core.MediaType;
+import jakarta.ws.rs.core.Response;
+import java.nio.file.NoSuchFileException;
+import java.util.*;
+
+@Path("/api/games")
+@Produces(MediaType.APPLICATION_JSON)
+public class GameResource {
+
+ @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();
+
+ // Client-side filtering (small dataset, fine for a personal library)
+ 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 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")));
+
+ 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();
+ }
+ }
+
+ @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;
+ }
+}
diff --git a/src/main/java/com/dostalgia/GameService.java b/src/main/java/com/dostalgia/GameService.java
new file mode 100644
index 0000000..9a22f65
--- /dev/null
+++ b/src/main/java/com/dostalgia/GameService.java
@@ -0,0 +1,146 @@
+package com.dostalgia;
+
+import com.fasterxml.jackson.databind.DeserializationFeature;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.SerializationFeature;
+import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
+import jakarta.annotation.PostConstruct;
+import jakarta.enterprise.context.ApplicationScoped;
+import org.eclipse.microprofile.config.inject.ConfigProperty;
+
+import java.io.IOException;
+import java.nio.file.*;
+import java.nio.file.attribute.BasicFileAttributes;
+import java.time.Instant;
+import java.util.*;
+
+/** Manages game metadata stored as JSON files on disk. */
+@ApplicationScoped
+public class GameService {
+
+ private final ObjectMapper mapper = new ObjectMapper()
+ .registerModule(new JavaTimeModule())
+ .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
+ .enable(SerializationFeature.INDENT_OUTPUT);
+
+ @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);
+ }
+ }
+
+ // ─── Game Directory Helpers ──────────────────────────────────
+
+ Path gameDir(String id) {
+ return gamesDir.resolve(id);
+ }
+
+ Path gameJsonPath(String id) {
+ return gameDir(id).resolve("game.json");
+ }
+
+ // ─── CRUD ────────────────────────────────────────────────────
+
+ /** 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"))
+ );
+ 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 list() throws IOException {
+ List games = new ArrayList<>();
+ if (!Files.isDirectory(gamesDir)) return games;
+
+ try (var stream = Files.list(gamesDir)) {
+ for (Path dir : (Iterable) 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 boolean delete(String id) throws IOException {
+ // Remove game directory
+ Path dir = gameDir(id);
+ if (Files.exists(dir)) {
+ Files.walkFileTree(dir, new SimpleFileVisitor<>() {
+ @Override
+ public FileVisitResult visitFile(Path f, BasicFileAttributes a) throws IOException {
+ Files.delete(f);
+ return FileVisitResult.CONTINUE;
+ }
+ @Override
+ public FileVisitResult postVisitDirectory(Path d, IOException e) throws IOException {
+ Files.delete(d);
+ return FileVisitResult.CONTINUE;
+ }
+ });
+ }
+ // Remove flat .jsdos bundle
+ Files.deleteIfExists(gamesDir.resolve(id + ".jsdos"));
+ return true;
+ }
+
+ // ─── Utility ─────────────────────────────────────────────────
+
+ /** 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;
+ }
+
+ /** Check if a file exists. */
+ public static boolean exists(Path p) {
+ return Files.exists(p);
+ }
+
+ public Path getGamesDir() { return gamesDir; }
+}
diff --git a/src/main/java/com/dostalgia/IgdbResource.java b/src/main/java/com/dostalgia/IgdbResource.java
new file mode 100644
index 0000000..a853333
--- /dev/null
+++ b/src/main/java/com/dostalgia/IgdbResource.java
@@ -0,0 +1,31 @@
+package com.dostalgia;
+
+import jakarta.ws.rs.*;
+import jakarta.ws.rs.core.MediaType;
+import jakarta.ws.rs.core.Response;
+import java.util.Map;
+
+/** IGDB integration placeholder. Enable by setting TWITCH_CLIENT_ID and TWITCH_CLIENT_SECRET. */
+@Path("/api/igdb")
+@Produces(MediaType.APPLICATION_JSON)
+public class IgdbResource {
+
+ @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' required")).build();
+ }
+ // TODO: Implement real IGDB search via Twitch OAuth2 + IGDB API
+ // 1. POST https://id.twitch.tv/oauth2/token → access_token
+ // 2. POST https://api.igdb.com/v4/games with search body
+ // 3. Return matches with cover URLs
+ return Response.ok(java.util.List.of()).build();
+ }
+
+ @GET
+ @Path("/covers/{igdbId}")
+ public Response covers(@PathParam("igdbId") int igdbId) {
+ return Response.ok(java.util.List.of()).build();
+ }
+}
diff --git a/src/main/java/com/dostalgia/SockdriveResource.java b/src/main/java/com/dostalgia/SockdriveResource.java
new file mode 100644
index 0000000..430f943
--- /dev/null
+++ b/src/main/java/com/dostalgia/SockdriveResource.java
@@ -0,0 +1,74 @@
+package com.dostalgia;
+
+import jakarta.inject.Inject;
+import jakarta.ws.rs.*;
+import jakarta.ws.rs.core.MediaType;
+import jakarta.ws.rs.core.Response;
+import jakarta.ws.rs.core.StreamingOutput;
+import java.io.*;
+import java.nio.file.*;
+import java.util.*;
+
+/** Serves game files for sockdrive streaming mode. */
+@Path("/api/sockdrive")
+@Produces(MediaType.APPLICATION_JSON)
+public class SockdriveResource {
+
+ @Inject
+ GameService svc;
+
+ @GET
+ @Path("/{slug}/files")
+ public Response listFiles(@PathParam("slug") String slug) {
+ Path gameDir = svc.gameDir(slug);
+ if (!Files.isDirectory(gameDir)) {
+ return Response.status(404).entity(Map.of("error", "Game not found")).build();
+ }
+
+ try {
+ var files = new ArrayList