IGDB: add DOS-only search, gameplay videos + screenshot support
Build & Deploy / build-and-deploy (push) Successful in 1m28s
Build & Deploy / build-and-deploy (push) Successful in 1m28s
- Search now filters to DOS platform only (platform ID 13) - New fetchVideos() queries IGDB game_videos endpoint for YouTube IDs - New fetchScreenshots() queries IGDB screenshots endpoint for images - autoScrape and applyIgdbId both fetch videos + screenshots after match - Upload auto-save also triggers when videos/screenshots found - GameDetail: embed first YouTube video + scrollable screenshot gallery - Handles missing media gracefully (section hidden entirely if neither exists)
This commit is contained in:
@@ -186,6 +186,38 @@
|
|||||||
<button class="btn btn-danger" onclick={handleDelete}>✕ Delete</button>
|
<button class="btn btn-danger" onclick={handleDelete}>✕ Delete</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Media Section — video + screenshots, handles missing media gracefully -->
|
||||||
|
{#if game.videos?.length || game.screenshots?.length}
|
||||||
|
<div class="media-section">
|
||||||
|
<span class="media-label">📺 Media</span>
|
||||||
|
<div class="media-grid">
|
||||||
|
{#if game.videos?.length}
|
||||||
|
<div class="video-container">
|
||||||
|
<iframe
|
||||||
|
src="https://www.youtube.com/embed/{game.videos[0]}"
|
||||||
|
title="Gameplay video"
|
||||||
|
allowfullscreen
|
||||||
|
frameborder="0"
|
||||||
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||||
|
></iframe>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if game.screenshots?.length}
|
||||||
|
<div class="screenshots-row">
|
||||||
|
{#each game.screenshots as shot}
|
||||||
|
<img
|
||||||
|
src={shot}
|
||||||
|
alt="Screenshot"
|
||||||
|
class="screenshot-thumb"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- IGDB Scrape Section -->
|
<!-- IGDB Scrape Section -->
|
||||||
<div class="scrape-section">
|
<div class="scrape-section">
|
||||||
<div class="scrape-header">
|
<div class="scrape-header">
|
||||||
@@ -280,6 +312,61 @@
|
|||||||
.edit-actions { display: flex; gap: 8px; margin-top: 12px; }
|
.edit-actions { display: flex; gap: 8px; margin-top: 12px; }
|
||||||
textarea { width: 100%; min-height: 100px; }
|
textarea { width: 100%; min-height: 100px; }
|
||||||
|
|
||||||
|
/* Media Section — video + screenshots */
|
||||||
|
.media-section {
|
||||||
|
margin-top: 24px;
|
||||||
|
padding-top: 20px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.media-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.media-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.video-container {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
overflow: hidden;
|
||||||
|
background: #000;
|
||||||
|
}
|
||||||
|
.video-container iframe {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.screenshots-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding-bottom: 4px;
|
||||||
|
}
|
||||||
|
.screenshot-thumb {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 240px;
|
||||||
|
height: 135px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.screenshot-thumb:hover {
|
||||||
|
border-color: var(--phosphor-dim);
|
||||||
|
}
|
||||||
|
|
||||||
/* IGDB Scrape */
|
/* IGDB Scrape */
|
||||||
.scrape-section {
|
.scrape-section {
|
||||||
margin-top: 24px;
|
margin-top: 24px;
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ public class Game {
|
|||||||
private boolean hasCover;
|
private boolean hasCover;
|
||||||
private boolean hasScreenshots;
|
private boolean hasScreenshots;
|
||||||
private List<String> screenshots;
|
private List<String> screenshots;
|
||||||
|
private List<String> videos;
|
||||||
|
|
||||||
private boolean ready;
|
private boolean ready;
|
||||||
private Instant createdAt;
|
private Instant createdAt;
|
||||||
@@ -95,6 +96,9 @@ public class Game {
|
|||||||
public List<String> getScreenshots() { return screenshots; }
|
public List<String> getScreenshots() { return screenshots; }
|
||||||
public void setScreenshots(List<String> screenshots) { this.screenshots = 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 boolean isReady() { return ready; }
|
||||||
public void setReady(boolean ready) { this.ready = ready; }
|
public void setReady(boolean ready) { this.ready = ready; }
|
||||||
|
|
||||||
|
|||||||
@@ -93,11 +93,12 @@ public class IgdbService {
|
|||||||
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 broadly; IGDB returns relevance-sorted results
|
// 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,"
|
||||||
+ " summary,cover.url,platforms;"
|
+ " summary,cover.url,platforms;"
|
||||||
|
+ " where platforms = [13];"
|
||||||
+ " limit 20;";
|
+ " limit 20;";
|
||||||
|
|
||||||
HttpRequest req = igdbRequest("/games")
|
HttpRequest req = igdbRequest("/games")
|
||||||
@@ -212,6 +213,57 @@ public class IgdbService {
|
|||||||
return IMG_BASE + "/" + COVER_SIZE + "/" + thumbUrl.substring(thumbUrl.lastIndexOf('/') + 1);
|
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();
|
||||||
|
|
||||||
|
List<String> videos = new ArrayList<>();
|
||||||
|
for (JsonNode v : results) {
|
||||||
|
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();
|
||||||
|
|
||||||
|
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");
|
||||||
|
screenshots.add(fullUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return screenshots;
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Auto-Scrape (called during upload) ──────────────────────
|
// ─── Auto-Scrape (called during upload) ──────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -242,6 +294,29 @@ public class IgdbService {
|
|||||||
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");
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
} 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());
|
||||||
}
|
}
|
||||||
@@ -272,6 +347,25 @@ public class IgdbService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
applyMatch(game, results.get(0));
|
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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -107,7 +107,9 @@ public class UploadResource {
|
|||||||
// Auto-populate from IGDB (non-blocking if credentials are set)
|
// Auto-populate from IGDB (non-blocking if credentials are set)
|
||||||
if (igdb.isConfigured()) {
|
if (igdb.isConfigured()) {
|
||||||
igdb.autoScrape(game);
|
igdb.autoScrape(game);
|
||||||
if (game.isHasCover() || game.getYear() != null || game.getGenre() != null) {
|
if (game.isHasCover() || game.getYear() != null || game.getGenre() != null
|
||||||
|
|| (game.getVideos() != null && !game.getVideos().isEmpty())
|
||||||
|
|| (game.getScreenshots() != null && !game.getScreenshots().isEmpty())) {
|
||||||
svc.save(game); // re-save with enriched metadata
|
svc.save(game); // re-save with enriched metadata
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user