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";
|
||||
}
|
||||
|
||||
private static String buildAutoexecScript(String dir, String exe, List<String> cdImages) {
|
||||
private static String buildCdMountScript(List<String> cdImages) {
|
||||
StringBuilder script = new StringBuilder();
|
||||
script.append("mount c .\n");
|
||||
char driveLetter = 'D';
|
||||
@@ -200,6 +200,11 @@ public class ConfigBuilder {
|
||||
.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");
|
||||
@@ -210,17 +215,7 @@ public class ConfigBuilder {
|
||||
}
|
||||
|
||||
private static String buildCdOnlyAutoexecScript(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++;
|
||||
}
|
||||
StringBuilder script = new StringBuilder(buildCdMountScript(cdImages));
|
||||
script.append("c:\n");
|
||||
return script.toString();
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.dostalgia;
|
||||
|
||||
import jakarta.annotation.Nonnull;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import jakarta.inject.Inject;
|
||||
|
||||
@@ -40,7 +41,7 @@ public class ExecutableDetector {
|
||||
List<String> result = new ArrayList<>();
|
||||
Files.walkFileTree(dir, new SimpleFileVisitor<>() {
|
||||
@Override
|
||||
public FileVisitResult visitFile(Path f, BasicFileAttributes a) {
|
||||
public FileVisitResult visitFile(@Nonnull Path f, @Nonnull BasicFileAttributes a) {
|
||||
String name = f.getFileName().toString().toLowerCase();
|
||||
if (name.endsWith(".exe") || name.endsWith(".com") || name.endsWith(".bat")) {
|
||||
result.add(dir.relativize(f).toString().replace('\\', '/'));
|
||||
@@ -59,7 +60,7 @@ public class ExecutableDetector {
|
||||
|
||||
Files.walkFileTree(dir, new SimpleFileVisitor<>() {
|
||||
@Override
|
||||
public FileVisitResult visitFile(Path f, BasicFileAttributes a) {
|
||||
public 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;
|
||||
@@ -103,7 +104,7 @@ public class ExecutableDetector {
|
||||
|
||||
Files.walkFileTree(dir, new SimpleFileVisitor<>() {
|
||||
@Override
|
||||
public FileVisitResult visitFile(Path f, BasicFileAttributes a) {
|
||||
public 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"))
|
||||
|
||||
@@ -23,6 +23,8 @@ import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.logging.Logger;
|
||||
import jakarta.annotation.Nonnull;
|
||||
|
||||
/** Manages game metadata stored as JSON files on disk. */
|
||||
@ApplicationScoped
|
||||
@@ -125,12 +127,12 @@ public class GameService implements GameStore {
|
||||
if (Files.exists(dir)) {
|
||||
Files.walkFileTree(dir, new SimpleFileVisitor<>() {
|
||||
@Override
|
||||
public FileVisitResult visitFile(Path f, BasicFileAttributes a) throws IOException {
|
||||
public FileVisitResult visitFile(@Nonnull Path f, @Nonnull BasicFileAttributes a) throws IOException {
|
||||
Files.delete(f);
|
||||
return FileVisitResult.CONTINUE;
|
||||
}
|
||||
@Override
|
||||
public FileVisitResult postVisitDirectory(Path d, IOException e) throws IOException {
|
||||
public FileVisitResult postVisitDirectory(@Nonnull Path d, IOException e) throws IOException {
|
||||
Files.delete(d);
|
||||
return FileVisitResult.CONTINUE;
|
||||
}
|
||||
@@ -227,16 +229,7 @@ public class GameService implements GameStore {
|
||||
if (entry.getName().equals(relExe.replace('\\', '/'))) {
|
||||
byte[] buf = new byte[0x80];
|
||||
int read = zis.readNBytes(buf, 0, buf.length);
|
||||
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], sig2 = buf[peOffset + 1];
|
||||
if ((sig1 == 0x50 && sig2 == 0x45) || (sig1 == 0x4E && sig2 == 0x45))
|
||||
return "windows";
|
||||
return "dos";
|
||||
return PlatformDetector.detectFromBytes(buf, read);
|
||||
}
|
||||
zis.closeEntry();
|
||||
}
|
||||
|
||||
@@ -12,12 +12,7 @@ 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.*;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
/** 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)) {
|
||||
return accessToken;
|
||||
}
|
||||
String body = "client_id=" + clientId.get()
|
||||
+ "&client_secret=" + clientSecret.get()
|
||||
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()
|
||||
@@ -72,27 +69,36 @@ public class IgdbService {
|
||||
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); // 2 min buffer
|
||||
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", clientId.get())
|
||||
.header("Client-ID", cid)
|
||||
.header("Authorization", "Bearer " + getToken())
|
||||
.header("Content-Type", "text/plain");
|
||||
}
|
||||
|
||||
/**
|
||||
* Search IGDB for games matching the given query.
|
||||
* Returns a list of simplified result maps suitable for JSON serialization.
|
||||
*/
|
||||
/** 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;
|
||||
}
|
||||
|
||||
// ─── Search ───────────────────────────────────────────────────
|
||||
|
||||
public List<Map<String, Object>> search(String query) throws Exception {
|
||||
if (!isConfigured()) return List.of();
|
||||
|
||||
// Search limited to DOS platform (IGDB platform ID 13)
|
||||
String apql = "search \"" + sanitize(query) + "\";"
|
||||
+ " fields name,first_release_date,genres.name,"
|
||||
+ " involved_companies.company.name,involved_companies.developer,involved_companies.publisher,"
|
||||
@@ -100,19 +106,9 @@ public class IgdbService {
|
||||
+ " where platforms = [13];"
|
||||
+ " limit 20;";
|
||||
|
||||
HttpRequest req = igdbRequest("/games")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(apql))
|
||||
.build();
|
||||
|
||||
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());
|
||||
JsonNode results = postAndGet("/games", apql);
|
||||
if (results == null || results.isEmpty()) {
|
||||
LOG.info("IGDB search: no results for '" + query + "'");
|
||||
return List.of();
|
||||
}
|
||||
|
||||
@@ -122,12 +118,7 @@ public class IgdbService {
|
||||
entry.put("igdb_id", game.get("id").asInt());
|
||||
entry.put("name", game.has("name") ? game.get("name").asText() : query);
|
||||
|
||||
if (game.has("first_release_date")) {
|
||||
long epoch = game.get("first_release_date").asLong();
|
||||
Calendar cal = Calendar.getInstance();
|
||||
cal.setTimeInMillis(epoch * 1000);
|
||||
entry.put("year", cal.get(Calendar.YEAR));
|
||||
}
|
||||
epochToYear(game, "first_release_date").ifPresent(y -> entry.put("year", y));
|
||||
|
||||
if (game.has("genres") && game.get("genres").isArray()) {
|
||||
List<String> genres = new ArrayList<>();
|
||||
@@ -137,7 +128,6 @@ public class IgdbService {
|
||||
entry.put("genres", genres);
|
||||
}
|
||||
|
||||
// Extract developer and publisher
|
||||
if (game.has("involved_companies") && game.get("involved_companies").isArray()) {
|
||||
String developer = null;
|
||||
String publisher = null;
|
||||
@@ -158,216 +148,106 @@ public class IgdbService {
|
||||
entry.put("summary", game.get("summary").asText());
|
||||
}
|
||||
|
||||
// Platforms (check if DOS or PC)
|
||||
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);
|
||||
boolean isDos = platformIds.contains(13); // 13 = DOS
|
||||
entry.put("is_dos", isDos);
|
||||
entry.put("is_dos", platformIds.contains(13));
|
||||
}
|
||||
|
||||
// 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")) {
|
||||
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);
|
||||
entry.put("cover_url", coverUrl);
|
||||
} 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();
|
||||
String coverUrl = fetchCoverUrl(coverId);
|
||||
if (coverUrl != null) {
|
||||
entry.put("cover_url", coverUrl);
|
||||
}
|
||||
if (coverUrl != null) entry.put("cover_url", coverUrl);
|
||||
}
|
||||
|
||||
out.add(entry);
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the cover image URL for a given IGDB cover ID.
|
||||
*/
|
||||
private String fetchCoverUrl(int coverId) throws Exception {
|
||||
String apql = "fields url; where id = " + coverId + ";";
|
||||
HttpRequest req = igdbRequest("/covers")
|
||||
.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;
|
||||
|
||||
JsonNode results = postAndGet("/covers", "fields url; where id = " + coverId + ";");
|
||||
if (results == null || results.isEmpty()) return null;
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch YouTube video IDs associated with an IGDB game.
|
||||
*/
|
||||
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();
|
||||
|
||||
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());
|
||||
}
|
||||
if (v.has("video_id")) videos.add(v.get("video_id").asText());
|
||||
}
|
||||
return videos;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch screenshot image URLs for an IGDB game.
|
||||
*/
|
||||
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();
|
||||
|
||||
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 thumbUrl = s.get("url").asText();
|
||||
// Use bigger size for display
|
||||
String fullUrl = "https:" + thumbUrl.replace("t_thumb", "t_1080p");
|
||||
String fullUrl = "https:" + s.get("url").asText().replace("t_thumb", "t_1080p");
|
||||
screenshots.add(fullUrl);
|
||||
}
|
||||
}
|
||||
return screenshots;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
// ─── Auto-Scrape ──────────────────────────────────────────────
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// Pick best match: prefer DOS platform, otherwise first result
|
||||
Map<String, Object> best = null;
|
||||
for (Map<String, Object> r : results) {
|
||||
if (Boolean.TRUE.equals(r.get("is_dos"))) {
|
||||
best = r;
|
||||
break;
|
||||
}
|
||||
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() + "'");
|
||||
|
||||
// Fetch videos and screenshots
|
||||
Object igdbIdObj = best.get("igdb_id");
|
||||
if (igdbIdObj instanceof Number igdbIdNum) {
|
||||
int igdbId = igdbIdNum.intValue();
|
||||
try {
|
||||
List<String> videos = fetchVideos(igdbId);
|
||||
if (!videos.isEmpty()) {
|
||||
game.setVideos(videos);
|
||||
}
|
||||
} 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());
|
||||
}
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a specific IGDB ID to a game. Fetches fresh data and downloads cover.
|
||||
*/
|
||||
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,"
|
||||
+ " involved_companies.company.name,involved_companies.developer,involved_companies.publisher,"
|
||||
+ " summary,cover.url;"
|
||||
+ " where id = " + igdbId + ";";
|
||||
|
||||
HttpRequest req = igdbRequest("/games")
|
||||
.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()) {
|
||||
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));
|
||||
|
||||
// Fetch videos and screenshots
|
||||
try {
|
||||
List<String> videos = fetchVideos(igdbId);
|
||||
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());
|
||||
}
|
||||
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()); }
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply IGDB game data to a local Game object. Downloads cover if available.
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
private void applyMatch(Game game, Object matchData) throws Exception {
|
||||
Map<String, Object> data;
|
||||
@@ -378,56 +258,26 @@ public class IgdbService {
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
// Update game fields
|
||||
if (data.containsKey("name") && (game.getTitle() == null || game.getTitle().isBlank())) {
|
||||
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("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 (!genres.isEmpty()) game.setGenre(genres.getFirst());
|
||||
}
|
||||
if (data.containsKey("igdb_id")) {
|
||||
game.setIgdbId((Integer) data.get("igdb_id"));
|
||||
}
|
||||
|
||||
// Download cover
|
||||
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);
|
||||
}
|
||||
if (coverUrl != null && !coverUrl.isBlank()) downloadCover(game, coverUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a cover image from IGDB CDN and save locally.
|
||||
*/
|
||||
private void downloadCover(Game game, String coverUrl) throws Exception {
|
||||
// Ensure full URL (IGDB sometimes omits https:)
|
||||
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(
|
||||
gameStore.gameDir(game.getId()).resolve("cover.jpg")
|
||||
));
|
||||
|
||||
gameStore.gameDir(game.getId()).resolve("cover.jpg")));
|
||||
if (res.statusCode() == 200) {
|
||||
game.setHasCover(true);
|
||||
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) {
|
||||
return s.replace("\\", "\\\\").replace("\"", "\\\"");
|
||||
}
|
||||
@@ -444,14 +307,7 @@ public class IgdbService {
|
||||
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() : "");
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
epochToYear(node, "first_release_date").ifPresent(y -> map.put("year", y));
|
||||
if (node.has("genres") && node.get("genres").isArray()) {
|
||||
List<String> genres = new ArrayList<>();
|
||||
for (JsonNode g : node.get("genres")) {
|
||||
@@ -459,7 +315,6 @@ public class IgdbService {
|
||||
}
|
||||
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")) {
|
||||
@@ -471,18 +326,12 @@ public class IgdbService {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (node.has("summary")) {
|
||||
map.put("summary", node.get("summary").asText());
|
||||
}
|
||||
|
||||
// Fetch cover URL — handle cover.url object or raw ID
|
||||
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 thumbUrl = cover.get("url").asText();
|
||||
String coverUrl = "https:" + thumbUrl.replace("t_thumb", COVER_SIZE);
|
||||
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());
|
||||
@@ -493,7 +342,6 @@ public class IgdbService {
|
||||
}
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,37 +31,35 @@ public class PlatformDetector {
|
||||
try (var fis = Files.newInputStream(exePath)) {
|
||||
byte[] buf = new byte[0x80];
|
||||
int read = fis.readNBytes(buf, 0, buf.length);
|
||||
if (read < 2) return null;
|
||||
if (buf[0] != 0x4D || buf[1] != 0x5A) return null;
|
||||
if (read < 0x40) return "dos";
|
||||
|
||||
// Read e_lfanew at offset 0x3C
|
||||
int peOffset = (buf[0x3C] & 0xFF)
|
||||
| ((buf[0x3D] & 0xFF) << 8)
|
||||
| ((buf[0x3E] & 0xFF) << 16)
|
||||
| ((buf[0x3F] & 0xFF) << 24);
|
||||
|
||||
if (peOffset < 0) return "dos";
|
||||
|
||||
byte sig1, sig2;
|
||||
if (peOffset + 2 <= read) {
|
||||
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
|
||||
|| (sig1 == 0x4E && sig2 == 0x45)) // NE
|
||||
return "windows";
|
||||
|
||||
return "dos";
|
||||
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 (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";
|
||||
}
|
||||
|
||||
@@ -20,42 +20,33 @@ public class StaticResource {
|
||||
@ConfigProperty(name = "dostalgia.data.dir", defaultValue = "data")
|
||||
String dataDir;
|
||||
|
||||
@GET
|
||||
@jakarta.ws.rs.Path("/games/{path: .*}")
|
||||
public Response getGameFile(@PathParam("path") String path) {
|
||||
Path file = Path.of(dataDir, "games", path).normalize();
|
||||
Path base = Path.of(dataDir, "games").normalize();
|
||||
/** 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());
|
||||
return Response.ok(new FileStream(file))
|
||||
.type(contentType)
|
||||
.header("Accept-Ranges", "bytes")
|
||||
.build();
|
||||
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) {
|
||||
Path file = Path.of(dataDir, "artwork", path).normalize();
|
||||
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();
|
||||
return serveFile(Path.of(dataDir, "artwork"), path, "Cache-Control: public, max-age=86400");
|
||||
}
|
||||
|
||||
/** Health check */
|
||||
|
||||
@@ -20,6 +20,7 @@ import java.nio.file.attribute.BasicFileAttributes;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.logging.Logger;
|
||||
import jakarta.annotation.Nonnull;
|
||||
|
||||
@jakarta.ws.rs.Path("/api/upload")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@@ -139,7 +140,8 @@ public class UploadResource {
|
||||
zip.createBundle(extractDir, mainExe, cdImages, bundlePath);
|
||||
|
||||
// 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
|
||||
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
|
||||
String relMain = mainExe != null
|
||||
? extractDir.relativize(Path.of(mainExe)).toString().replace('\\', '/')
|
||||
? extractDir.relativize(mainExePath).toString().replace('\\', '/')
|
||||
: "";
|
||||
game.setExecutable(relMain);
|
||||
game.setExecutables(exeDetector.findAll(extractDir));
|
||||
if (setupExe != null) {
|
||||
String setupPlatform = platform.detect(Path.of(setupExe));
|
||||
Path setupExePath = Path.of(setupExe);
|
||||
String setupPlatform = platform.detect(setupExePath);
|
||||
if (!"windows".equals(setupPlatform)) {
|
||||
game.setSetupExe(extractDir.relativize(Path.of(setupExe)).toString());
|
||||
game.setSetupExe(extractDir.relativize(setupExePath).toString());
|
||||
}
|
||||
}
|
||||
game.setReady(true);
|
||||
@@ -221,12 +224,12 @@ public class UploadResource {
|
||||
if (!Files.exists(dir)) return;
|
||||
Files.walkFileTree(dir, new SimpleFileVisitor<>() {
|
||||
@Override
|
||||
public FileVisitResult visitFile(Path f, BasicFileAttributes a) throws IOException {
|
||||
public FileVisitResult visitFile(@Nonnull Path f, @Nonnull BasicFileAttributes a) throws IOException {
|
||||
Files.delete(f);
|
||||
return FileVisitResult.CONTINUE;
|
||||
}
|
||||
@Override
|
||||
public FileVisitResult postVisitDirectory(Path d, IOException e) throws IOException {
|
||||
public FileVisitResult postVisitDirectory(@Nonnull Path d, IOException e) throws IOException {
|
||||
Files.delete(d);
|
||||
return FileVisitResult.CONTINUE;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ 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.
|
||||
@@ -25,7 +27,6 @@ public class ZipService {
|
||||
@Inject
|
||||
ConfigBuilder config;
|
||||
|
||||
/** File extensions to exclude from .jsdos bundles (disk images DOSBox can't mount). */
|
||||
private static final Set<String> SKIP_EXT = Set.of(
|
||||
".nrg", ".mdf", ".mds", ".sub", ".dmg"
|
||||
);
|
||||
@@ -50,14 +51,13 @@ public class ZipService {
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
Path singleDir = null;
|
||||
try (var files = Files.list(dir)) {
|
||||
for (Path entry : files.toList()) {
|
||||
if (Files.isDirectory(entry)) {
|
||||
if (singleDir != null) return; // multiple directories — abort
|
||||
if (singleDir != null) return;
|
||||
singleDir = entry;
|
||||
}
|
||||
}
|
||||
@@ -85,10 +85,8 @@ public class ZipService {
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
// Write .jsdos config into the extract directory
|
||||
Path jsdos = extractDir.resolve(".jsdos");
|
||||
Files.createDirectories(jsdos);
|
||||
if (exePath != null) {
|
||||
@@ -100,8 +98,7 @@ public class ZipService {
|
||||
Files.write(jsdos.resolve("jsdos.json"), config.buildCdOnlyJsdosJson(cdImages));
|
||||
}
|
||||
|
||||
// ZIP it up from extractDir
|
||||
try (var zos = new java.util.zip.ZipOutputStream(Files.newOutputStream(bundlePath))) {
|
||||
try (var zos = new ZipOutputStream(Files.newOutputStream(bundlePath))) {
|
||||
|
||||
// Phase 1: Collect all directory paths
|
||||
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) {
|
||||
zos.putNextEntry(new java.util.zip.ZipEntry(dir));
|
||||
zos.putNextEntry(new ZipEntry(dir));
|
||||
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)) {
|
||||
walk.filter(Files::isRegularFile).sorted().forEach(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);
|
||||
}
|
||||
});
|
||||
walk.filter(Files::isRegularFile).sorted()
|
||||
.forEach(f -> zipEntry(zos, extractDir, f));
|
||||
}
|
||||
|
||||
// Phase 4: Game files (excluding skipped extensions)
|
||||
@@ -146,22 +135,27 @@ public class ZipService {
|
||||
return dot < 0 || !SKIP_EXT.contains(n.substring(dot));
|
||||
})
|
||||
.sorted()
|
||||
.forEach(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);
|
||||
}
|
||||
});
|
||||
.forEach(f -> zipEntry(zos, extractDir, f));
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up .jsdos config files from the extract dir
|
||||
Files.walk(jsdos).sorted(Comparator.reverseOrder()).forEach(p -> {
|
||||
try { Files.deleteIfExists(p); } catch (IOException ignored) {}
|
||||
});
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user