diff --git a/README.md b/README.md index 8e0a4b2..3623f6f 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,6 @@ java -jar target/quarkus-app/quarkus-run.jar "year": 1993, "genre": "FPS", "developer": "id Software", - "bundle_type": "standard", "bundle_file": "doom.jsdos", "has_cover": true, "ready": true diff --git a/src/main/java/com/dostalgia/Game.java b/src/main/java/com/dostalgia/Game.java index 4ca9a09..7338832 100644 --- a/src/main/java/com/dostalgia/Game.java +++ b/src/main/java/com/dostalgia/Game.java @@ -19,7 +19,6 @@ public class Game { private Double rating; private List platforms; - private String bundleType; // "standard" or "sockdrive" private String bundleFile; // relative path within game dir private String setupExe; // relative path to SETUP.EXE (null if none) @@ -77,9 +76,6 @@ public class Game { 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; } diff --git a/src/main/java/com/dostalgia/GameResource.java b/src/main/java/com/dostalgia/GameResource.java index 0eed896..60215ab 100644 --- a/src/main/java/com/dostalgia/GameResource.java +++ b/src/main/java/com/dostalgia/GameResource.java @@ -8,8 +8,6 @@ import jakarta.ws.rs.core.StreamingOutput; import java.nio.file.NoSuchFileException; import java.nio.file.Files; import java.util.*; -import java.util.zip.ZipEntry; -import java.util.zip.ZipOutputStream; @Path("/api/games") @Produces(MediaType.APPLICATION_JSON) @@ -101,42 +99,14 @@ public class GameResource { try { var game = svc.load(id); String filename = (game.getTitle() != null ? game.getTitle() : id) + ".zip"; - - if ("sockdrive".equals(game.getBundleType())) { - // ZIP all game files on the fly, excluding metadata - StreamingOutput stream = output -> { - var gameDir = svc.gameDir(id); - try (var zos = new ZipOutputStream(output)) { - Files.walk(gameDir) - .filter(Files::isRegularFile) - .filter(p -> !p.getFileName().toString().equals("game.json")) - .filter(p -> !p.getFileName().toString().startsWith("cover.")) - .forEach(p -> { - try { - String entryName = gameDir.relativize(p).toString().replace('\\', '/'); - zos.putNextEntry(new ZipEntry(entryName)); - Files.copy(p, zos); - zos.closeEntry(); - } catch (Exception e) { - throw new RuntimeException(e); - } - }); - } - }; - return Response.ok(stream) - .header("Content-Disposition", "attachment; filename=\"" + filename + "\"") - .build(); - } else { - // Standard .jsdos bundle - 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(); + 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) { diff --git a/src/main/java/com/dostalgia/SockdriveResource.java b/src/main/java/com/dostalgia/SockdriveResource.java deleted file mode 100644 index 96095db..0000000 --- a/src/main/java/com/dostalgia/SockdriveResource.java +++ /dev/null @@ -1,77 +0,0 @@ -package com.dostalgia; - -import jakarta.inject.Inject; -import jakarta.ws.rs.GET; -import jakarta.ws.rs.Produces; -import jakarta.ws.rs.PathParam; -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.io.*; -import java.nio.file.*; -import java.util.*; - -/** Serves game files for sockdrive streaming mode. */ -@jakarta.ws.rs.Path("/api/sockdrive") -@Produces(MediaType.APPLICATION_JSON) -public class SockdriveResource { - - @Inject - GameService svc; - - @GET - @jakarta.ws.rs.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>(); - Files.walk(gameDir).filter(Files::isRegularFile).forEach(f -> { - String rel = gameDir.relativize(f).toString().replace('\\', '/'); - try { - files.add(Map.of("path", rel, "size", Files.size(f))); - } catch (IOException ignored) {} - }); - return Response.ok(Map.of("files", files, "total", files.size())).build(); - } catch (IOException e) { - return Response.serverError().entity(Map.of("error", e.getMessage())).build(); - } - } - - @GET - @jakarta.ws.rs.Path("/{slug}/file") - public Response getFile( - @PathParam("slug") String slug, - @QueryParam("path") String path - ) { - if (path == null || path.isBlank()) { - return Response.status(400).entity(Map.of("error", "path parameter required")).build(); - } - - Path resolved = svc.gameDir(slug).resolve(path).normalize(); - if (!resolved.startsWith(svc.gameDir(slug).normalize())) { - return Response.status(403).build(); - } - - if (!Files.exists(resolved) || Files.isDirectory(resolved)) { - return Response.status(404).build(); - } - - // Determine content type - String contentType = MediaType.APPLICATION_OCTET_STREAM; - String name = resolved.getFileName().toString().toLowerCase(); - if (name.endsWith(".conf") || name.endsWith(".txt")) contentType = "text/plain"; - else if (name.endsWith(".json")) contentType = "application/json"; - else if (name.endsWith(".html")) contentType = "text/html"; - else if (name.endsWith(".js")) contentType = "application/javascript"; - - final String ct = contentType; - StreamingOutput stream = output -> Files.copy(resolved, output); - - return Response.ok(stream).type(ct).header("Accept-Ranges", "bytes").build(); - } -} diff --git a/src/main/java/com/dostalgia/UploadResource.java b/src/main/java/com/dostalgia/UploadResource.java index ee901e8..025654e 100644 --- a/src/main/java/com/dostalgia/UploadResource.java +++ b/src/main/java/com/dostalgia/UploadResource.java @@ -93,7 +93,6 @@ public class UploadResource { // Save metadata Game game = new Game(gameId, title); - game.setBundleType("standard"); game.setBundleFile(bundleFile); game.setSetupExe(setupExe != null ? extractDir.relativize(Path.of(setupExe)).toString() : null); game.setReady(true); @@ -168,24 +167,29 @@ public class UploadResource { /** * If the extraction directory contains a single subdirectory, move * its contents up (flatten). This handles ZIPs where all game files - * are inside a folder (e.g. "game/DOOM2.EXE" → "DOOM2.EXE"). + * are inside a folder (e.g. "mastori2/ORION2.EXE" → "ORION2.EXE"). + * Works even when there are metadata files (file_id.diz, *.nfo, etc.) + * alongside the single game directory at the ZIP root. */ private void flattenSingleDir(Path dir) throws IOException { - List entries; + Path singleDir = null; try (var files = Files.list(dir)) { - entries = files.toList(); + for (Path entry : files.toList()) { + if (Files.isDirectory(entry)) { + if (singleDir != null) return; // multiple directories — abort + singleDir = entry; + } + } } - if (entries.size() != 1) return; - Path only = entries.get(0); - if (!Files.isDirectory(only)) return; + if (singleDir == null) return; - LOG.info("Flattening single root directory: " + only.getFileName()); + LOG.info("Flattening root directory: " + singleDir.getFileName()); // Walk in reverse order so directories are empty when we delete them - try (var walk = Files.walk(only)) { + try (var walk = Files.walk(singleDir)) { var list = walk.sorted(Comparator.reverseOrder()).toList(); for (Path f : list) { - if (f.equals(only)) continue; - Path target = dir.resolve(only.relativize(f)); + if (f.equals(singleDir)) continue; + Path target = dir.resolve(singleDir.relativize(f)); if (Files.isDirectory(f)) { Files.createDirectories(target); Files.delete(f); @@ -195,8 +199,8 @@ public class UploadResource { } } } - Files.delete(only); - LOG.info("Flattened " + only.getFileName()); + Files.delete(singleDir); + LOG.info("Flattened " + singleDir.getFileName()); } // ─── Executable Detection ──────────────────────────────────── @@ -299,13 +303,31 @@ public class UploadResource { }); } - // ─── Configuration Helpers ─────────────────────────────────── + // ─── Configuration Helpers ────────────────────────────────── + + /** + * Split a relative executable path into its directory and file parts. + * If relExe contains a '/' (or '\'), returns {dir, file}. + * If relExe is just a filename, returns {null, relExe}. + */ + private static String[] splitDirExe(String relExe) { + int idx = relExe.replace('\\', '/').lastIndexOf('/'); + if (idx >= 0) { + return new String[]{relExe.substring(0, idx), relExe.substring(idx + 1)}; + } + return new String[]{null, relExe}; + } private byte[] buildJsdosJson(String relExe) { - // relExe is always just a filename after flattenSingleDir + // DOS uses \ as path separator; / is a switch character. + // If the executable is in a subdirectory, cd into it first. + var parts = splitDirExe(relExe); + String script = parts[0] != null + ? "cd " + parts[0] + "\n" + parts[1] + : relExe; String json = "{" + "\"autoexec\":{" - + "\"options\":{\"script\":{\"value\":\"" + escapeJson(relExe) + "\"}}" + + "\"options\":{\"script\":{\"value\":\"" + escapeJson(script) + "\"}}" + "}," + "\"output\":{" + "\"options\":{\"autolock\":{\"value\":true}}}" @@ -314,37 +336,46 @@ public class UploadResource { } private String buildDosboxConf(String relExe) { - // relExe is always just a filename after flattenSingleDir - return "[sdl]\n" - + "autolock=true\n" - + "usescancodes=true\n" - + "output=surface\n" - + "\n" - + "[cpu]\n" - + "core=auto\n" - + "cycles=auto\n" - + "\n" - + "[mixer]\n" - + "nosound=false\n" - + "rate=44100\n" - + "\n" - + "[sblaster]\n" - + "sbtype=sb16\n" - + "irq=7\n" - + "dma=1\n" - + "hdma=5\n" - + "\n" - + "[dos]\n" - + "xms=true\n" - + "ems=true\n" - + "umb=true\n" - + "\n" - + "[autoexec]\n" - + "@echo off\n" - + "mount c .\n" - + "c:\n" - + "cls\n" - + relExe + "\n"; + // DOS uses \ as path separator; / is a switch character. + // If the executable is in a subdirectory, cd into it first. + var parts = splitDirExe(relExe); + String dir = parts[0]; + String exe = parts[1]; + StringBuilder sb = new StringBuilder(); + sb.append("[sdl]\n"); + sb.append("autolock=true\n"); + sb.append("usescancodes=true\n"); + sb.append("output=surface\n"); + sb.append("\n"); + sb.append("[cpu]\n"); + sb.append("core=auto\n"); + sb.append("cycles=auto\n"); + sb.append("\n"); + sb.append("[mixer]\n"); + sb.append("nosound=false\n"); + sb.append("rate=44100\n"); + sb.append("\n"); + sb.append("[sblaster]\n"); + sb.append("sbtype=sb16\n"); + sb.append("irq=7\n"); + sb.append("dma=1\n"); + sb.append("hdma=5\n"); + sb.append("\n"); + sb.append("[dos]\n"); + sb.append("xms=true\n"); + sb.append("ems=true\n"); + sb.append("umb=true\n"); + sb.append("\n"); + sb.append("[autoexec]\n"); + sb.append("@echo off\n"); + sb.append("mount c .\n"); + sb.append("c:\n"); + sb.append("cls\n"); + if (dir != null) { + sb.append("cd ").append(dir).append("\n"); + } + sb.append(exe).append("\n"); + return sb.toString(); } private String escapeJson(String s) {