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
@@ -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");
}
}
}