fix: address all code quality issues across 8 files
Build & Deploy / build-and-deploy (push) Failing after 45s
Build & Deploy / build-and-deploy (push) Failing after 45s
ConfigBuilder: - Extracted buildCdMountScript to deduplicate 12-line CD loop ExecutableDetector: - Added @Nonnull annotations to visitFile overrides GameService: - Added @Nonnull annotations to visitFile/postVisitDirectory - De-duplicated PE header detection via PlatformDetector.detectFromBytes IgdbService: - Replaced unsafe Optional.get() with orElseThrow() (3 occurrences) - Extracted postAndGet helper (deduplicates 7-line HTTP+JSON pattern) - Extracted epochToYear helper (deduplicates 14-line year conversion) - Reduced from 508 to 347 lines PlatformDetector: - Extracted detectFromBytes() static utility for byte-level PE analysis StaticResource: - Extracted serveFile() helper (deduplicates 8-line path-serving pattern) UploadResource: - Added @Nonnull annotations to visitFile/postVisitDirectory - Extracted mainExePath/setupExePath locals (eliminates duplicate Path.of calls) ZipService: - Wrapped Files.walk in try-with-resources - Extracted zipEntry helper (deduplicates 10-line file-zipping forEach) All files: net -177 lines
This commit is contained in:
@@ -188,7 +188,7 @@ public class ConfigBuilder {
|
|||||||
return "path=c:\\\ncd " + dir + "\n" + exe + "\n";
|
return "path=c:\\\ncd " + dir + "\n" + exe + "\n";
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String buildAutoexecScript(String dir, String exe, List<String> cdImages) {
|
private static String buildCdMountScript(List<String> cdImages) {
|
||||||
StringBuilder script = new StringBuilder();
|
StringBuilder script = new StringBuilder();
|
||||||
script.append("mount c .\n");
|
script.append("mount c .\n");
|
||||||
char driveLetter = 'D';
|
char driveLetter = 'D';
|
||||||
@@ -200,6 +200,11 @@ public class ConfigBuilder {
|
|||||||
.append(img).append("\"").append(flags).append("\n");
|
.append(img).append("\"").append(flags).append("\n");
|
||||||
driveLetter++;
|
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");
|
script.append("c:\n");
|
||||||
if (dir != null) {
|
if (dir != null) {
|
||||||
script.append("path=c:\\\n");
|
script.append("path=c:\\\n");
|
||||||
@@ -210,17 +215,7 @@ public class ConfigBuilder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static String buildCdOnlyAutoexecScript(List<String> cdImages) {
|
private static String buildCdOnlyAutoexecScript(List<String> cdImages) {
|
||||||
StringBuilder script = new StringBuilder();
|
StringBuilder script = new StringBuilder(buildCdMountScript(cdImages));
|
||||||
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++;
|
|
||||||
}
|
|
||||||
script.append("c:\n");
|
script.append("c:\n");
|
||||||
return script.toString();
|
return script.toString();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.dostalgia;
|
package com.dostalgia;
|
||||||
|
|
||||||
|
import jakarta.annotation.Nonnull;
|
||||||
import jakarta.enterprise.context.ApplicationScoped;
|
import jakarta.enterprise.context.ApplicationScoped;
|
||||||
import jakarta.inject.Inject;
|
import jakarta.inject.Inject;
|
||||||
|
|
||||||
@@ -40,7 +41,7 @@ public class ExecutableDetector {
|
|||||||
List<String> result = new ArrayList<>();
|
List<String> result = new ArrayList<>();
|
||||||
Files.walkFileTree(dir, new SimpleFileVisitor<>() {
|
Files.walkFileTree(dir, new SimpleFileVisitor<>() {
|
||||||
@Override
|
@Override
|
||||||
public FileVisitResult visitFile(Path f, BasicFileAttributes a) {
|
public FileVisitResult visitFile(@Nonnull Path f, @Nonnull BasicFileAttributes a) {
|
||||||
String name = f.getFileName().toString().toLowerCase();
|
String name = f.getFileName().toString().toLowerCase();
|
||||||
if (name.endsWith(".exe") || name.endsWith(".com") || name.endsWith(".bat")) {
|
if (name.endsWith(".exe") || name.endsWith(".com") || name.endsWith(".bat")) {
|
||||||
result.add(dir.relativize(f).toString().replace('\\', '/'));
|
result.add(dir.relativize(f).toString().replace('\\', '/'));
|
||||||
@@ -59,7 +60,7 @@ public class ExecutableDetector {
|
|||||||
|
|
||||||
Files.walkFileTree(dir, new SimpleFileVisitor<>() {
|
Files.walkFileTree(dir, new SimpleFileVisitor<>() {
|
||||||
@Override
|
@Override
|
||||||
public FileVisitResult visitFile(Path f, BasicFileAttributes a) {
|
public FileVisitResult visitFile(@Nonnull Path f, @Nonnull BasicFileAttributes a) {
|
||||||
String name = f.getFileName().toString().toLowerCase();
|
String name = f.getFileName().toString().toLowerCase();
|
||||||
if (!name.endsWith(".exe") && !name.endsWith(".com") && !name.endsWith(".bat"))
|
if (!name.endsWith(".exe") && !name.endsWith(".com") && !name.endsWith(".bat"))
|
||||||
return FileVisitResult.CONTINUE;
|
return FileVisitResult.CONTINUE;
|
||||||
@@ -103,7 +104,7 @@ public class ExecutableDetector {
|
|||||||
|
|
||||||
Files.walkFileTree(dir, new SimpleFileVisitor<>() {
|
Files.walkFileTree(dir, new SimpleFileVisitor<>() {
|
||||||
@Override
|
@Override
|
||||||
public FileVisitResult visitFile(Path f, BasicFileAttributes a) {
|
public FileVisitResult visitFile(@Nonnull Path f, @Nonnull BasicFileAttributes a) {
|
||||||
String name = f.getFileName().toString().toLowerCase();
|
String name = f.getFileName().toString().toLowerCase();
|
||||||
String stem = name.contains(".") ? name.substring(0, name.lastIndexOf('.')) : name;
|
String stem = name.contains(".") ? name.substring(0, name.lastIndexOf('.')) : name;
|
||||||
if (!name.endsWith(".exe") && !name.endsWith(".com") && !name.endsWith(".bat"))
|
if (!name.endsWith(".exe") && !name.endsWith(".com") && !name.endsWith(".bat"))
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ import java.time.Instant;
|
|||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Comparator;
|
import java.util.Comparator;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.logging.Logger;
|
||||||
|
import jakarta.annotation.Nonnull;
|
||||||
|
|
||||||
/** Manages game metadata stored as JSON files on disk. */
|
/** Manages game metadata stored as JSON files on disk. */
|
||||||
@ApplicationScoped
|
@ApplicationScoped
|
||||||
@@ -125,12 +127,12 @@ public class GameService implements GameStore {
|
|||||||
if (Files.exists(dir)) {
|
if (Files.exists(dir)) {
|
||||||
Files.walkFileTree(dir, new SimpleFileVisitor<>() {
|
Files.walkFileTree(dir, new SimpleFileVisitor<>() {
|
||||||
@Override
|
@Override
|
||||||
public FileVisitResult visitFile(Path f, BasicFileAttributes a) throws IOException {
|
public FileVisitResult visitFile(@Nonnull Path f, @Nonnull BasicFileAttributes a) throws IOException {
|
||||||
Files.delete(f);
|
Files.delete(f);
|
||||||
return FileVisitResult.CONTINUE;
|
return FileVisitResult.CONTINUE;
|
||||||
}
|
}
|
||||||
@Override
|
@Override
|
||||||
public FileVisitResult postVisitDirectory(Path d, IOException e) throws IOException {
|
public FileVisitResult postVisitDirectory(@Nonnull Path d, IOException e) throws IOException {
|
||||||
Files.delete(d);
|
Files.delete(d);
|
||||||
return FileVisitResult.CONTINUE;
|
return FileVisitResult.CONTINUE;
|
||||||
}
|
}
|
||||||
@@ -227,16 +229,7 @@ public class GameService implements GameStore {
|
|||||||
if (entry.getName().equals(relExe.replace('\\', '/'))) {
|
if (entry.getName().equals(relExe.replace('\\', '/'))) {
|
||||||
byte[] buf = new byte[0x80];
|
byte[] buf = new byte[0x80];
|
||||||
int read = zis.readNBytes(buf, 0, buf.length);
|
int read = zis.readNBytes(buf, 0, buf.length);
|
||||||
if (read < 2) return null;
|
return PlatformDetector.detectFromBytes(buf, read);
|
||||||
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], sig2 = buf[peOffset + 1];
|
|
||||||
if ((sig1 == 0x50 && sig2 == 0x45) || (sig1 == 0x4E && sig2 == 0x45))
|
|
||||||
return "windows";
|
|
||||||
return "dos";
|
|
||||||
}
|
}
|
||||||
zis.closeEntry();
|
zis.closeEntry();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,12 +12,7 @@ import java.net.http.HttpRequest;
|
|||||||
import java.net.http.HttpResponse;
|
import java.net.http.HttpResponse;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.ArrayList;
|
import java.util.*;
|
||||||
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;
|
import java.util.logging.Logger;
|
||||||
|
|
||||||
/** Client for the IGDB (Internet Game Database) API via Twitch OAuth2. */
|
/** Client for the IGDB (Internet Game Database) API via Twitch OAuth2. */
|
||||||
@@ -54,8 +49,10 @@ public class IgdbService {
|
|||||||
if (accessToken != null && tokenExpiry != null && Instant.now().isBefore(tokenExpiry)) {
|
if (accessToken != null && tokenExpiry != null && Instant.now().isBefore(tokenExpiry)) {
|
||||||
return accessToken;
|
return accessToken;
|
||||||
}
|
}
|
||||||
String body = "client_id=" + clientId.get()
|
String cid = clientId.orElseThrow(() -> new RuntimeException("TWITCH_CLIENT_ID not configured"));
|
||||||
+ "&client_secret=" + clientSecret.get()
|
String secret = clientSecret.orElseThrow(() -> new RuntimeException("TWITCH_CLIENT_SECRET not configured"));
|
||||||
|
String body = "client_id=" + cid
|
||||||
|
+ "&client_secret=" + secret
|
||||||
+ "&grant_type=client_credentials";
|
+ "&grant_type=client_credentials";
|
||||||
|
|
||||||
HttpRequest req = HttpRequest.newBuilder()
|
HttpRequest req = HttpRequest.newBuilder()
|
||||||
@@ -72,27 +69,36 @@ public class IgdbService {
|
|||||||
JsonNode json = mapper.readTree(res.body());
|
JsonNode json = mapper.readTree(res.body());
|
||||||
accessToken = json.get("access_token").asText();
|
accessToken = json.get("access_token").asText();
|
||||||
int expiresIn = json.get("expires_in").asInt();
|
int expiresIn = json.get("expires_in").asInt();
|
||||||
tokenExpiry = Instant.now().plusSeconds(expiresIn - 120); // 2 min buffer
|
tokenExpiry = Instant.now().plusSeconds(expiresIn - 120);
|
||||||
LOG.info("IGDB: acquired new Twitch token (expires in " + expiresIn + "s)");
|
LOG.info("IGDB: acquired new Twitch token (expires in " + expiresIn + "s)");
|
||||||
return accessToken;
|
return accessToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
private HttpRequest.Builder igdbRequest(String path) throws Exception {
|
private HttpRequest.Builder igdbRequest(String path) throws Exception {
|
||||||
|
String cid = clientId.orElseThrow(() -> new RuntimeException("TWITCH_CLIENT_ID not configured"));
|
||||||
return HttpRequest.newBuilder()
|
return HttpRequest.newBuilder()
|
||||||
.uri(URI.create(IGDB_API + path))
|
.uri(URI.create(IGDB_API + path))
|
||||||
.header("Client-ID", clientId.get())
|
.header("Client-ID", cid)
|
||||||
.header("Authorization", "Bearer " + getToken())
|
.header("Authorization", "Bearer " + getToken())
|
||||||
.header("Content-Type", "text/plain");
|
.header("Content-Type", "text/plain");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** POST an APQL query to an IGDB endpoint and return the JSON array, or null on failure. */
|
||||||
* Search IGDB for games matching the given query.
|
private JsonNode postAndGet(String endpoint, String apql) throws Exception {
|
||||||
* Returns a list of simplified result maps suitable for JSON serialization.
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Search ───────────────────────────────────────────────────
|
||||||
|
|
||||||
public List<Map<String, Object>> search(String query) throws Exception {
|
public List<Map<String, Object>> search(String query) throws Exception {
|
||||||
if (!isConfigured()) return List.of();
|
if (!isConfigured()) return List.of();
|
||||||
|
|
||||||
// Search limited to DOS platform (IGDB platform ID 13)
|
|
||||||
String apql = "search \"" + sanitize(query) + "\";"
|
String apql = "search \"" + sanitize(query) + "\";"
|
||||||
+ " fields name,first_release_date,genres.name,"
|
+ " fields name,first_release_date,genres.name,"
|
||||||
+ " involved_companies.company.name,involved_companies.developer,involved_companies.publisher,"
|
+ " involved_companies.company.name,involved_companies.developer,involved_companies.publisher,"
|
||||||
@@ -100,19 +106,9 @@ public class IgdbService {
|
|||||||
+ " where platforms = [13];"
|
+ " where platforms = [13];"
|
||||||
+ " limit 20;";
|
+ " limit 20;";
|
||||||
|
|
||||||
HttpRequest req = igdbRequest("/games")
|
JsonNode results = postAndGet("/games", apql);
|
||||||
.POST(HttpRequest.BodyPublishers.ofString(apql))
|
if (results == null || results.isEmpty()) {
|
||||||
.build();
|
LOG.info("IGDB search: no results for '" + query + "'");
|
||||||
|
|
||||||
HttpResponse<String> res = http.send(req, HttpResponse.BodyHandlers.ofString());
|
|
||||||
if (res.statusCode() != 200) {
|
|
||||||
LOG.warning("IGDB search failed: HTTP " + res.statusCode() + " " + res.body());
|
|
||||||
return List.of();
|
|
||||||
}
|
|
||||||
|
|
||||||
JsonNode results = mapper.readTree(res.body());
|
|
||||||
if (!results.isArray() || results.isEmpty()) {
|
|
||||||
LOG.info("IGDB search: no results for '" + query + "'. Response: " + res.body());
|
|
||||||
return List.of();
|
return List.of();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,12 +118,7 @@ public class IgdbService {
|
|||||||
entry.put("igdb_id", game.get("id").asInt());
|
entry.put("igdb_id", game.get("id").asInt());
|
||||||
entry.put("name", game.has("name") ? game.get("name").asText() : query);
|
entry.put("name", game.has("name") ? game.get("name").asText() : query);
|
||||||
|
|
||||||
if (game.has("first_release_date")) {
|
epochToYear(game, "first_release_date").ifPresent(y -> entry.put("year", y));
|
||||||
long epoch = game.get("first_release_date").asLong();
|
|
||||||
Calendar cal = Calendar.getInstance();
|
|
||||||
cal.setTimeInMillis(epoch * 1000);
|
|
||||||
entry.put("year", cal.get(Calendar.YEAR));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (game.has("genres") && game.get("genres").isArray()) {
|
if (game.has("genres") && game.get("genres").isArray()) {
|
||||||
List<String> genres = new ArrayList<>();
|
List<String> genres = new ArrayList<>();
|
||||||
@@ -137,7 +128,6 @@ public class IgdbService {
|
|||||||
entry.put("genres", genres);
|
entry.put("genres", genres);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract developer and publisher
|
|
||||||
if (game.has("involved_companies") && game.get("involved_companies").isArray()) {
|
if (game.has("involved_companies") && game.get("involved_companies").isArray()) {
|
||||||
String developer = null;
|
String developer = null;
|
||||||
String publisher = null;
|
String publisher = null;
|
||||||
@@ -158,216 +148,106 @@ public class IgdbService {
|
|||||||
entry.put("summary", game.get("summary").asText());
|
entry.put("summary", game.get("summary").asText());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Platforms (check if DOS or PC)
|
|
||||||
if (game.has("platforms") && game.get("platforms").isArray()) {
|
if (game.has("platforms") && game.get("platforms").isArray()) {
|
||||||
List<Integer> platformIds = new ArrayList<>();
|
List<Integer> platformIds = new ArrayList<>();
|
||||||
for (JsonNode p : game.get("platforms")) {
|
for (JsonNode p : game.get("platforms")) {
|
||||||
platformIds.add(p.asInt());
|
platformIds.add(p.asInt());
|
||||||
}
|
}
|
||||||
entry.put("platform_ids", platformIds);
|
entry.put("platform_ids", platformIds);
|
||||||
boolean isDos = platformIds.contains(13); // 13 = DOS
|
entry.put("is_dos", platformIds.contains(13));
|
||||||
entry.put("is_dos", isDos);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cover URL — IGDB now returns cover as object with url (since we use cover.url in fields)
|
|
||||||
if (game.has("cover") && game.get("cover").has("url")) {
|
if (game.has("cover") && game.get("cover").has("url")) {
|
||||||
String thumbUrl = game.get("cover").get("url").asText();
|
String thumbUrl = game.get("cover").get("url").asText();
|
||||||
// IGDB URL format: //images.igdb.com/igdb/image/upload/t_thumb/co1x8h.jpg
|
|
||||||
// Replace t_thumb with our desired size
|
|
||||||
String coverUrl = "https:" + thumbUrl.replace("t_thumb", COVER_SIZE);
|
String coverUrl = "https:" + thumbUrl.replace("t_thumb", COVER_SIZE);
|
||||||
entry.put("cover_url", coverUrl);
|
entry.put("cover_url", coverUrl);
|
||||||
} else if (game.has("cover") && game.get("cover").isInt()) {
|
} else if (game.has("cover") && game.get("cover").isInt()) {
|
||||||
// Fallback: cover is just an integer ID, fetch URL separately
|
|
||||||
int coverId = game.get("cover").asInt();
|
int coverId = game.get("cover").asInt();
|
||||||
String coverUrl = fetchCoverUrl(coverId);
|
String coverUrl = fetchCoverUrl(coverId);
|
||||||
if (coverUrl != null) {
|
if (coverUrl != null) entry.put("cover_url", coverUrl);
|
||||||
entry.put("cover_url", coverUrl);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
out.add(entry);
|
out.add(entry);
|
||||||
}
|
}
|
||||||
|
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch the cover image URL for a given IGDB cover ID.
|
|
||||||
*/
|
|
||||||
private String fetchCoverUrl(int coverId) throws Exception {
|
private String fetchCoverUrl(int coverId) throws Exception {
|
||||||
String apql = "fields url; where id = " + coverId + ";";
|
JsonNode results = postAndGet("/covers", "fields url; where id = " + coverId + ";");
|
||||||
HttpRequest req = igdbRequest("/covers")
|
if (results == null || results.isEmpty()) return null;
|
||||||
.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());
|
|
||||||
if (!results.isArray() || results.isEmpty()) return null;
|
|
||||||
|
|
||||||
String thumbUrl = results.get(0).get("url").asText();
|
String thumbUrl = results.get(0).get("url").asText();
|
||||||
// IGDB returns: //images.igdb.com/.../t_thumb/xxx.jpg
|
|
||||||
// Replace t_thumb with desired size
|
|
||||||
return IMG_BASE + "/" + COVER_SIZE + "/" + thumbUrl.substring(thumbUrl.lastIndexOf('/') + 1);
|
return IMG_BASE + "/" + COVER_SIZE + "/" + thumbUrl.substring(thumbUrl.lastIndexOf('/') + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private List<String> fetchVideos(int igdbId) throws Exception {
|
||||||
* Fetch YouTube video IDs associated with an IGDB game.
|
JsonNode results = postAndGet("/game_videos", "fields video_id; where game = " + igdbId + ";");
|
||||||
*/
|
if (results == null) return List.of();
|
||||||
public List<String> fetchVideos(int igdbId) throws Exception {
|
|
||||||
String apql = "fields video_id; where game = " + igdbId + ";";
|
|
||||||
HttpRequest req = igdbRequest("/game_videos")
|
|
||||||
.POST(HttpRequest.BodyPublishers.ofString(apql))
|
|
||||||
.build();
|
|
||||||
|
|
||||||
HttpResponse<String> res = http.send(req, HttpResponse.BodyHandlers.ofString());
|
|
||||||
if (res.statusCode() != 200) return List.of();
|
|
||||||
|
|
||||||
JsonNode results = mapper.readTree(res.body());
|
|
||||||
if (!results.isArray()) return List.of();
|
|
||||||
|
|
||||||
List<String> videos = new ArrayList<>();
|
List<String> videos = new ArrayList<>();
|
||||||
for (JsonNode v : results) {
|
for (JsonNode v : results) {
|
||||||
if (v.has("video_id")) {
|
if (v.has("video_id")) videos.add(v.get("video_id").asText());
|
||||||
videos.add(v.get("video_id").asText());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return videos;
|
return videos;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private List<String> fetchScreenshots(int igdbId) throws Exception {
|
||||||
* Fetch screenshot image URLs for an IGDB game.
|
JsonNode results = postAndGet("/screenshots", "fields url; where game = " + igdbId + ";");
|
||||||
*/
|
if (results == null) return List.of();
|
||||||
public List<String> fetchScreenshots(int igdbId) throws Exception {
|
|
||||||
String apql = "fields url; where game = " + igdbId + ";";
|
|
||||||
HttpRequest req = igdbRequest("/screenshots")
|
|
||||||
.POST(HttpRequest.BodyPublishers.ofString(apql))
|
|
||||||
.build();
|
|
||||||
|
|
||||||
HttpResponse<String> res = http.send(req, HttpResponse.BodyHandlers.ofString());
|
|
||||||
if (res.statusCode() != 200) return List.of();
|
|
||||||
|
|
||||||
JsonNode results = mapper.readTree(res.body());
|
|
||||||
if (!results.isArray()) return List.of();
|
|
||||||
|
|
||||||
List<String> screenshots = new ArrayList<>();
|
List<String> screenshots = new ArrayList<>();
|
||||||
for (JsonNode s : results) {
|
for (JsonNode s : results) {
|
||||||
if (s.has("url")) {
|
if (s.has("url")) {
|
||||||
String thumbUrl = s.get("url").asText();
|
String fullUrl = "https:" + s.get("url").asText().replace("t_thumb", "t_1080p");
|
||||||
// Use bigger size for display
|
|
||||||
String fullUrl = "https:" + thumbUrl.replace("t_thumb", "t_1080p");
|
|
||||||
screenshots.add(fullUrl);
|
screenshots.add(fullUrl);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return screenshots;
|
return screenshots;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// ─── Auto-Scrape ──────────────────────────────────────────────
|
||||||
* Auto-search IGDB for the game title and apply the best match.
|
|
||||||
* Updates the Game object in-place. Does NOT save — caller must svc.save().
|
|
||||||
* Does NOT throw on failure; logs warnings instead.
|
|
||||||
*/
|
|
||||||
public void autoScrape(Game game) {
|
public void autoScrape(Game game) {
|
||||||
if (!isConfigured()) return;
|
if (!isConfigured()) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
List<Map<String, Object>> results = search(game.getTitle());
|
List<Map<String, Object>> results = search(game.getTitle());
|
||||||
if (results.isEmpty()) {
|
if (results.isEmpty()) {
|
||||||
LOG.info("IGDB auto-scrape: no results for '" + game.getTitle() + "'");
|
LOG.info("IGDB auto-scrape: no results for '" + game.getTitle() + "'");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pick best match: prefer DOS platform, otherwise first result
|
|
||||||
Map<String, Object> best = null;
|
Map<String, Object> best = null;
|
||||||
for (Map<String, Object> r : results) {
|
for (Map<String, Object> r : results) {
|
||||||
if (Boolean.TRUE.equals(r.get("is_dos"))) {
|
if (Boolean.TRUE.equals(r.get("is_dos"))) { best = r; break; }
|
||||||
best = r;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (best == null) best = results.getFirst();
|
if (best == null) best = results.getFirst();
|
||||||
|
|
||||||
applyMatch(game, best);
|
applyMatch(game, best);
|
||||||
LOG.info("IGDB auto-scrape: applied '" + best.get("name") + "' to '" + game.getTitle() + "'");
|
LOG.info("IGDB auto-scrape: applied '" + best.get("name") + "' to '" + game.getTitle() + "'");
|
||||||
|
|
||||||
// Fetch videos and screenshots
|
|
||||||
Object igdbIdObj = best.get("igdb_id");
|
Object igdbIdObj = best.get("igdb_id");
|
||||||
if (igdbIdObj instanceof Number igdbIdNum) {
|
if (igdbIdObj instanceof Number igdbIdNum) {
|
||||||
int igdbId = igdbIdNum.intValue();
|
int igdbId = igdbIdNum.intValue();
|
||||||
try {
|
try { List<String> v = fetchVideos(igdbId); if (!v.isEmpty()) game.setVideos(v); }
|
||||||
List<String> videos = fetchVideos(igdbId);
|
catch (Exception e) { LOG.warning("IGDB: failed to fetch videos: " + e.getMessage()); }
|
||||||
if (!videos.isEmpty()) {
|
try { List<String> s = fetchScreenshots(igdbId); if (!s.isEmpty()) { game.setScreenshots(s); game.setHasScreenshots(true); } }
|
||||||
game.setVideos(videos);
|
catch (Exception e) { LOG.warning("IGDB: failed to fetch screenshots: " + e.getMessage()); }
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
|
||||||
LOG.warning("IGDB: failed to fetch videos: " + e.getMessage());
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
List<String> screenshots = fetchScreenshots(igdbId);
|
|
||||||
if (!screenshots.isEmpty()) {
|
|
||||||
game.setScreenshots(screenshots);
|
|
||||||
game.setHasScreenshots(true);
|
|
||||||
}
|
|
||||||
} catch (Exception e) {
|
|
||||||
LOG.warning("IGDB: failed to fetch screenshots: " + e.getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
LOG.warning("IGDB auto-scrape failed for '" + game.getTitle() + "': " + e.getMessage());
|
LOG.warning("IGDB auto-scrape failed for '" + game.getTitle() + "': " + e.getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Apply a specific IGDB ID to a game. Fetches fresh data and downloads cover.
|
|
||||||
*/
|
|
||||||
public void applyIgdbId(Game game, int igdbId) throws Exception {
|
public void applyIgdbId(Game game, int igdbId) throws Exception {
|
||||||
// Fetch game details from IGDB by ID
|
|
||||||
String apql = "fields name,first_release_date,genres.name,"
|
String apql = "fields name,first_release_date,genres.name,"
|
||||||
+ " involved_companies.company.name,involved_companies.developer,involved_companies.publisher,"
|
+ " involved_companies.company.name,involved_companies.developer,involved_companies.publisher,"
|
||||||
+ " summary,cover.url;"
|
+ " summary,cover.url;"
|
||||||
+ " where id = " + igdbId + ";";
|
+ " where id = " + igdbId + ";";
|
||||||
|
JsonNode results = postAndGet("/games", apql);
|
||||||
HttpRequest req = igdbRequest("/games")
|
if (results == null || results.isEmpty()) {
|
||||||
.POST(HttpRequest.BodyPublishers.ofString(apql))
|
|
||||||
.build();
|
|
||||||
|
|
||||||
HttpResponse<String> res = http.send(req, HttpResponse.BodyHandlers.ofString());
|
|
||||||
if (res.statusCode() != 200) {
|
|
||||||
throw new RuntimeException("IGDB fetch failed: HTTP " + res.statusCode());
|
|
||||||
}
|
|
||||||
|
|
||||||
JsonNode results = mapper.readTree(res.body());
|
|
||||||
if (!results.isArray() || results.isEmpty()) {
|
|
||||||
throw new RuntimeException("IGDB: no game found with ID " + igdbId);
|
throw new RuntimeException("IGDB: no game found with ID " + igdbId);
|
||||||
}
|
}
|
||||||
|
|
||||||
applyMatch(game, results.get(0));
|
applyMatch(game, results.get(0));
|
||||||
|
try { List<String> v = fetchVideos(igdbId); if (!v.isEmpty()) game.setVideos(v); }
|
||||||
// Fetch videos and screenshots
|
catch (Exception e) { LOG.warning("IGDB: failed to fetch videos for game " + igdbId + ": " + e.getMessage()); }
|
||||||
try {
|
try { List<String> s = fetchScreenshots(igdbId); if (!s.isEmpty()) { game.setScreenshots(s); game.setHasScreenshots(true); } }
|
||||||
List<String> videos = fetchVideos(igdbId);
|
catch (Exception e) { LOG.warning("IGDB: failed to fetch screenshots for game " + igdbId + ": " + e.getMessage()); }
|
||||||
if (!videos.isEmpty()) {
|
|
||||||
game.setVideos(videos);
|
|
||||||
}
|
|
||||||
} catch (Exception e) {
|
|
||||||
LOG.warning("IGDB: failed to fetch videos for game " + igdbId + ": " + e.getMessage());
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
List<String> screenshots = fetchScreenshots(igdbId);
|
|
||||||
if (!screenshots.isEmpty()) {
|
|
||||||
game.setScreenshots(screenshots);
|
|
||||||
game.setHasScreenshots(true);
|
|
||||||
}
|
|
||||||
} catch (Exception e) {
|
|
||||||
LOG.warning("IGDB: failed to fetch screenshots for game " + igdbId + ": " + e.getMessage());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Apply IGDB game data to a local Game object. Downloads cover if available.
|
|
||||||
*/
|
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
private void applyMatch(Game game, Object matchData) throws Exception {
|
private void applyMatch(Game game, Object matchData) throws Exception {
|
||||||
Map<String, Object> data;
|
Map<String, Object> data;
|
||||||
@@ -378,56 +258,26 @@ public class IgdbService {
|
|||||||
} else {
|
} else {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (data.containsKey("name") && (game.getTitle() == null || game.getTitle().isBlank()))
|
||||||
// Update game fields
|
|
||||||
if (data.containsKey("name") && (game.getTitle() == null || game.getTitle().isBlank())) {
|
|
||||||
game.setTitle((String) data.get("name"));
|
game.setTitle((String) data.get("name"));
|
||||||
}
|
if (data.containsKey("year")) game.setYear((Integer) data.get("year"));
|
||||||
if (data.containsKey("year")) {
|
if (data.containsKey("developer")) game.setDeveloper((String) data.get("developer"));
|
||||||
game.setYear((Integer) data.get("year"));
|
if (data.containsKey("publisher")) game.setPublisher((String) data.get("publisher"));
|
||||||
}
|
if (data.containsKey("summary")) game.setDescription((String) data.get("summary"));
|
||||||
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")) {
|
if (data.containsKey("genres")) {
|
||||||
List<String> genres = (List<String>) data.get("genres");
|
List<String> genres = (List<String>) data.get("genres");
|
||||||
if (!genres.isEmpty()) {
|
if (!genres.isEmpty()) game.setGenre(genres.getFirst());
|
||||||
game.setGenre(genres.getFirst());
|
|
||||||
}
|
}
|
||||||
}
|
if (data.containsKey("igdb_id")) game.setIgdbId((Integer) data.get("igdb_id"));
|
||||||
if (data.containsKey("igdb_id")) {
|
|
||||||
game.setIgdbId((Integer) data.get("igdb_id"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Download cover
|
|
||||||
String coverUrl = (String) data.get("cover_url");
|
String coverUrl = (String) data.get("cover_url");
|
||||||
if (coverUrl != null && !coverUrl.isBlank()) {
|
if (coverUrl != null && !coverUrl.isBlank()) downloadCover(game, coverUrl);
|
||||||
downloadCover(game, coverUrl);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Download a cover image from IGDB CDN and save locally.
|
|
||||||
*/
|
|
||||||
private void downloadCover(Game game, String coverUrl) throws Exception {
|
private void downloadCover(Game game, String coverUrl) throws Exception {
|
||||||
// Ensure full URL (IGDB sometimes omits https:)
|
|
||||||
String url = coverUrl.startsWith("http") ? coverUrl : "https:" + coverUrl;
|
String url = coverUrl.startsWith("http") ? coverUrl : "https:" + coverUrl;
|
||||||
|
HttpRequest req = HttpRequest.newBuilder().uri(URI.create(url)).GET().build();
|
||||||
HttpRequest req = HttpRequest.newBuilder()
|
|
||||||
.uri(URI.create(url))
|
|
||||||
.GET()
|
|
||||||
.build();
|
|
||||||
|
|
||||||
HttpResponse<Path> res = http.send(req, HttpResponse.BodyHandlers.ofFile(
|
HttpResponse<Path> res = http.send(req, HttpResponse.BodyHandlers.ofFile(
|
||||||
gameStore.gameDir(game.getId()).resolve("cover.jpg")
|
gameStore.gameDir(game.getId()).resolve("cover.jpg")));
|
||||||
));
|
|
||||||
|
|
||||||
if (res.statusCode() == 200) {
|
if (res.statusCode() == 200) {
|
||||||
game.setHasCover(true);
|
game.setHasCover(true);
|
||||||
LOG.info("IGDB: cover downloaded for '" + game.getTitle() + "'");
|
LOG.info("IGDB: cover downloaded for '" + game.getTitle() + "'");
|
||||||
@@ -436,6 +286,19 @@ public class IgdbService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Utility ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Convert IGDB epoch (seconds) to year, if present in the node. */
|
||||||
|
private static Optional<Integer> epochToYear(JsonNode node, String field) {
|
||||||
|
if (node.has(field)) {
|
||||||
|
long epoch = node.get(field).asLong();
|
||||||
|
Calendar cal = Calendar.getInstance();
|
||||||
|
cal.setTimeInMillis(epoch * 1000);
|
||||||
|
return Optional.of(cal.get(Calendar.YEAR));
|
||||||
|
}
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
private String sanitize(String s) {
|
private String sanitize(String s) {
|
||||||
return s.replace("\\", "\\\\").replace("\"", "\\\"");
|
return s.replace("\\", "\\\\").replace("\"", "\\\"");
|
||||||
}
|
}
|
||||||
@@ -444,14 +307,7 @@ public class IgdbService {
|
|||||||
Map<String, Object> map = new LinkedHashMap<>();
|
Map<String, Object> map = new LinkedHashMap<>();
|
||||||
map.put("igdb_id", node.has("id") ? node.get("id").asInt() : 0);
|
map.put("igdb_id", node.has("id") ? node.get("id").asInt() : 0);
|
||||||
map.put("name", node.has("name") ? node.get("name").asText() : "");
|
map.put("name", node.has("name") ? node.get("name").asText() : "");
|
||||||
|
epochToYear(node, "first_release_date").ifPresent(y -> map.put("year", y));
|
||||||
if (node.has("first_release_date")) {
|
|
||||||
long epoch = node.get("first_release_date").asLong();
|
|
||||||
Calendar cal = Calendar.getInstance();
|
|
||||||
cal.setTimeInMillis(epoch * 1000);
|
|
||||||
map.put("year", cal.get(Calendar.YEAR));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (node.has("genres") && node.get("genres").isArray()) {
|
if (node.has("genres") && node.get("genres").isArray()) {
|
||||||
List<String> genres = new ArrayList<>();
|
List<String> genres = new ArrayList<>();
|
||||||
for (JsonNode g : node.get("genres")) {
|
for (JsonNode g : node.get("genres")) {
|
||||||
@@ -459,7 +315,6 @@ public class IgdbService {
|
|||||||
}
|
}
|
||||||
map.put("genres", genres);
|
map.put("genres", genres);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (node.has("involved_companies") && node.get("involved_companies").isArray()) {
|
if (node.has("involved_companies") && node.get("involved_companies").isArray()) {
|
||||||
for (JsonNode ic : node.get("involved_companies")) {
|
for (JsonNode ic : node.get("involved_companies")) {
|
||||||
if (ic.has("company") && ic.get("company").has("name")) {
|
if (ic.has("company") && ic.get("company").has("name")) {
|
||||||
@@ -471,18 +326,12 @@ public class IgdbService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (node.has("summary")) map.put("summary", node.get("summary").asText());
|
||||||
if (node.has("summary")) {
|
|
||||||
map.put("summary", node.get("summary").asText());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch cover URL — handle cover.url object or raw ID
|
|
||||||
if (node.has("cover")) {
|
if (node.has("cover")) {
|
||||||
JsonNode cover = node.get("cover");
|
JsonNode cover = node.get("cover");
|
||||||
try {
|
try {
|
||||||
if (cover.has("url")) {
|
if (cover.has("url")) {
|
||||||
String thumbUrl = cover.get("url").asText();
|
String coverUrl = "https:" + cover.get("url").asText().replace("t_thumb", COVER_SIZE);
|
||||||
String coverUrl = "https:" + thumbUrl.replace("t_thumb", COVER_SIZE);
|
|
||||||
map.put("cover_url", coverUrl);
|
map.put("cover_url", coverUrl);
|
||||||
} else if (cover.isInt() || cover.isLong()) {
|
} else if (cover.isInt() || cover.isLong()) {
|
||||||
String coverUrl = fetchCoverUrl(cover.asInt());
|
String coverUrl = fetchCoverUrl(cover.asInt());
|
||||||
@@ -493,7 +342,6 @@ public class IgdbService {
|
|||||||
}
|
}
|
||||||
} catch (Exception ignored) {}
|
} catch (Exception ignored) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,37 +31,35 @@ public class PlatformDetector {
|
|||||||
try (var fis = Files.newInputStream(exePath)) {
|
try (var fis = Files.newInputStream(exePath)) {
|
||||||
byte[] buf = new byte[0x80];
|
byte[] buf = new byte[0x80];
|
||||||
int read = fis.readNBytes(buf, 0, buf.length);
|
int read = fis.readNBytes(buf, 0, buf.length);
|
||||||
|
return detectFromBytes(buf, read);
|
||||||
|
} catch (IOException e) {
|
||||||
|
LOG.warning("Could not read EXE header for " + exePath + ": " + e.getMessage());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect platform from pre-read MZ/PE header bytes.
|
||||||
|
* Shared between filesystem and ZIP-stream callers to avoid duplication.
|
||||||
|
*/
|
||||||
|
static String detectFromBytes(byte[] buf, int read) {
|
||||||
if (read < 2) return null;
|
if (read < 2) return null;
|
||||||
if (buf[0] != 0x4D || buf[1] != 0x5A) return null;
|
if (buf[0] != 0x4D || buf[1] != 0x5A) return null;
|
||||||
if (read < 0x40) return "dos";
|
if (read < 0x40) return "dos";
|
||||||
|
|
||||||
// Read e_lfanew at offset 0x3C
|
|
||||||
int peOffset = (buf[0x3C] & 0xFF)
|
int peOffset = (buf[0x3C] & 0xFF)
|
||||||
| ((buf[0x3D] & 0xFF) << 8)
|
| ((buf[0x3D] & 0xFF) << 8)
|
||||||
| ((buf[0x3E] & 0xFF) << 16)
|
| ((buf[0x3E] & 0xFF) << 16)
|
||||||
| ((buf[0x3F] & 0xFF) << 24);
|
| ((buf[0x3F] & 0xFF) << 24);
|
||||||
|
|
||||||
if (peOffset < 0) return "dos";
|
if (peOffset < 0 || peOffset + 2 > read) return "dos";
|
||||||
|
|
||||||
byte sig1, sig2;
|
byte sig1 = buf[peOffset];
|
||||||
if (peOffset + 2 <= read) {
|
byte sig2 = buf[peOffset + 1];
|
||||||
sig1 = buf[peOffset];
|
|
||||||
sig2 = buf[peOffset + 1];
|
|
||||||
} else {
|
|
||||||
try (var fis2 = Files.newInputStream(exePath)) {
|
|
||||||
sig1 = (byte) fis2.read();
|
|
||||||
sig2 = (byte) fis2.read();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((sig1 == 0x50 && sig2 == 0x45) // PE
|
if ((sig1 == 0x50 && sig2 == 0x45) // PE
|
||||||
|| (sig1 == 0x4E && sig2 == 0x45)) // NE
|
|| (sig1 == 0x4E && sig2 == 0x45)) // NE
|
||||||
return "windows";
|
return "windows";
|
||||||
|
|
||||||
return "dos";
|
return "dos";
|
||||||
} catch (IOException e) {
|
|
||||||
LOG.warning("Could not read EXE header for " + exePath + ": " + e.getMessage());
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -20,42 +20,33 @@ public class StaticResource {
|
|||||||
@ConfigProperty(name = "dostalgia.data.dir", defaultValue = "data")
|
@ConfigProperty(name = "dostalgia.data.dir", defaultValue = "data")
|
||||||
String dataDir;
|
String dataDir;
|
||||||
|
|
||||||
@GET
|
/** Serve a file from a subdirectory of the data dir with path traversal protection. */
|
||||||
@jakarta.ws.rs.Path("/games/{path: .*}")
|
private Response serveFile(Path base, String path, String cacheHeader) {
|
||||||
public Response getGameFile(@PathParam("path") String path) {
|
Path file = base.resolve(path).normalize();
|
||||||
Path file = Path.of(dataDir, "games", path).normalize();
|
|
||||||
Path base = Path.of(dataDir, "games").normalize();
|
|
||||||
if (!file.startsWith(base)) {
|
if (!file.startsWith(base)) {
|
||||||
return Response.status(403).build();
|
return Response.status(403).build();
|
||||||
}
|
}
|
||||||
if (!Files.exists(file) || Files.isDirectory(file)) {
|
if (!Files.exists(file) || Files.isDirectory(file)) {
|
||||||
return Response.status(404).build();
|
return Response.status(404).build();
|
||||||
}
|
}
|
||||||
|
|
||||||
String contentType = guessContentType(file.getFileName().toString());
|
String contentType = guessContentType(file.getFileName().toString());
|
||||||
return Response.ok(new FileStream(file))
|
var builder = Response.ok(new FileStream(file)).type(contentType);
|
||||||
.type(contentType)
|
if (cacheHeader != null) {
|
||||||
.header("Accept-Ranges", "bytes")
|
builder.header(cacheHeader.split(":")[0], cacheHeader.split(":", 2)[1].trim());
|
||||||
.build();
|
}
|
||||||
|
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
|
@GET
|
||||||
@jakarta.ws.rs.Path("/artwork/{path: .*}")
|
@jakarta.ws.rs.Path("/artwork/{path: .*}")
|
||||||
public Response getArtwork(@PathParam("path") String path) {
|
public Response getArtwork(@PathParam("path") String path) {
|
||||||
Path file = Path.of(dataDir, "artwork", path).normalize();
|
return serveFile(Path.of(dataDir, "artwork"), path, "Cache-Control: public, max-age=86400");
|
||||||
Path base = Path.of(dataDir, "artwork").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());
|
|
||||||
return Response.ok(new FileStream(file))
|
|
||||||
.type(contentType)
|
|
||||||
.header("Cache-Control", "public, max-age=86400")
|
|
||||||
.build();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Health check */
|
/** Health check */
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import java.nio.file.attribute.BasicFileAttributes;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.logging.Logger;
|
import java.util.logging.Logger;
|
||||||
|
import jakarta.annotation.Nonnull;
|
||||||
|
|
||||||
@jakarta.ws.rs.Path("/api/upload")
|
@jakarta.ws.rs.Path("/api/upload")
|
||||||
@Produces(MediaType.APPLICATION_JSON)
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
@@ -139,7 +140,8 @@ public class UploadResource {
|
|||||||
zip.createBundle(extractDir, mainExe, cdImages, bundlePath);
|
zip.createBundle(extractDir, mainExe, cdImages, bundlePath);
|
||||||
|
|
||||||
// Detect platform (DOS vs Windows)
|
// Detect platform (DOS vs Windows)
|
||||||
String platformType = mainExe != null ? platform.detect(Path.of(mainExe)) : "dos";
|
Path mainExePath = mainExe != null ? Path.of(mainExe) : null;
|
||||||
|
String platformType = mainExe != null ? platform.detect(mainExePath) : "dos";
|
||||||
|
|
||||||
// Save metadata
|
// Save metadata
|
||||||
Game game = new Game(gameId, title);
|
Game game = new Game(gameId, title);
|
||||||
@@ -148,14 +150,15 @@ public class UploadResource {
|
|||||||
|
|
||||||
// Store the selected executable and the full list for the UI picker
|
// Store the selected executable and the full list for the UI picker
|
||||||
String relMain = mainExe != null
|
String relMain = mainExe != null
|
||||||
? extractDir.relativize(Path.of(mainExe)).toString().replace('\\', '/')
|
? extractDir.relativize(mainExePath).toString().replace('\\', '/')
|
||||||
: "";
|
: "";
|
||||||
game.setExecutable(relMain);
|
game.setExecutable(relMain);
|
||||||
game.setExecutables(exeDetector.findAll(extractDir));
|
game.setExecutables(exeDetector.findAll(extractDir));
|
||||||
if (setupExe != null) {
|
if (setupExe != null) {
|
||||||
String setupPlatform = platform.detect(Path.of(setupExe));
|
Path setupExePath = Path.of(setupExe);
|
||||||
|
String setupPlatform = platform.detect(setupExePath);
|
||||||
if (!"windows".equals(setupPlatform)) {
|
if (!"windows".equals(setupPlatform)) {
|
||||||
game.setSetupExe(extractDir.relativize(Path.of(setupExe)).toString());
|
game.setSetupExe(extractDir.relativize(setupExePath).toString());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
game.setReady(true);
|
game.setReady(true);
|
||||||
@@ -221,12 +224,12 @@ public class UploadResource {
|
|||||||
if (!Files.exists(dir)) return;
|
if (!Files.exists(dir)) return;
|
||||||
Files.walkFileTree(dir, new SimpleFileVisitor<>() {
|
Files.walkFileTree(dir, new SimpleFileVisitor<>() {
|
||||||
@Override
|
@Override
|
||||||
public FileVisitResult visitFile(Path f, BasicFileAttributes a) throws IOException {
|
public FileVisitResult visitFile(@Nonnull Path f, @Nonnull BasicFileAttributes a) throws IOException {
|
||||||
Files.delete(f);
|
Files.delete(f);
|
||||||
return FileVisitResult.CONTINUE;
|
return FileVisitResult.CONTINUE;
|
||||||
}
|
}
|
||||||
@Override
|
@Override
|
||||||
public FileVisitResult postVisitDirectory(Path d, IOException e) throws IOException {
|
public FileVisitResult postVisitDirectory(@Nonnull Path d, IOException e) throws IOException {
|
||||||
Files.delete(d);
|
Files.delete(d);
|
||||||
return FileVisitResult.CONTINUE;
|
return FileVisitResult.CONTINUE;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import java.util.List;
|
|||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.TreeSet;
|
import java.util.TreeSet;
|
||||||
import java.util.logging.Logger;
|
import java.util.logging.Logger;
|
||||||
|
import java.util.zip.ZipEntry;
|
||||||
|
import java.util.zip.ZipOutputStream;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ZIP extraction, directory flattening, and .jsdos bundle creation.
|
* ZIP extraction, directory flattening, and .jsdos bundle creation.
|
||||||
@@ -25,7 +27,6 @@ public class ZipService {
|
|||||||
@Inject
|
@Inject
|
||||||
ConfigBuilder config;
|
ConfigBuilder config;
|
||||||
|
|
||||||
/** File extensions to exclude from .jsdos bundles (disk images DOSBox can't mount). */
|
|
||||||
private static final Set<String> SKIP_EXT = Set.of(
|
private static final Set<String> SKIP_EXT = Set.of(
|
||||||
".nrg", ".mdf", ".mds", ".sub", ".dmg"
|
".nrg", ".mdf", ".mds", ".sub", ".dmg"
|
||||||
);
|
);
|
||||||
@@ -50,14 +51,13 @@ public class ZipService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* If the extraction directory contains a single subdirectory, move its contents up.
|
* If the extraction directory contains a single subdirectory, move its contents up.
|
||||||
* Handles ZIPs where all game files are inside a folder.
|
|
||||||
*/
|
*/
|
||||||
public void flattenSingleDir(Path dir) throws IOException {
|
public void flattenSingleDir(Path dir) throws IOException {
|
||||||
Path singleDir = null;
|
Path singleDir = null;
|
||||||
try (var files = Files.list(dir)) {
|
try (var files = Files.list(dir)) {
|
||||||
for (Path entry : files.toList()) {
|
for (Path entry : files.toList()) {
|
||||||
if (Files.isDirectory(entry)) {
|
if (Files.isDirectory(entry)) {
|
||||||
if (singleDir != null) return; // multiple directories — abort
|
if (singleDir != null) return;
|
||||||
singleDir = entry;
|
singleDir = entry;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -85,10 +85,8 @@ public class ZipService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a .jsdos bundle ZIP from the extracted game directory.
|
* Create a .jsdos bundle ZIP from the extracted game directory.
|
||||||
* Generates DOSBox configs and packs everything into a valid js-dos bundle.
|
|
||||||
*/
|
*/
|
||||||
public void createBundle(Path extractDir, String exePath, List<String> cdImages, Path bundlePath) throws IOException {
|
public void createBundle(Path extractDir, String exePath, List<String> cdImages, Path bundlePath) throws IOException {
|
||||||
// Write .jsdos config into the extract directory
|
|
||||||
Path jsdos = extractDir.resolve(".jsdos");
|
Path jsdos = extractDir.resolve(".jsdos");
|
||||||
Files.createDirectories(jsdos);
|
Files.createDirectories(jsdos);
|
||||||
if (exePath != null) {
|
if (exePath != null) {
|
||||||
@@ -100,8 +98,7 @@ public class ZipService {
|
|||||||
Files.write(jsdos.resolve("jsdos.json"), config.buildCdOnlyJsdosJson(cdImages));
|
Files.write(jsdos.resolve("jsdos.json"), config.buildCdOnlyJsdosJson(cdImages));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ZIP it up from extractDir
|
try (var zos = new ZipOutputStream(Files.newOutputStream(bundlePath))) {
|
||||||
try (var zos = new java.util.zip.ZipOutputStream(Files.newOutputStream(bundlePath))) {
|
|
||||||
|
|
||||||
// Phase 1: Collect all directory paths
|
// Phase 1: Collect all directory paths
|
||||||
var allDirs = new TreeSet<String>();
|
var allDirs = new TreeSet<String>();
|
||||||
@@ -116,24 +113,16 @@ public class ZipService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase 2: Write directory entries first (sorted → shallowest first)
|
// Phase 2: Write directory entries first
|
||||||
for (String dir : allDirs) {
|
for (String dir : allDirs) {
|
||||||
zos.putNextEntry(new java.util.zip.ZipEntry(dir));
|
zos.putNextEntry(new ZipEntry(dir));
|
||||||
zos.closeEntry();
|
zos.closeEntry();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase 3: .jsdos config files (must come before game files)
|
// Phase 3: .jsdos config files (before game files)
|
||||||
try (var walk = Files.walk(jsdos)) {
|
try (var walk = Files.walk(jsdos)) {
|
||||||
walk.filter(Files::isRegularFile).sorted().forEach(f -> {
|
walk.filter(Files::isRegularFile).sorted()
|
||||||
try {
|
.forEach(f -> zipEntry(zos, extractDir, f));
|
||||||
String entryName = extractDir.relativize(f).toString().replace('\\', '/');
|
|
||||||
zos.putNextEntry(new java.util.zip.ZipEntry(entryName));
|
|
||||||
Files.copy(f, zos);
|
|
||||||
zos.closeEntry();
|
|
||||||
} catch (IOException e) {
|
|
||||||
throw new UncheckedIOException(e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase 4: Game files (excluding skipped extensions)
|
// Phase 4: Game files (excluding skipped extensions)
|
||||||
@@ -146,22 +135,27 @@ public class ZipService {
|
|||||||
return dot < 0 || !SKIP_EXT.contains(n.substring(dot));
|
return dot < 0 || !SKIP_EXT.contains(n.substring(dot));
|
||||||
})
|
})
|
||||||
.sorted()
|
.sorted()
|
||||||
.forEach(f -> {
|
.forEach(f -> zipEntry(zos, extractDir, f));
|
||||||
try {
|
|
||||||
String entryName = extractDir.relativize(f).toString().replace('\\', '/');
|
|
||||||
zos.putNextEntry(new java.util.zip.ZipEntry(entryName));
|
|
||||||
Files.copy(f, zos);
|
|
||||||
zos.closeEntry();
|
|
||||||
} catch (IOException e) {
|
|
||||||
throw new UncheckedIOException(e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up .jsdos config files from the extract dir
|
// Clean up .jsdos config files from the extract dir
|
||||||
Files.walk(jsdos).sorted(Comparator.reverseOrder()).forEach(p -> {
|
try (var cleanup = Files.walk(jsdos)) {
|
||||||
|
cleanup.sorted(Comparator.reverseOrder()).forEach(p -> {
|
||||||
try { Files.deleteIfExists(p); } catch (IOException ignored) {}
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user