Compare commits

..

134 Commits

Author SHA1 Message Date
david.alvarez dfb46ef032 Merge remote-tracking branch 'origin/main'
Build & Deploy / build-and-deploy (push) Successful in 50s
2026-06-10 09:21:02 +02:00
david.alvarez be15783034 more style 2026-06-10 09:20:47 +02:00
david.alvarez e452df00e4 more style
Build & Deploy / build-and-deploy (push) Successful in 1m37s
2026-06-10 09:19:11 +02:00
david.alvarez 7bd25136ab more style
Build & Deploy / build-and-deploy (push) Successful in 41s
2026-06-09 14:17:27 +02:00
David Alvarez 3a4e4f7e95 rename: IgdbResource → IgdbController
Build & Deploy / build-and-deploy (push) Successful in 37s
2026-06-09 14:13:57 +02:00
David Alvarez 30927c4159 rename: GameResource → GameController (more conventional name for REST handler)
Build & Deploy / build-and-deploy (push) Successful in 40s
2026-06-09 14:13:13 +02:00
David Alvarez 9afbac32f1 fix: correct findMain_subdirectoryDeprioritized test expectation
Build & Deploy / build-and-deploy (push) Successful in 50s
The sort order is: installer → platform → size → depth.
Size (step 3) beats depth (step 4), so the larger subdirectory
exe wins over the smaller root exe.
2026-06-09 14:06:21 +02:00
David Alvarez f69f3fa30a fix: wire PlatformDetector in ExecutableDetectorTest
Build & Deploy / build-and-deploy (push) Successful in 38s
ExecutableDetector uses @Inject for PlatformDetector, but tests
instantiate it directly via 'new' — CDI doesn't run. Manually set
the package-private 'platform' field in an instance initializer.
2026-06-09 14:04:55 +02:00
david.alvarez c3a332d600 style
Build & Deploy / build-and-deploy (push) Successful in 39s
2026-06-09 14:03:37 +02:00
David Alvarez 397c99bd0c fix: backslash path splitting bug in ConfigPatcher
Build & Deploy / build-and-deploy (push) Successful in 40s
Root cause: the separator check used '\\\\'.equals(sep) comparing a
2-char Java string literal to a 1-char substring result — always false.
This caused backslash paths (C:\FALLOUT1\MASTER.DAT) to never be split
into components, so stale prefixes were never detected or rewritten.

Fix: use char comparison (sep == '\\') which correctly identifies the
separator type. Affected both findReplacement and existsIgnoreCase.

This was a pre-existing bug from the original UploadResource code that
was carried over during the refactor. Re-uploading Fallout after this
fix will correctly patch FALLOUT.CFG paths.
2026-06-09 13:35:35 +02:00
David Alvarez a109dee5ba fix: add throws Exception to findMain_emptyDir_returnsNull test
Build & Deploy / build-and-deploy (push) Successful in 40s
2026-06-09 13:03:20 +02:00
David Alvarez f8adddec52 fix: add JUnit5 dependency, new tests, StaticResource cleanup
Build & Deploy / build-and-deploy (push) Failing after 30s
- Added quarkus-junit5 test dependency (was missing — caused assertNull failures)
- ExecutableDetectorTest: 12 tests (findAll, findMain prioritization, extender/sfx skip, setup detection)
- CdImageServiceTest: 7 tests (findInDirectory, fixCueReferences, findInBundle)
- ConfigPatcherTest: 8 tests (path rewriting, non-C drive skip, binary/large file skip, unix paths)
- StaticResource: removed unused content types (.html/.js/.css/.conf/.txt)
2026-06-09 13:01:40 +02:00
David Alvarez bb38066069 fix: add public modifier to gameDir/gameJsonPath for GameStore interface
Build & Deploy / build-and-deploy (push) Failing after 26s
2026-06-09 12:58:17 +02:00
David Alvarez e5d2fc2096 test: add unit tests for ConfigBuilder and PlatformDetector
Build & Deploy / build-and-deploy (push) Failing after 29s
2026-06-09 12:54:48 +02:00
David Alvarez c3b6ea8567 refactor: apply SOLID/KISS/YAGNI principles
Build & Deploy / build-and-deploy (push) Failing after 59s
Phase 1-2: Extract ConfigBuilder — single source of truth for all DOSBox config
  - Uses Java 21 text block templates (eliminates \n escaping bugs)
  - Deduplicated 6 config-building methods from GameService + UploadResource
  - CD mounting strategy: mount ALL images, dedup by parent dir, prefer data formats

Phase 1: Extract services from UploadResource monolith
  - PlatformDetector — PE/NE/MZ header analysis (was duplicated in 2 files)
  - CdImageService — CUE sheet repair + CD image discovery in dirs and ZIPs
  - ExecutableDetector — main exe / setup exe / self-extractor detection
  - ZipService — ZIP extraction, directory flattening, .jsdos bundle creation
  - ConfigPatcher — hardcoded C:\ path detection and rewriting

Phase 3 (YAGNI): Remove dead/misused code
  - Removed Game.platforms field (was storing genres, not platforms)
  - Fixed IgdbService that set platforms to genres list

Phase 4 (SOLID-D): Add GameStore interface
  - IgdbService now depends on GameStore, not concrete GameService
  - Enables testing with mocks

GameService.setExecutable now transactional:
  - Patches into temp file, detects platform from patched bundle
  - Atomic move on success, temp cleanup on failure

UploadResource: 1094 → 237 lines (-78%)
GameService: 412 → 290 lines (-30%)
Total: 2846 lines across 14 focused files (was 1723 lines in 3 monolithic files)
2026-06-09 12:54:07 +02:00
David Alvarez ab7a5ed8de fix: add memsize=64 to generated dosbox.conf and fix pre-existing backslash bug
Build & Deploy / build-and-deploy (push) Successful in 42s
Fallout needs 64MB of XMS memory to load its ~500MB of data files.
DOSBox defaults to 16MB, causing a crash during loading after the
loading screen appears.

Also fixes a pre-existing bug in GameService.buildDosboxConfBytes()
and UploadResource.buildCdOnlyDosboxConf() where \n was used instead
of actual newline escapes, producing config files with literal 'backslash-n'
characters instead of real line breaks.

Cleans up all leftover code from previous fix attempts (fix-paths endpoint,
hardcoded CONFIG_PATH_FIXES map, etc.).
2026-06-08 13:28:41 +02:00
David Alvarez 554347ac0e feat: include mount/imgmount in jsdos.json autoexec script + remove fix-paths endpoint
Build & Deploy / build-and-deploy (push) Successful in 36s
js-dos v8 uses the jsdos.json autoexec.script instead of the
dosbox.conf [autoexec] section. Without mount/imgmount in the jsdos.json
script, the CD image was never mounted — games loaded base data from C:
(bundle root) but crashed when trying to access CD-ROM content (videos,
audio).

Also removes the now-unnecessary fix-paths endpoint (both endpoint and
GameService implementation) since config path fixing is automatic during
upload via the game-agnostic scanner.
2026-06-08 12:49:33 +02:00
David Alvarez 9a5ed5bc03 fix: use dirStream.toList() instead of method reference cast in existsIgnoreCase
Build & Deploy / build-and-deploy (push) Successful in 40s
Iterable<Path> cast on dirStream::iterator method reference fails
in some Java environments. Stream.toList() (Java 16+) is cleaner
and guaranteed to compile.
2026-06-08 12:39:08 +02:00
David Alvarez 149d6d6c1a fix: wrap isSmallTextFile in try-catch to handle checked IOException in stream filter
Build & Deploy / build-and-deploy (push) Successful in 42s
2026-06-08 11:45:38 +02:00
David Alvarez efe0017756 feat: make config path patcher fully game-agnostic
Build & Deploy / build-and-deploy (push) Failing after 46s
Removes hardcoded CONFIG_PATH_FIXES map with Fallout-specific entries.
Replaces with generic scanner that:
- Scans all small text files (<100KB) for X:\... patterns
- Only processes C: drive paths (D:/E:/ left alone as CD-ROM refs)
- Uses case-insensitive filesystem checks to find what exists after flattening
- Progressively tries shorter path suffixes to find the stale prefix
- Rewrites C:\STALEDIR\ → .\ only when the leaf file/dir is found at root
- Works with both \ and / path separators

Also refactors the GameService fixBundleConfigPaths to build a ZIP entry
index and use the same logic for existing bundle patching.
2026-06-08 11:40:40 +02:00
David Alvarez 82b379d211 ci: use docker run with correct volume path instead of compose file
Build & Deploy / build-and-deploy (push) Successful in 11s
The runner container can't access the compose file path on the host.
Use the compose file's parameters (network: dockernet, volume:
/mnt/cache/appdata/dostalgia) directly in docker run instead.
2026-06-08 11:10:46 +02:00
David Alvarez 9801e37bec ci: fix COMPOSE_DIR to runner-internal path /unraid/dostalgia
Build & Deploy / build-and-deploy (push) Failing after 17s
The Gitea Actions runner is a container; it can't see host paths.
The dostalgia compose dir is mounted inside the runner at /unraid/dostalgia,
so that's what the workflow must reference.
2026-06-08 11:09:01 +02:00
droideparanoico f2fb1c035d Actualizar .gitea/workflows/build.yaml
Build & Deploy / build-and-deploy (push) Failing after 6s
2026-06-08 10:58:49 +02:00
droideparanoico fc6f44b2ce Actualizar .gitea/workflows/build.yaml
Build & Deploy / build-and-deploy (push) Failing after 21s
2026-06-08 10:55:17 +02:00
droideparanoico ee05a7f031 Actualizar .gitea/workflows/build.yaml
Build & Deploy / build-and-deploy (push) Failing after 6s
2026-06-08 10:53:04 +02:00
David Alvarez 106cb5bf83 ci: deploy via docker-compose instead of raw docker run
Build & Deploy / build-and-deploy (push) Failing after 39s
Uses docker compose -f /appdata/dockhand/stacks/unraid/dostalgia/compose.yaml
so the container matches the user's Unraid stack configuration (correct volume
mount at /mnt/cache/appdata/dostalgia, proper network, labels, etc.)
2026-06-08 10:49:44 +02:00
David Alvarez 145f11eaa0 feat: fix hardcoded DOS paths in configs during upload + fix-paths endpoint
Build & Deploy / build-and-deploy (push) Successful in 43s
- Add both uppercase C: and lowercase c: variants to CONFIG_PATH_FIXES
- Add fixBundleConfigPaths() to GameService to patch existing bundles
- Add POST /api/games/{id}/fix-paths endpoint for existing games
2026-06-08 10:37:04 +02:00
David Alvarez 22a9a55af8 feat: patch hardcoded absolute DOS paths in game configs during upload
Build & Deploy / build-and-deploy (push) Successful in 1m41s
Adds fixAbsoluteConfigPaths() which runs after flattening and cue fixing
during game upload. Detects known config files (FALLOUT.CFG, etc.) with
hardcoded C:\dir\ paths and rewrites them to .\ relative paths, so games
like Fallout can find their DAT files after flattening moves everything
to the bundle root.

Uses byte-level replacement to preserve original CRLF line endings and
encoding. Extensible via CONFIG_PATH_FIXES map — add new entries for
other games with the same problem.
2026-06-08 10:16:33 +02:00
David Alvarez 9fceeb4b78 fix: remove unrecognized G1HeapUncommitPercent flag, keep Min/MaxHeapFreeRatio
Build & Deploy / build-and-deploy (push) Successful in 14s
G1HeapUncommitPercent is not available in this JRE build. The
MinHeapFreeRatio=10 and MaxHeapFreeRatio=20 flags are sufficient
to keep JVM committed heap from growing unbounded after uploads.
2026-06-04 11:27:02 +02:00
David Alvarez e9c4d963ad feat: add G1GC heap uncommit flags so container memory shrinks after uploads
Build & Deploy / build-and-deploy (push) Failing after 49s
-XX:MinHeapFreeRatio=10 -XX:MaxHeapFreeRatio=20 -XX:G1HeapUncommitPercent=5
After large game uploads, the JVM committed heap stays high. These flags tell
G1GC to release free heap pages back to the OS after GC cycles, so Docker
memory usage drops back toward baseline instead of staying at peak.
2026-06-04 11:25:02 +02:00
david.alvarez d119b3ccb9 Revert "fix: limit JVM heap to 128MB with G1GC periodic GC container"
Build & Deploy / build-and-deploy (push) Successful in 12s
This reverts commit d652f3f94b.
2026-06-03 21:29:36 +02:00
Hermes Agent d652f3f94b fix: limit JVM heap to 128MB with G1GC periodic GC container
Build & Deploy / build-and-deploy (push) Successful in 14s
Revert to commit 8108a66 (cue file fix + CD mounting fixes) and
apply only the Dockerfile JVM memory limit. All other changes
(StaticResource Range support, FileChannel transferTo) reverted.
2026-06-03 16:15:20 +02:00
Hermes Agent 8108a661fa fix: rewrite .cue FILE references that point to non-existent data files
Build & Deploy / build-and-deploy (push) Successful in 12s
CloneCD rips generate a .cue that references DOTT.bin but the actual
data file is DOTT.img. DOSBox fails to mount the CD when the referenced
file doesn't exist, causing 'Illegal command' for executables on the CD.

Now before bundle creation, we scan all .cue files and fix any FILE line
that points to a missing data file by rewriting it to reference .img
or .bin files actually present in the same directory.
2026-06-03 12:46:33 +02:00
Hermes Agent 57842abe0d fix: smarter CD image selection per directory with format priority
Build & Deploy / build-and-deploy (push) Successful in 43s
Replace the broken descriptor-preference logic with a clean two-pass
approach: group images by parent directory, then pick the best format
per directory with priority .cue > .iso > .ccd > .img > .bin.

js-dos DOSBox's imgmount does NOT support .ccd (CloneCD descriptor)
natively, but .cue is widely supported. When both exist, .cue wins.
When only .ccd exists, it falls back to mounting the .img directly.
2026-06-03 12:40:11 +02:00
Hermes Agent 8a45ceccdc fix: prefer descriptor formats (.cue/.ccd) over raw data (.img/.bin) for DOSBox CD mounting
Build & Deploy / build-and-deploy (push) Successful in 45s
DOSBox's imgmount works best with descriptors because they contain track layout
info (audio tracks, subchannel data, copy protection). Raw .img/.bin mounts
lose this info, making the filesystem inaccessible and causing 'Illegal command'
when trying to run executables from the CD.
2026-06-03 12:36:14 +02:00
Hermes Agent 3162b9ff38 fix: remove duplicate imgLower declaration in buildCdOnlyDosboxConf
Build & Deploy / build-and-deploy (push) Successful in 40s
2026-06-03 12:29:21 +02:00
Hermes Agent 6f68e6c062 fix: seenDirs.add() after descriptor check, not before
Build & Deploy / build-and-deploy (push) Failing after 39s
The bug: seenDirs.add(parentDir) was called BEFORE the descriptor-skip
check. When .ccd was processed first, it marked cd/ as seen even
though it was skipped. Then .img was skipped because cd/ was already
'seen', leaving zero imgmount lines.

Fix: use seenDirs.contains(parentDir) for the continue check, and
call seenDirs.add(parentDir) only after passing all checks, right
before the actual imgmount output.
2026-06-03 12:27:15 +02:00
Hermes Agent 1fc0a11512 debug: show cdImages count in CD-only config
Build & Deploy / build-and-deploy (push) Successful in 50s
2026-06-03 12:20:19 +02:00
Hermes Agent 632fd7f554 fix: detect CD images once, pass into createBundle
Build & Deploy / build-and-deploy (push) Successful in 44s
CD-only games were failing because the second findCdImages() call
inside createBundle returned empty. Now detection happens once in
the upload method and the result is passed to createBundle.
2026-06-03 12:15:10 +02:00
Hermes Agent 85b0aa8c0c fix: prefer .img/.iso/.bin over .ccd/.cue descriptors
Build & Deploy / build-and-deploy (push) Successful in 37s
CloneCD (.ccd) and cue sheets (.cue) contain internal FILE references
that may not match the actual filename on disk (case mismatch).
When a data format (.img, .iso, .bin) exists in the same directory,
it's mounted directly instead of the descriptor.
2026-06-03 12:03:52 +02:00
Hermes Agent 4cdd72b3e2 fix: use Files.write() for byte[] return from buildCdOnlyJsdosJson
Build & Deploy / build-and-deploy (push) Successful in 36s
Files.writeString expects CharSequence, but buildCdOnlyJsdosJson
returns byte[]. Changed to Files.write() which accepts byte[].
2026-06-03 11:58:45 +02:00
Hermes Agent 6f59028cb0 feat: support CD-only games (no executable on filesystem)
Build & Deploy / build-and-deploy (push) Failing after 31s
When no .exe/.com/.bat files are found but CD images are present,
the upload no longer fails. Instead it creates a CD-only game where
the autoexec mounts the CD-ROM image(s) and presents a DOS prompt
with instructions for the user.

- findMainExe returning null + CD images exist = CD-only game
- createBundle handles null exePath (generates CD-only config)
- New buildCdOnlyDosboxConf / buildCdOnlyJsdosJson methods
- Upload metadata handles null executable gracefully (empty string)
- Platform defaults to 'dos' when no executable to inspect
2026-06-03 11:46:14 +02:00
Hermes Agent 383c66b166 restore dedup: mount one CD image per directory, prefer .bin over .cue
Build & Deploy / build-and-deploy (push) Successful in 37s
Since GameService now preserves CD images when regenerating configs,
the dedup logic is safe to re-add. Deduplicates by parent directory
so a .bin and .cue from the same folder only produce one imgmount.
Alphabetical sort ensures .bin (b) is mounted before .cue (c).
2026-06-03 10:22:27 +02:00
Hermes Agent 7c4d090de0 fix: preserve CD image mounts when changing executable
Build & Deploy / build-and-deploy (push) Successful in 36s
GameService.buildDosboxConfBytes() was rebuilding dosbox.conf with
only the executable path — CD image mount lines were lost.

- Added findCdImagesInBundle() to scan a .jsdos ZIP for CD images
- buildDosboxConfBytes() now accepts List<String> cdImages and
  generates imgmount lines (same logic as UploadResource)
- Both setExecutable() and streamSetupBundle() now scan for
  and include CD images when regenerating config
2026-06-03 10:09:38 +02:00
Hermes Agent 3b7c9b587f simplify: mount all CD images, no dedup
Build & Deploy / build-and-deploy (push) Successful in 38s
Removed the directory-level dedup and .bin-over-.cue preference
logic — it was causing imgmount lines to disappear entirely.

Now mounts every detected CD image (.iso/.cue/.img/.ccd/.bin)
with appropriate flags (.bin gets -fs iso for direct fs access).
2026-06-03 09:59:32 +02:00
Hermes Agent de9e978e55 fix: mount .bin directly instead of .cue to avoid case mismatch
Build & Deploy / build-and-deploy (push) Successful in 39s
.cue files contain an internal FILE reference (e.g. FILE "z.BIN")
that may not match the actual filename on disk (e.g. z.bin) due to
case differences. In js-dos's case-sensitive WASM filesystem, this
causes the imgmount to fail silently.

Changes:
- Added .bin to CD_EXT so findCdImages detects bin files
- When both .cue and .bin exist in the same directory, only the
  .bin is mounted (with -fs iso flag for direct filesystem access)
- .cue files without a matching .bin are still mounted as before
- Deduplication prevents mounting both .cue and .bin from same dir
2026-06-03 09:45:04 +02:00
Hermes Agent 4a3ba3b986 perf: switch build stages to Alpine for faster CI pulls
Build & Deploy / build-and-deploy (push) Successful in 1m18s
- node:20-bookworm → node:20-alpine (350MB → 130MB)
- maven:3-eclipse-temurin-21 → maven:3-eclipse-temurin-21-alpine (630MB → 200MB)

Runtime stage (eclipse-temurin:21-jre-alpine) unchanged — final
image size stays the same, but CI caches and pulls are faster.
2026-06-03 09:22:42 +02:00
Hermes Agent 7b7cc56bc1 feat: keep and auto-mount CD images via imgmount instead of stripping
Build & Deploy / build-and-deploy (push) Successful in 1m17s
- Removed .iso, .cue, .img, .ccd from SKIP_EXT (they're now kept in bundles)
- Added CD_EXT set and findCdImages() method to detect mountable images
- buildDosboxConf() now generates imgmount D:/E:/… commands for each CD image
- Only .nrg, .mdf, .mds, .sub, .dmg are still stripped (unsupported formats)

Games with CD images will now work out of the box — DOSBox presents
them as CD-ROM drives and the game can find its disc.
2026-06-02 16:11:24 +02:00
Hermes Agent 1362e6034a Revert "feat: add Sierra game bundle patcher and API endpoint"
Build & Deploy / build-and-deploy (push) Successful in 20s
This reverts commit 5abc066178.
2026-06-01 13:35:19 +02:00
Hermes Agent 5abc066178 feat: add Sierra game bundle patcher and API endpoint
Build & Deploy / build-and-deploy (push) Successful in 41s
Handles common Sierra game file layout issues:
- RESOURCE.CFG with directory paths pointing to \KQ6CD when
  resources are actually at the bundle root (fixes paths to '.')
- INTERP.ERR/INTERP.ERRC files buried in subdirectories instead
  of at root (copies them up so SIERRA.EXE can find them)

POST /api/games/{id}/patch-sierra triggers the fix on an existing
game bundle.
2026-06-01 13:08:16 +02:00
Hermes Agent f297d30248 fix: resolveSibling using filename only, not full bundle path
Build & Deploy / build-and-deploy (push) Successful in 46s
bundleFile changed from 'wolf3d.jsdos' to 'wolf3d/wolf3d.jsdos' when
bundles moved inside game directories. resolveSibling(bundleFile + '.tmp')
produced a wrong nested path. Now uses bundlePath.getFileName() to get
just the filename part before appending .tmp.
2026-06-01 12:48:04 +02:00
Hermes Agent 4044ef3786 feat: pre-populate upload title with filename when no IGDB matches
Build & Deploy / build-and-deploy (push) Successful in 2m17s
When a game ZIP is selected and IGDB returns no matches, the search
box is now pre-filled with the filename (minus .zip extension) so
the user can edit it rather than typing from scratch.
2026-06-01 12:41:24 +02:00
Hermes Agent 677740aece fix: prevent layout shift by using 100vw body instead of scrollbar-gutter
Build & Deploy / build-and-deploy (push) Successful in 1m35s
Setting body { width: 100vw; overflow-x: hidden; } means the body is
always the full viewport width regardless of scrollbar presence.
margin: 0 auto centers content within the body, which doesn't
change width when a vertical scrollbar appears — so the content
stays in place.

html { overflow-x: hidden; } clips any body overflow past the
viewport (body is 100vw while html content area shrinks with
scrollbar). No gutter discoloration since there's no gutter
reservation — the scrollbar appears naturally on the html element.
2026-05-29 23:32:04 +02:00
Hermes Agent 9bb341eab9 fix: use scrollbar-gutter to prevent layout shift without always showing scrollbar
Build & Deploy / build-and-deploy (push) Successful in 45s
Replaces overflow-y: scroll with scrollbar-gutter: stable on html.
This reserves the scrollbar gutter space in the layout at all times,
so the content width never changes when a scrollbar appears/disappears.
The scrollbar itself only shows when content overflows (as desired).
Works on all modern browsers (Chrome 94+, Firefox 97+, Safari 15.4+).
2026-05-29 23:27:59 +02:00
Hermes Agent e2dca1db39 fix: force scrollbar visibility to prevent layout shift on tall pages
Build & Deploy / build-and-deploy (push) Successful in 56s
Add overflow-y: scroll to body so the scrollbar is always present,
preventing the ~15px layout shift when a page exceeds viewport height
(e.g., games with long descriptions). Also add overflow-x: hidden to
guard against any horizontal overflow breaking the layout.
2026-05-29 23:23:25 +02:00
Hermes Agent ab1ba503ff feat: store .jsdos bundle inside game directory instead of as flat file
Build & Deploy / build-and-deploy (push) Successful in 52s
Before: data/games/{id}.jsdos (flat, alongside the game dir)
After:  data/games/{id}/{id}.jsdos (inside the game directory)

Benefits:
- Delete is atomic — removing the game dir removes everything
- Cleaner data directory structure
- No orphan .jsdos files on delete

Also updated frontend bundleUrl() to use game.bundle_file from
the backend, making it path-agnostic. Old flat layout is still
served via StaticResource for backward compat.
2026-05-29 23:07:07 +02:00
Hermes Agent 4c3ded802b fix: valid Java char literal for backslash in UploadResource.java
Build & Deploy / build-and-deploy (push) Successful in 45s
replace('\', '/') is Java source for a char literal representing
a single backslash character. The previous commit had a single
backslash byte between quotes which made Java interpret it as
\\' (escaped single-quote) causing unclosed char literal errors.
2026-05-29 23:01:35 +02:00
Hermes Agent 9b95e5b994 feat: stream setup .jsdos bundles on-the-fly instead of duplicating game data
Build & Deploy / build-and-deploy (push) Failing after 1m20s
- Add streamSetupBundle() to GameService — reads main bundle as zip,
  replaces config files (.jsdos/dosbox.conf, .jsdos/jsdos.json) with
  setup variants pointing to SETUP.EXE, streams directly to response
- Add GET /api/games/{id}/setup-bundle endpoint that calls the above
- Remove .setup.jsdos disk creation during upload — setupExe is still
  detected and stored in game metadata, but no duplicate bundle is saved
- Update frontend api.js to point setupBundleUrl() to the new endpoint
- Fix pre-existing backslash escaping bug in UploadResource.java path
  normalization (replace('\', '/') instead of replace('\\', '/'))
2026-05-29 18:08:04 +02:00
Hermes Agent 5f69ea5ead fix: remove injected js-dos CSS/script on Play unmount to prevent style leaking into other pages
Build & Deploy / build-and-deploy (push) Successful in 48s
2026-05-29 15:15:02 +02:00
Hermes Agent e39ee05a16 fix: add missing closing brace to load() method
Build & Deploy / build-and-deploy (push) Successful in 38s
2026-05-29 14:56:12 +02:00
Hermes Agent 2ab6b19598 Fix upload dialog re-trigger + add game size to detail page
Build & Deploy / build-and-deploy (push) Failing after 34s
- Fixed: navigating back to library no longer re-opens file dialog
  (prev-value guard in uploadTriggered effect)
- Added bundleSize field to Game, populated at load time from .jsdos file
- Game detail page shows file size next to DOS badge (e.g. 'DOS · 127 MB')
2026-05-29 14:54:13 +02:00
Hermes Agent 02d0c4f377 Add executable picker in game edit UI
Build & Deploy / build-and-deploy (push) Successful in 49s
- Added 'executables' (full list) and 'executable' (selected) fields to Game
- Upload now stores ALL discoverable .exe/.com/.bat files in game metadata
- Added setExecutable() to GameService that patches the .jsdos bundle
  (updates dosbox.conf + jsdos.json inside the ZIP) when changing executable
- GameResource PATCH handler delegates executable changes to bundle patching
- GameDetail edit mode shows a radio-button picker of all executables
- Heuristics remain as first-guess default, user can always override
- No re-upload needed to fix wrong executable selection
2026-05-29 10:31:25 +02:00
Hermes Agent e262a46487 Add path=c:\ to autoexec for DOS extender findability
Build & Deploy / build-and-deploy (push) Successful in 47s
When the main executable is in a subdirectory but DOS4GW.EXE (or other
DOS extender) is at the ZIP root, DOS/4GW fails with 'No such file or
directory' because it only searches the current directory and PATH.
Adding 'path=c:\' before the 'cd' command ensures root-level DOS
extenders are always findable regardless of where the game EXE lives.
2026-05-29 10:15:23 +02:00
Hermes Agent 815247f760 Fix setupExe metadata + improve PKSFX detection
Build & Deploy / build-and-deploy (push) Successful in 50s
- Added 'sfx' and 'makesfx' to SKIP_EXE_NAMES
- Increased self-extractor detection buffer to 32KB
- Fixed false-positive risk: PK signatures only checked after offset 0x40
  using proper signature comparison (not sequential byte matching)
- Fixed setupExe metadata: only set when setup bundle was actually created,
  preventing phantom Setup button when setup exe is Windows
2026-05-29 10:07:52 +02:00
Hermes Agent 54542612a5 Skip self-extracting archive executables (PKSFX etc.)
Build & Deploy / build-and-deploy (push) Successful in 53s
- Added archiver/compression tool names to SKIP_EXE_NAMES (pksfx,
  pkzip, pkunzip, unzip, arj, rar, lha, etc.)
- Added isLikelySelfExtractor() which reads the EXE body looking for
  PKWARE signatures (PKZIP/PKSFX strings and PK\x03\x04 local file
  headers) — catches renamed self-extractors regardless of filename
- Both filters applied during findMainExe candidate collection
2026-05-29 09:57:34 +02:00
Hermes Agent 473fb7a216 Check setup EXE's own platform before creating setup bundle
Build & Deploy / build-and-deploy (push) Successful in 40s
Previously only checked the main game's platform. Fallout's SETUP.EXE
is a Windows executable even though FALLOUT.EXE is DOS — creating a
.setup.jsdos bundle for it would show 'This program cannot run in DOS mode'.
Now detects the setup EXE's own header and skips if it targets Windows.
2026-05-29 09:41:45 +02:00
Hermes Agent 45d145a950 Prefer larger files over shallow depth in findMainExe
Build & Deploy / build-and-deploy (push) Successful in 45s
Size is a better indicator of 'this is the actual game' than depth.
A 1MB+ FALLOUT.EXE in a subdirectory should beat a 50KB BOOTME.EXE
at the root. Sort order: non-installer → DOS → size → depth.
2026-05-29 09:40:06 +02:00
Hermes Agent dee37b3f01 Prefer DOS executables over Windows in findMainExe
Build & Deploy / build-and-deploy (push) Successful in 46s
When a ZIP contains both a DOS version (FALLOUT.EXE) and a Windows
version (FALLOUTW.EXE), the sort now prefers DOS executables.
This reads the EXE header during candidate collection and ranks:
  non-installer → DOS platform → depth → size

Fixes Fallout being mis-detected as a Windows game.
2026-05-29 09:34:57 +02:00
Hermes Agent fa1804dc65 Fix Java type inference in comparator chain
Build & Deploy / build-and-deploy (push) Successful in 41s
2026-05-29 09:30:04 +02:00
Hermes Agent 12db0e9bb9 Fix: prioritize non-installer executables over depth in findMainExe
Build & Deploy / build-and-deploy (push) Failing after 33s
The sort was: depth → non-installer → size.
This meant a root-level SETUP.EXE (depth 1, installer) beat a
subdirectory FALLOUT.EXE (depth 2, non-installer).

New sort: non-installer → depth → size.
Now any non-installer game EXE wins over installers regardless of depth.
2026-05-29 09:28:05 +02:00
Hermes Agent 27a9c180d7 Fix: skip DOS extender executables (DOS4GW etc.) when finding main game exe
Build & Deploy / build-and-deploy (push) Successful in 1m35s
DOS4GW.EXE is a 32-bit DOS extender that ships with many DOS games
(DOOM, Fallout, Duke Nukem 3D, etc.). The findMainExe algorithm was
picking it as the main executable because it's typically large and at
the ZIP root, causing the autoexec to run 'DOS4GW' with no arguments
→ 'DOS/4GW fatal error (1004): syntax is DOS/4GW <executable.xxx>'

Added SKIP_EXE_NAMES set with known DOS extenders, DPMI hosts,
debuggers, memory managers, and uninstallers.
2026-05-29 09:15:23 +02:00
Hermes Agent 3f69820911 Move Reset button to bottom of sidebar
Build & Deploy / build-and-deploy (push) Successful in 49s
2026-05-28 23:58:10 +02:00
Hermes Agent 13293b325e Restructure layout: search in sidebar, upload in header
Build & Deploy / build-and-deploy (push) Successful in 58s
- Removed toolbar (Library title, search, upload button) from library
- Moved search box into the sidebar alongside Year/Genre filters
- Removed 'Filters' heading from sidebar
- Replaced 'Library' nav button in header with '+ Upload' button
- Genre filters now sorted by count (desc) instead of alphabetically
- Cleaned up unused CSS
2026-05-28 23:16:22 +02:00
droideparanoico b8ccba4d20 Actualizar README.md
Build & Deploy / build-and-deploy (push) Successful in 12s
2026-05-28 23:14:35 +02:00
Hermes Agent 676e101ffc Library filters sidebar, clickable tags, remove Ready badge
Build & Deploy / build-and-deploy (push) Successful in 59s
- GameCard: removed 'Ready' badge from covers (keep '🪟 Win' for
  Windows games only — it's informative since they can't be played)
- Library: sidebar with Year + Genre checkbox filter groups
  - Each shows up to 5 items with game counts, 'Show all' button
  - Active filters shown as removable chips above the grid
  - Reset button clears all filters
  - Filters reflected in URL hash (#/?genre=FPS&year=1996)
- GameDetail: year and genre tags are now clickable links to
  #/?year=1996 or #/?genre=FPS, which opens the Library with that
  filter pre-applied
- App.svelte: parses query params from hash and passes to Library
2026-05-28 22:56:51 +02:00
Hermes Agent e15ce10f5a Upload progress spinner + duplicate game detection
Build & Deploy / build-and-deploy (push) Successful in 1m20s
Two features:

1. Progress indicator: when upload starts, the dialog switches from
   IGDB results to a centered spinner with 'Uploading and processing'
   text + filename. The Cancel button stays visible but disabled.

2. Duplicate game blocking: before upload, checks if a game with the
   same title (case-insensitive) already exists in the library.
   Frontend checks against the loaded games list; backend also
   checks as a second line of defense. Shows ⚠️ message in dialog.
   Existing unique-ID suffix logic (foo-2, foo-3) is still in place
   but now only reached when titles actually differ.
2026-05-28 22:28:27 +02:00
Hermes Agent a9e52ade68 Resilient flatten: catch exceptions + return meaningful error messages
Build & Deploy / build-and-deploy (push) Successful in 47s
- Wrap flattenSingleDir in try-catch — if it fails, the upload continues
  and the cd <subdir> fix in dosbox.conf handles subdirectory executables
- Add global catch to upload method returning the actual exception
  message instead of a generic 500
- This will help diagnose why the user's King's Quest VI zip fails:
  flattening can throw on special filenames, Mac resource forks,
  symlinks, or other unusual ZIP contents
2026-05-28 16:13:11 +02:00
Hermes Agent ff7b7b446f Fix IGDB result selection: use DOM data attributes + pass igdb_id to backend
Build & Deploy / build-and-deploy (push) Successful in 58s
Two issues fixed:

1. Frontend closure bug: IGDB result buttons used Svelte {#each} closure
   with onclick={() => doUpload(result.name)}. When clicking the 3rd
   result, the 1st result's handler sometimes fired. Fixed by reading
   data-name from e.currentTarget.dataset instead of JS closure.

2. Backend scrape mismatch: when user selected 'Blood' from IGDB
   results, backend's autoScrape searched for 'Blood' and could match
   'Captain Blood' first (alphabetical), overwriting metadata.
   Now passes igdb_id along with title so backend uses the EXACT
   IGDB entry the user selected via applyIgdbId() instead of
   auto-searching.
2026-05-28 16:00:51 +02:00
Hermes Agent a4b633ae86 Fix crash in detectPlatform: handle negative e_lfanew offset
Build & Deploy / build-and-deploy (push) Successful in 53s
For pure DOS executables, the e_lfanew field at MZ header offset 0x3C
is uninitialized garbage. Duke Nukem 2's EXE had bytes that form a
negative int32 (-1878619136), which was used as an array index causing
ArrayIndexOutOfBoundsException.

Now checks peOffset < 0 and returns 'dos' (no Windows signature found).
2026-05-28 15:53:51 +02:00
Hermes Agent 41d1e22522 Fix platform detection: LE (DOS/4GW extender) is DOS, not Windows
Build & Deploy / build-and-deploy (push) Successful in 48s
Many classic DOS games (Blood, DOOM, Duke Nukem 3D, Quake) use
DOS/4GW or DOS/32A — 32-bit DOS extenders with Linear Executable
(LE) format. My code was detecting LE as 'windows', causing these
games to be incorrectly flagged as unplayable.

Only PE (Portable Executable / Win32) and NE (New Executable /
Windows 3.x) are actually Windows.
2026-05-28 15:46:36 +02:00
Hermes Agent 95c7ba7192 Fix upload dialog: search box button always searches
Build & Deploy / build-and-deploy (push) Successful in 44s
Previously when no IGDB matches were found, the button next to the
search box changed from 'Search' to 'Upload' after the first search.
Since the text is editable and users may want to refine their query,
this button should always trigger a search.

Upload is still available via:
- The 'Upload anyway as "..."' button (shown after failed search)
- The 'Use filename: ...' button at the bottom
2026-05-28 15:03:13 +02:00
Hermes Agent 5b55a53b73 Skip creating .setup.jsdos bundles for Windows games
Build & Deploy / build-and-deploy (push) Successful in 47s
- Move platform detection before setup bundle creation
- Only create setup bundle when platform != 'windows'
- Also hide the Setup button in GameDetail for Windows games
- Windows setup executables (INSTALL.EXE) can't run in DOSBox anyway
2026-05-28 14:57:13 +02:00
Hermes Agent 733bdaa44f Fix js-dos ZIP extraction: write dirs shallowest-first, then files
Build & Deploy / build-and-deploy (push) Successful in 1m40s
js-dos v8's C extraction code (jsdos-libzip.c) iterates ZIP entries
linearly and calls mkdir() on each directory entry. If a child
directory appears before its parent, mkdir() fails with perror()
and exit(1) — fatal crash with 'No such file or directory'.

Previous approach wrote directory entries interleaved with files,
deepest-first (from the file path right-to-left). Now:

Phase 1: Walk entire file tree to discover ALL directories
Phase 2: Write ALL dir entries sorted (shallowest first via TreeSet
         natural ordering — '/' sorts before any letter)
Phase 3: Write .jsdos/ config files
Phase 4: Write game files (parents already exist from Phase 2)

This guarantees that mkdir("NAS50TH/TRACKS/") runs before
mkdir("NAS50TH/TRACKS/WILKES/"), which runs before open().
2026-05-28 14:43:28 +02:00
Hermes Agent f9967eacf8 Auto-exclude disk image files (.img .iso .ccd .sub etc.) from .jsdos bundles
Build & Deploy / build-and-deploy (push) Successful in 48s
Many DOS game ZIPs include CloneCD backups (.ccd/.img/.sub) or
other disk images that are not needed for gameplay — the game data
is already extracted in the working directory. These can be 300MB+
and cause WASM memory exhaustion in js-dos.

Added a SKIP_EXT set that filters out known disk image extensions
during game file pass. The .jsdos config pass is unaffected.
2026-05-28 13:54:56 +02:00
Hermes Agent eb37cd43bb Fix ZIP entry order: write .jsdos/ config first, then game files sorted
Build & Deploy / build-and-deploy (push) Successful in 40s
js-dos reads ZIP entries sequentially and needs .jsdos/dosbox.conf and
.jsdos/jsdos.json first to configure DOSBox. Java's Files.walk() uses
filesystem readdir order which put them at the very end of the ZIP
(after the 328MB CloneCD image), causing extraction failures.

Now:
1. First pass: walk .jsdos/ directory and write config files first
2. Second pass: walk everything else (excluding .jsdos/) in sorted order
3. All directory ancestors are still added as explicit entries
4. .sorted() ensures deterministic, alphabetical entry order
2026-05-28 13:47:52 +02:00
Hermes Agent 8f14ecb5a1 Fix nested directory entries: add full ancestor chain to ZIP
Build & Deploy / build-and-deploy (push) Successful in 45s
Previously only the immediate parent directory was added as a ZIP
entry. For deeply nested paths like NAS50TH/TRACKS/WILKES/WILKES.DAT,
only NAS50TH/TRACKS/WILKES/ was created — but NAS50TH/TRACKS/ and
NAS50TH/ were skipped (they're only parents of other directories,
never the immediate parent of a file).

Emscripten's FS.createDirectory() fails if parent doesn't exist,
so intermediate directories must be in the ZIP too.

Now walks up the full ancestor chain with a while loop.
2026-05-28 13:37:21 +02:00
Hermes Agent 5a3500c586 Fix js-dos ZIP extraction: add explicit directory entries to bundles
Build & Deploy / build-and-deploy (push) Successful in 46s
js-dos uses Emscripten's virtual filesystem which requires explicit
ZIP directory entries (names ending with '/') to create directories.
Java's ZipOutputStream only writes file entries, so subdirectories
like NAS50TH/TRACKS/WILKES/ were never created — causing Emscripten
'No such file or directory' errors for any path inside them.

- createBundle: before writing each file entry, add its parent
  directory as an explicit ZIP entry if not already added
- Affects all games with subdirectory structures (NASCAR Racing 2,
  Gus Goes to Cyberopolis, etc.)
- Use a HashSet to track already-added directories (no duplicates)
2026-05-28 13:28:10 +02:00
Hermes Agent 65e098244a Add platform auto-detection and UI flagging for Windows games
Build & Deploy / build-and-deploy (push) Successful in 49s
- Game.java: add 'platform' field ('dos'/'windows') + 'isPlayable' helper
- UploadResource.java: detect EXE type by reading PE/NE header during upload
- GameResource.java: allow PATCH to update platform
- GameCard.svelte: show 🪟 Win badge for Windows games (blue style)
- GameDetail.svelte: show platform badge, disable Play for Windows with
  'Unplayable' button; add platform dropdown to edit form
- Play.svelte: show blocking overlay for Windows games with 'Try anyway'
  option for users who want to attempt it
2026-05-28 13:16:04 +02:00
Hermes Agent efa0aa5f81 Fix MoO2 subdirectory bug + remove sockdrive references
Build & Deploy / build-and-deploy (push) Successful in 36s
- Fix flattenSingleDir: flatten when exactly 1 root directory exists,
  even if other metadata files (file_id.diz, *.nfo) are present at root
- Fix buildDosboxConf/buildJsdosJson: add 'cd <dir>' before executable
  when it lives in a subdirectory, since DOS treats '/' as switch char
  (previously 'mastori2/ORION2.EXE' was parsed as command 'mastori2'
  with switch '/ORION2.EXE')
- Remove SockdriveResource.java entirely
- Remove bundleType field from Game.java (was 'standard'/'sockdrive')
- Simplify GameResource.java download endpoint
- Clean up upload resource
2026-05-28 12:10:43 +02:00
David Alvarez ba34065278 fix: remove zos.setLevel(0) from createBundle — default DEFLATE is more reliable with emscripten libzip extraction
Build & Deploy / build-and-deploy (push) Successful in 17s
2026-05-28 10:06:28 +02:00
David Alvarez e11f19c769 fix: remove sockdrive mode, always create .jsdos ZIP bundles
Build & Deploy / build-and-deploy (push) Successful in 40s
js-dos v8's window.Dos(container, { url }) expects the URL to point
to a .jsdos ZIP archive, not a loose dosbox.conf file. The
'sockdrive' mode was serving game files loosely with a URL pointing
to a raw config file, causing: 'Unable to add .jsdos/jsdos.json
into bundle.zip'.

Changes:
- Removed SOCKDRIVE_THRESHOLD_MB and the entire sockdrive branch
- Always create a .jsdos ZIP bundle via createBundle()
- Optimized createBundle: no temp dir copy (saves 2x I/O for large
  games), uses STORE compression (level 0) since DOS files are
  often already compressed
- Removed dirSizeMB(), copyDir() and setup config builders —
  no longer needed after removing sockdrive mode
- frontend bundleUrl()/setupBundleUrl() always use the standard
  /games/{id}.jsdos path
- GameService.delete cleanup simplified
2026-05-28 09:45:45 +02:00
David Alvarez 6840b3eee1 fix: raise upload limit from 200MB to 2048MB for large DOS games
Build & Deploy / build-and-deploy (push) Successful in 56s
The previous 200M limit on quarkus.http.limits.max-body-size and
multipart input-part.max-size rejected games over 200MB. DOS CD-ROM
games can easily exceed this (e.g. DOOM 95 full CD, Diablo). Raised
to 2048M to cover the largest DOS/CD-ROM game archives.
2026-05-28 09:32:33 +02:00
David Alvarez 5f5cb8411f feat: SETUP.EXE detection + separate setup .jsdos bundle
Build & Deploy / build-and-deploy (push) Successful in 1m1s
When a game archive contains SETUP.EXE, INSTALL.EXE, CONFIG.EXE or
CUSTOM.EXE (or .com/.bat variants), the upload now:

- Detects and stores the path in game metadata (setup_exe / has_setup)
- Generates a second .jsdos bundle with SETUP.EXE as autoexec:
  - Standard mode: {gameId}.setup.jsdos (full game + setup autoexec)
  - Sockdrive mode: .jsdos-setup/ directory alongside .jsdos/
- Both bundles share the same game files; only the autoexec differs

Frontend changes:
- Router strips query params from hash so ?setup=1 doesn't leak into id
- GameDetail shows a "Setup" button between Play and Edit (only when
  game.has_setup is true), navigating to /play/{id}?setup=1
- Play.svelte detects ?setup=1, loads setupBundleUrl() instead, and
  shows setup-specific overlay text

Persistence is handled automatically by js-dos v8 via the built-in
auto-save mechanism (ci.persist() → browser OPFS), so any config
changes made via SETUP.EXE survive across sessions.
2026-05-27 16:49:15 +02:00
David Alvarez 00267da3dd fix: manual title input searches IGDB first, warns before uploading
Build & Deploy / build-and-deploy (push) Successful in 45s
Flow when auto-search finds nothing:
1. User types a title → presses Enter/clicks button
2. System searches IGDB with that title
3a. Results found → shown as selectable cards
3b. No results → warning + amber 'Upload anyway as xxx' button
4. Button label changes: 'Search' → 'Upload' after manual search

Also changed Enter key behavior: first press searches, second
press uploads (if no results after manual search)
2026-05-27 11:57:16 +02:00
David Alvarez 61a83cff65 fix: increase max upload body size to 200MB
Build & Deploy / build-and-deploy (push) Successful in 46s
Quarkus defaults to 10MB max body size. DOS games like
Warcraft 2 (50-100MB) were silently rejected. Added:
- quarkus.http.limits.max-body-size=200M
- quarkus.resteasy-reactive.multipart.input-part.max-size=200M
2026-05-27 11:49:45 +02:00
David Alvarez 72391fb577 feat: auto-search IGDB on file select, pick match or type custom title
Build & Deploy / build-and-deploy (push) Successful in 54s
Upload flow is now:
1. Click +Upload → select a .zip file
2. IGDB is auto-searched with the filename
3. If matches found → show results as selectable cards + fallback
   custom title search at the bottom
4. If no matches → show 'no matches' + manual title + Upload btn
   + a fallback 'Use filename: xxx' button
5. Clicking a result uploads immediately with that game's title
   (so IGDB auto-scrape picks it up correctly)
6. Cancel button to dismiss
2026-05-27 11:25:03 +02:00
David Alvarez 3c49d72f56 refactor: flatten ZIP single-root-directory instead of path splitting
Build & Deploy / build-and-deploy (push) Successful in 1m45s
Completely new approach:
- After unzip, check if extractDir has exactly one entry (a directory)
- If so, move all its contents up and delete the empty folder
- This means game files are always at the bundle root (c:\)
- No more cd/subdirectory handling in config builders
- No more path separator conversions (forward/backslash)
- Removed splitPath() helper entirely
- Config builders are now trivial — relExe is always just a filename
2026-05-27 11:07:39 +02:00
David Alvarez e7b845e8a1 clean: remove all Doom-specific logic, fix path separator in jsdos.json
Build & Deploy / build-and-deploy (push) Successful in 1m5s
Removed:
- injectDoomConfig() — WAD-detection and WASD config injection
- buildDoomDefaultCfg() — Doom config file generator
- All calls to injectDoomConfig in both bundle paths

Fixed:
- buildJsdosJson() now also uses splitPath() helper to handle
  backslash separators (was using lastIndexOf('/') only)
- Extracted shared splitPath() method used by both
  buildJsdosJson and buildDosboxConf

No more game-specific hacks. Just clean config generation.
2026-05-27 10:48:38 +02:00
David Alvarez 596b897df6 fix: use backslash path separator for DOS paths
Build & Deploy / build-and-deploy (push) Successful in 49s
extractDir.relativize() returns paths with native Linux forward
slashes. DOS requires backslashes. Changed .replace('\', '/')
to .replace('/', '\') and updated buildDosboxConf split logic
to also check for backslash separator.
2026-05-27 10:41:45 +02:00
David Alvarez 542db6a916 Fix: handle DOS games in subdirectories inside .jsdos bundles
Build & Deploy / build-and-deploy (push) Successful in 48s
When a ZIP contains game files in a subdirectory (e.g. doomiido/),
findMainExe() correctly finds the executable but the bundle autoexec
was using just the filename, causing DOSBox 'Illegal command' error.

- Use extractDir.relativize() to compute the relative exe path
- buildDosboxConf() now adds 'cd dirname' before running the exe
- buildJsdosJson() uses 'cd dirname && exe' for multi-line script
- Fix applies to both sockdrive and standard bundle paths
2026-05-27 10:36:44 +02:00
David Alvarez 6c56cbc992 fix: use CRLF line endings in DEFAULT.CFG to match Doom's fgets parser
Build & Deploy / build-and-deploy (push) Successful in 51s
The dos.zone DEFAULT.CFG uses \r\n (DOS/CRLF) line endings.
Our injected config used \n (LF/Unix). While fgets() reads
both, the \r character stays in the buffer and the Doom
parser may behave differently when it's absent. Matching
the exact byte-for-byte format that works on dos.zone.
2026-05-27 09:57:41 +02:00
David Alvarez 4bd771f97f fix: add jsdos.json to bundle for proper js-dos v8 config
Build & Deploy / build-and-deploy (push) Successful in 1m4s
The missing piece — dos.zone's bundle has .jsdos/jsdos.json
which tells js-dos v8 how to configure the emulator (autoexec
command, autolock=true, etc.). Without it, js-dos may apply
different defaults that don't match our dosbox.conf.

Also simplified dosbox.conf to just the essential sections
and removed mapperfile=mapper-jsdos.map (not bundled).
Keeps usescancodes=true, autolock=true, SB16 audio.
2026-05-27 09:38:26 +02:00
David Alvarez 9a4e87d044 fix: recursive WAD detection + write BOTH DEFAULT.CFG and DOOM2.CFG
Build & Deploy / build-and-deploy (push) Successful in 49s
Previous injectDoomConfig used Files.list() which only checks the
top-level directory — if the WAD was in a subdirectory, detection
failed silently and no config was written. Switched to Files.walk()
for recursive search.

Also:
- Write BOTH DEFAULT.CFG AND DOOM2.CFG (Doom II reads DOOM2.CFG
  first as game-specific config, falling back to DEFAULT.CFG)
- Always overwrite (don't skip if file exists — the ZIP might
  contain a stock DEFAULT.CFG with arrow-key defaults)
2026-05-27 09:30:49 +02:00
David Alvarez 655d3e83cd fix: full dosbox.conf with usescancodes=true + auto WASD for Doom
Build & Deploy / build-and-deploy (push) Successful in 53s
Root cause: Dosbox.conf was missing the [sdl] section with
'usescancodes=true' — without it DOSBox doesn't translate
keyboard scan codes and WASD keys don't register as game input.

Fix:
- Generate a complete dosbox.conf matching js-dos v8 standards
  (all sections: sdl, dosbox, cpu, mixer, render, midi,
   sblaster, gus, speaker, joystick, serial, dos, ipx, autoexec)
- Key setting: usescancodes=true + mapperfile=mapper-jsdos.map
- Uses svga_s3 machine type with SB16 audio (same as dos.zone)

Bonus: Auto-detect Doom-engine games (DOOM.WAD / DOOM2.WAD)
and inject a DEFAULT.CFG with WASD controls:
  W=forward, S=backward, A=strafe-left, D=strafe-right
  Arrows=turn, Ctrl=fire, Space=use, Alt=strafe, Shift=run
  Mouse enabled for looking

Note: Existing uploaded games still have the old dosbox.conf.
They need to be re-uploaded to get the fix.
2026-05-27 09:19:56 +02:00
David Alvarez feccf4178f feat: move meta/dev under cover, upload dialog on file select
Build & Deploy / build-and-deploy (push) Successful in 52s
Game Detail:
- Year/genre badges and developer/publisher info moved from
  the info column to the cover column (below the cover image)
- Edit mode unaffected — fields remain in the form on the right
- Added CSS spacing for meta/dev in cover section

Library upload:
- Removed the always-visible title input field
- After selecting a .zip, a modal dialog appears with two options:
  1. 'Use filename as title' — uploads immediately with filename
  2. Custom title input + 'Upload' button
  Plus a Cancel button
- Modal is dismissible by clicking outside or Cancel
2026-05-26 21:31:50 +02:00
David Alvarez d751779984 fix: constrain 1fr grid column with min-width:0 and word-break
Build & Deploy / build-and-deploy (push) Successful in 51s
The info-section grid column (1fr) was expanding to fit wide content
like long game titles in Press Start 2P font (each character ~24px).
Added min-width:0 on grid children to prevent overflow, and
word-break:break-word on text elements as a safety net.
2026-05-26 21:16:15 +02:00
David Alvarez 3ae6f43ccb feat: responsive mobile UI across all pages
Build & Deploy / build-and-deploy (push) Successful in 50s
Global:
- Viewport meta already present; added -webkit-text-size-adjust: 100%
- Container padding reduces to 12px on mobile (was 24px)
- Smaller heading sizes (h1: 1.5rem, h2: 1.25rem)
- Buttons get min-height 40px for touch targets
- Inputs forced to 16px font-size to prevent iOS zoom
- Thinner scrollbar on mobile

Header:
- Logo text shrinks to 1rem on mobile, icon to 28px
- Header padding reduces to 12px

Library:
- Toolbar stacks vertically on mobile, full-width inputs
- Grid forced to 2 columns on narrow screens (480px), reduced gap

GameCard:
- Smaller info text, padding, badge on 480px screens

GameDetail:
- Cover section capped at 200px width on mobile
- Media thumbnails shrink to 160x90
- Scrape header stacks vertically with full-width buttons
- Edit rows stack vertically on mobile

Play:
- DOS container caps at 50vh on mobile, smaller border-radius
- Overlay text/icon sizes reduced, more compact layout
2026-05-26 17:22:15 +02:00
David Alvarez 404e8b6719 fix: remove java.nio.file.Path import conflicting with @Path annotation
Build & Deploy / build-and-deploy (push) Successful in 1m1s
2026-05-26 16:53:17 +02:00
David Alvarez 446a9d61b8 feat: all-video media row, clickable media selector, download button
Build & Deploy / build-and-deploy (push) Failing after 44s
- Backend: Add GET /api/games/{id}/download endpoint (ZIP streaming)
- Frontend: All videos now shown in media row alongside screenshots
- Click any media thumbnail to load it into the media container
- Videos show YouTube thumbnail, screenshots show image preview
- Active thumbnail has green border highlight
- Download button between Edit and Delete triggers ZIP download
- Early return: if no media, the entire section is hidden
2026-05-26 16:47:17 +02:00
David Alvarez 0912e43d99 IGDB: add DOS-only search, gameplay videos + screenshot support
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)
2026-05-26 15:49:27 +02:00
David Alvarez 1f55f63b99 Replace Handjet font with Press Start 2P from Google Fonts
Build & Deploy / build-and-deploy (push) Successful in 18s
2026-05-25 13:57:12 +02:00
David Alvarez b8cc11caf4 Fix workflow health check: docker inspect instead of localhost curl (runner can't reach host's localhost)
Build & Deploy / build-and-deploy (push) Successful in 15s
2026-05-25 13:49:42 +02:00
David Alvarez 8c1be9db03 Replace mono font stack with Handjet from Google Fonts
Build & Deploy / build-and-deploy (push) Failing after 1m6s
2026-05-25 13:46:31 +02:00
David Alvarez b2eb99ec82 Add Gitea Actions workflow for auto-build on push; update docker-compose.yml
Build & Deploy / build-and-deploy (push) Has been cancelled
2026-05-25 13:02:55 +02:00
David Alvarez 672be01e8c Fix IGDB search: remove where category=0 filter that blocked all results; fix cover.url handling in both search and applyIgdbId; add debug logging 2026-05-25 12:03:40 +02:00
David Alvarez 466299b8f0 IGDB integration: auto-scrape on upload + manual scrape button
- New IgdbService: Twitch OAuth2, IGDB API v4 search, cover download
- Upload auto-scrapes IGDB after extracting game ZIP
- GameDetail page: 'Auto Scrape' + 'Search' buttons + result picker
- Upload dialog: optional title field (defaults to filename)
- Jackson SNAKE_CASE naming to match frontend expectations
- IGDB status endpoint to check credentials
2026-05-25 10:22:58 +02:00
David Alvarez f2bd6ca3fb Fix Path name collision: use full @jakarta.ws.rs.Path for annotation, specific imports to avoid conflict with java.nio.file.Path 2026-05-25 09:40:50 +02:00
David Alvarez 5579afb5e7 Fix pom.xml: remove quarkus-rest-multipart (built into quarkus-rest), add maven-resources-plugin version; fix Dockerfile: DOSTALGIA_DATA -> DOSTALGIA_DATA_DIR 2026-05-25 09:29:22 +02:00
David Alvarez 28ea711b24 Migrate backend from Go to Quarkus (Java 21 / JAX-RS)
- Replaced Go stdlib server with Quarkus (RESTEasy Reactive + Jackson)
- 7 Java classes: Game POJO, GameService (JSON file I/O), 4 REST resources, static file serving
- Same JSON-per-game storage, same ZIP upload + bundle creation
- Same Svelte frontend (unchanged), built into the JAR via maven-resources-plugin
- Multi-stage Dockerfile: Node → Maven → JRE runtime
2026-05-24 15:41:51 +02:00
David Alvarez 7f17f4b31e Fix Stop + broken buttons: use location.reload() which preserves hash but fully unloads page, killing workers and audio. Revert pathname routing (unnecessary). 2026-05-23 19:29:07 +02:00
David Alvarez f6a1dccdf2 Fix Stop permanently: navigate to real server path /game/{id} to force full page unload, add pathname routing to SPA 2026-05-23 19:21:50 +02:00
David Alvarez a3ba5f8098 Fix Stop button killing audio: use full page reload to terminate Web Workers and AudioContexts that js-dos spawns internally 2026-05-23 19:16:14 +02:00
David Alvarez 40aede0dfc Fix Stop button: await ci.exit() so emulator process fully terminates before navigation, prevent stale audio 2026-05-23 19:11:53 +02:00
David Alvarez 3f41f6f678 Remove test ZIP from repo, fix gitignore for uppercase extensions 2026-05-23 19:07:55 +02:00
David Alvarez 5aedf20b7e Simplify Play page: no Start Game button, seamless boot
- dos-container div always mounted (fixes circular dep: container needed for start, was gated on running)
- Overlay covers the empty canvas while booting: Loading → Booting (CDN) → Running
- No intermediate "Start Game" screen — emulator auto-starts immediately
- Error state shown inline if something fails
- Stop button + back arrow both kill emulator and return to detail
2026-05-23 19:07:38 +02:00
David Alvarez 353f1d8af3 Remove test ZIP from tracking, add *.zip to .gitignore 2026-05-23 18:59:04 +02:00
David Alvarez 338ec870f5 UX: auto-start emulator, stop button, cleanup on navigate away
- Emulator auto-starts when Play page loads (no intermediate launch screen)
- Added ⏹ Stop button while game is running
- Back arrow and Stop both kill the emulator (js-dos CI.exit())
- Cleanup on unmount prevents background emulation
- Loading state shown while js-dos CDN loads
- Manual "Start Game" fallback if auto-start fails
2026-05-23 18:58:52 +02:00
David Alvarez 2b82404cf8 Remove test ZIP from repo 2026-05-23 18:38:59 +02:00
David Alvarez 545e2d2bb5 Fix Play button 404: bundle path and field name
- .jsdos bundles saved flat in data/games/ so /games/{id}.jsdos serves them
- bundleUrl() uses game.id (matches JSON) instead of game.slug (undefined)
- Delete handler also removes flat .jsdos bundle on game deletion
2026-05-23 18:38:53 +02:00
David Alvarez df5ce4f127 Fix: remove --only=production flag so devDependencies (vite, svelte plugin) are installed 2026-05-23 18:28:05 +02:00
David Alvarez aad4ec21a6 Fix Docker build: Debian Node image, npx vite build, .dockerignore 2026-05-23 18:13:00 +02:00
David Alvarez 70fba6d14a Fix Dockerfile: use --legacy-peer-deps for npm install, simplify build stages 2026-05-23 18:07:25 +02:00
David Alvarez 1efd10f81f Initial commit: Dostalgia — DOS game hub
- Go backend (single binary) with embedded Svelte frontend
- JSON-per-game storage (no database)
- .jsdos bundle creation on upload
- Dark phosphor-green theme (#33FF33)
- js-dos v8 emulator integration
- Sockdrive support for games >80MB
- IGDB placeholder for future artwork scraping
- Docker deployment ready
2026-05-23 11:18:41 +02:00
droideparanoico c3c154ec65 Initial commit 2026-05-23 11:00:23 +02:00
57 changed files with 576 additions and 4337 deletions
-10
View File
@@ -1,10 +0,0 @@
# DOStalgia — Environment Configuration
# Copy this file to .env and fill in your values:
# cp .env.example .env
# Twitch Client ID for IGDB metadata auto-scrape.
# Get yours at https://dev.twitch.tv/console/apps → Create Application
# (Set OAuth Redirect URL to http://localhost, Category to "Other")
# Optional — IGDB features gracefully skip if not set.
# TWITCH_CLIENT_ID=your_client_id_here
# TWITCH_CLIENT_SECRET=your_client_secret_here
+34 -1
View File
@@ -1,5 +1,7 @@
# This workflow builds DOStalgia whenever code is pushed to main.
# This workflow builds and deploys Dostalgia whenever code is pushed to main.
# The runner has Docker socket access so it can manage containers on the host.
# Parameters match the docker-compose at:
# /appdata/dockhand/stacks/unraid/dostalgia/compose.yaml
# See: https://docs.gitea.com/usage/actions
name: Build & Deploy
@@ -11,6 +13,8 @@ on:
env:
IMAGE_NAME: dostalgia
CONTAINER_NAME: dostalgia
DATA_VOLUME: /mnt/cache/appdata/dostalgia
jobs:
build-and-deploy:
@@ -25,5 +29,34 @@ jobs:
docker build -t ${{ env.IMAGE_NAME }}:${{ gitea.sha }} .
docker tag ${{ env.IMAGE_NAME }}:${{ gitea.sha }} ${{ env.IMAGE_NAME }}:latest
- name: Deploy container
run: |
docker stop ${{ env.CONTAINER_NAME }} 2>/dev/null || true
docker rm ${{ env.CONTAINER_NAME }} 2>/dev/null || true
docker run -d \
--name ${{ env.CONTAINER_NAME }} \
--restart unless-stopped \
--network dockernet \
-p 8765:8765/tcp \
-v ${{ env.DATA_VOLUME }}:/data \
-e TWITCH_CLIENT_ID='${{ secrets.TWITCH_CLIENT_ID }}' \
-e TWITCH_CLIENT_SECRET='${{ secrets.TWITCH_CLIENT_SECRET }}' \
${{ env.IMAGE_NAME }}:latest
- name: Verify container is running
run: |
sleep 5
STATUS=$(docker inspect -f '{{.State.Status}}' ${{ env.CONTAINER_NAME }})
if [ "$STATUS" = "running" ]; then
echo "✅ Dostalgia container is running"
echo "--- last logs ---"
docker logs ${{ env.CONTAINER_NAME }} --tail 5
echo "✅ Dostalgia deployed!"
else
echo "❌ Container status: $STATUS"
docker logs ${{ env.CONTAINER_NAME }} --tail 30
exit 1
fi
- name: Clean up old images
run: docker image prune -f --filter "until=24h"
-45
View File
@@ -1,45 +0,0 @@
name: Publish Docker image
on:
release:
types: [published]
jobs:
push:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_TOKEN }}
- name: Extract tags
id: meta
uses: docker/metadata-action@v5
with:
images: droideparanoico/dostalgia
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
-7
View File
@@ -2,10 +2,6 @@
node_modules/
frontend/node_modules/
# Environment — secrets
.env
.env.local
# Built output
frontend/dist/
dist/
@@ -40,6 +36,3 @@ data/saves/*
# OS
.DS_Store
Thumbs.db
# Gitea
.gitea/
+2 -3
View File
@@ -1,4 +1,4 @@
# DOStalgia — Quarkus multi-stage build
# Dostalgia — Quarkus multi-stage build
# ─── 1. Build frontend ───────────────────────────
FROM node:20-alpine AS frontend
@@ -7,7 +7,6 @@ COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci 2>/dev/null || npm install --legacy-peer-deps
COPY frontend/ ./
RUN npm run build
RUN npm test
# ─── 2. Build Quarkus app ────────────────────────
FROM maven:3-eclipse-temurin-21-alpine AS build
@@ -16,7 +15,7 @@ COPY pom.xml ./
COPY src ./src
# Copy the built frontend into the source tree so maven-resources-plugin picks it up
COPY --from=frontend /build/dist ./frontend/dist/
RUN mvn package -q -DskipFrontend=true -DskipFrontendTests=true
RUN mvn package -DskipTests -q
# ─── 3. Runtime ──────────────────────────────────
FROM eclipse-temurin:21-jre-alpine
-21
View File
@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2026 droideparanoico
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+33 -99
View File
@@ -1,109 +1,17 @@
# DOStalgia 🕹️
# Dostalgia
A nostalgic DOS game hub. Upload your old DOS games, auto-scrape artwork and metadata from IGDB, and play them directly in your browser via js-dos (DOSBox compiled to WebAssembly).
### Library view
![library](images/library.jpeg "Library")
### Game detail view
![game](images/game.jpeg "Game")
## Features
### 🎮 Play in the browser
Every uploaded game is packaged into a `.jsdos` bundle — a standard ZIP with embedded DOSBox configuration. When you hit **Play**, js-dos v8 is loaded from CDN and starts the emulator instantly in your browser. No plugins, no native installs.
**Save states are automatic** — js-dos persists your game progress to the browser's storage. Come back anytime and pick up where you left off.
### 📦 Handles any file structure
DOS games come in all shapes. DOStalgia handles them transparently:
- **Flattening** — If your ZIP has a single root directory (e.g. `doom/` with all files inside), the extractor flattens it so the game files sit at the bundle root. No extra nesting.
- **Subdirectory games** — If files are deeper, the autoexec automatically `cd`s to the right directory before launching the executable.
- **CD images** — Games shipped on CD-ROM often need the disc mounted. DOStalgia detects `.iso`, `.cue`, `.img`, `.ccd`, and `.bin` files, fixes broken CloneCD `.cue` references (where the referenced `.bin` is actually `.img`), and mounts them in both `dosbox.conf` and `jsdos.json`.
- **CD-only games** — If the archive contains only CD images with no executable, a CD-only bundle is created that mounts the disc and drops you at the DOS prompt.
- **Hardcoded paths** — `ConfigPatcher` scans game config files for hardcoded absolute paths (e.g. `C:\FALLOUT1\MASTER.DAT`) that broke after flattening, and rewrites them to relative paths.
Other solutions such as [RomM](https://www.romm.app/) generate raw ZIP bundles and require you to write your own `dosbox.conf` with manual `mount` and `imgmount` commands. DOStalgia automates all of this for you.
### 🔍 Smart executable detection
On upload, DOStalgia scans every `.exe`, `.com`, and `.bat` file and picks the best candidate as the main executable using a scoring system:
1. **Not an installer** — INSTALL/SETUP/CONFIG executables are deprioritised
2. **DOS executables** — Pure DOS apps are preferred over Windows ones
3. **Larger files** — Bigger executables are more likely to be the game
4. **Shallow depth** — Files closer to the root are preferred
5. **Self-extractor filtering** — PKZIP/PKSFX stubs are filtered out
All discovered executables are stored and available in the **Edit** page, where you can pick a different one via radio buttons.
Again, RomM requires you to manually specify the executable in a custom `dosbox.conf`, while DOStalgia detects and configures it for you.
> **⚠️ Cache note:** If you change the executable, the `.jsdos` bundle is patched in-place. Your browser may serve a cached version of the old bundle — if the game doesn't launch with the new executable, **hard-refresh** the play page (Ctrl+Shift+R / Cmd+Shift+R).
### 🛠 One-click Setup launcher
Many DOS games include a `SETUP.EXE`, `INSTALL.EXE`, or `CONFIG.EXE` used to configure sound, controls, and graphics. When DOStalgia detects one:
- A **🛠 Setup** button appears on the game detail page
- Clicking it launches the setup executable *without modifying the main game bundle*
- The setup bundle is generated **on-the-fly** by the server — a modified `.jsdos` is streamed with the setup executable in the autoexec, then discarded. Nothing is written to disk.
This is not supported in RomM, where you would have to run the setup manually from the DOS prompt every time.
### 🪟 Windows game detection
DOSBox can't run Windows executables. DOStalgia's `PlatformDetector` reads MZ/PE/NE headers to detect Windows executables:
- The game detail page shows a **🪟 Requires Windows 3.1** badge
- The **Play** button is disabled with "Unplayable" text
- If you click Play anyway, a warning overlay explains the limitation with a **Try anyway** fallback
- Setup executables that are Windows-native are also filtered out from the Setup button
### 📡 IGDB metadata & media
DOStalgia integrates with [IGDB](https://www.igdb.com/) (via Twitch OAuth2) to auto-populate game info:
- **Auto Scrape** — On upload, DOStalgia searches IGDB by title and fills in year, genre, developer, publisher, description, and cover art. If IGDB finds a DOS result, it prioritises it.
- **Manual Search** — From the game detail page you can search IGDB by any query, browse results with cover thumbnails and DOS badges, and apply the one you want.
- **Media** — Videos (YouTube embeds) and screenshots (1080p) are fetched and displayed in a scrollable media gallery with a preview player.
**Setup:**
1. Go to https://dev.twitch.tv/console/apps → **Register Your Application**
2. Name: `dostalgia` (or anything), OAuth Redirect URL: `http://localhost`, Category: **Other**
3. Copy the **Client ID** and generate a **Client Secret**
4. Provide them via environment variables:
Method | How
--- | ---
Docker run | `-e TWITCH_CLIENT_ID=xxx -e TWITCH_CLIENT_SECRET=yyy`
Docker Compose | Copy `.env.example` to `.env` and fill in the values
Local dev | `export TWITCH_CLIENT_ID=xxx TWITCH_CLIENT_SECRET=yyy`
IGDB features degrade gracefully — if credentials aren't set, uploads and metadata editing still work, just without auto-scrape.
A nostalgic DOS game hub. Upload your old DOS games, scrape artwork, and play them directly in the browser via js-dos (WebAssembly DOSBox).
## Quick Start
### With Docker Compose (easiest)
```bash
# 1. (Optional) Enable IGDB metadata auto-scrape
cp .env.example .env
# Edit .env with your Twitch credentials (see IGDB section below)
# 2. Pull & run
docker compose up -d
# Build and run with Docker
docker build -t dostalgia .
docker run -p 8765:8765 -v $(pwd)/data:/data dostalgia
```
Open http://localhost:8765
### With Docker
```bash
docker run -p 8765:8765 -v $(pwd)/data:/data \
-e TWITCH_CLIENT_ID=your_id \
-e TWITCH_CLIENT_SECRET=your_secret \
droideparanoico/dostalgia
```
## Development (without Docker)
Terminal 1 — Frontend dev server:
@@ -127,8 +35,34 @@ java -jar target/quarkus-app/quarkus-run.jar
## Architecture
- **Backend**: Quarkus (Java 21, JAX-RS) — REST endpoints, zero external database
- **Backend**: Quarkus (Java 21, JAX-RS) — ~5 REST resource classes, zero database
- **Frontend**: Svelte 5 SPA with hash-based routing
- **Emulation**: js-dos v8 loaded from CDN, runs DOSBox in WebAssembly
- **Storage**: JSON-per-game under `/data/games/{id}/game.json`
- **Saves**: Browser localStorage / indexedDB (managed by js-dos)
## API
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/games` | List all games |
| GET | `/api/games/:id` | Get game details |
| PATCH | `/api/games/:id` | Update game metadata |
| DELETE | `/api/games/:id` | Delete a game |
| POST | `/api/upload` | Upload a game ZIP (multipart) |
| POST | `/api/games/:id/cover` | Upload cover art |
| GET | `/api/igdb/search?q=` | Search IGDB (placeholder) |
## Game JSON Schema
```json
{
"id": "doom",
"title": "Doom",
"year": 1993,
"genre": "FPS",
"developer": "id Software",
"bundle_file": "doom.jsdos",
"has_cover": true,
"ready": true
}
```
+5 -5
View File
@@ -1,14 +1,14 @@
services:
dostalgia:
image: droideparanoico/dostalgia
build: .
container_name: dostalgia
restart: unless-stopped
ports:
- "8765:8765"
volumes:
# Configure here your data directory if you want persistence.
- ./data:/data
env_file:
- .env
environment:
- DOSTALGIA_DATA_DIR=/data
# IGDB credentials — set these for auto-scrape to work:
# - TWITCH_CLIENT_ID=your_client_id
# - TWITCH_CLIENT_SECRET=your_client_secret
restart: unless-stopped
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 36 36'><rect x='4' y='6' width='28' height='22' rx='2' fill='%2333FF33' opacity='.15'/><rect x='6' y='8' width='24' height='16' fill='%230A0A0A'/><rect x='8' y='10' width='20' height='10' fill='%2333FF33' opacity='.3'/><rect x='10' y='12' width='6' height='4' fill='%2333FF33' opacity='.6'/><rect x='18' y='12' width='6' height='4' fill='%2333FF33' opacity='.4'/></svg>" />
<title>DOStalgia</title>
<title>Dostalgia</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap" rel="stylesheet" />
+3 -1874
View File
File diff suppressed because it is too large Load Diff
+3 -8
View File
@@ -1,21 +1,16 @@
{
"name": "dostalgia-frontend",
"private": true,
"version": "0.1.1",
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest"
"preview": "vite preview"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^4.0.0",
"@testing-library/svelte": "^5.3.1",
"jsdom": "^29.1.1",
"svelte": "^5.0.0",
"vite": "^5.0.0",
"vitest": "^4.1.8"
"vite": "^5.0.0"
}
}
+1 -1
View File
@@ -43,7 +43,7 @@
});
</script>
<Header onUploadClick={() => uploadTriggered++} hideUpload={route !== "/"} />
<Header onUploadClick={() => uploadTriggered++} />
<main class="container">
{#if route === "/"}
+8 -13
View File
@@ -98,8 +98,7 @@ h3 { font-size: 1.2rem; }
/* ═══════════════════════════════════════════════════════════════
BUTTONS
═══════════════════════════════════════════════════════════════ */
/* Only targets DOStalgia buttons, not js-dos emulator buttons inside .dos-container */
.btn.btn:not(.dos-container .btn) {
.btn {
display: inline-flex;
align-items: center;
gap: 8px;
@@ -112,36 +111,32 @@ h3 { font-size: 1.2rem; }
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
text-transform: none;
height: auto;
min-height: 0;
line-height: normal;
transition: all 0.15s ease;
}
.btn.btn:not(.dos-container .btn):hover {
.btn:hover {
background: var(--phosphor-burn);
border-color: var(--phosphor);
box-shadow: 0 0 12px var(--phosphor-dark);
}
.btn.btn:not(.dos-container .btn):active {
.btn:active {
transform: scale(0.97);
}
.btn-primary.btn-primary:not(.dos-container .btn) {
.btn-primary {
background: var(--phosphor-dark);
border-color: var(--phosphor);
color: var(--phosphor-glow);
}
.btn-primary.btn-primary:not(.dos-container .btn):hover {
.btn-primary:hover {
background: var(--phosphor-dim);
color: var(--bg);
}
.btn-danger.btn-danger:not(.dos-container .btn) {
.btn-danger {
border-color: #cc3333;
color: #cc3333;
}
.btn-danger.btn-danger:not(.dos-container .btn):hover {
.btn-danger:hover {
background: #331111;
border-color: #ff4444;
color: #ff4444;
@@ -192,7 +187,7 @@ input::placeholder {
@media (max-width: 640px) {
h1 { font-size: 1.5rem; }
h2 { font-size: 1.25rem; }
.btn.btn:not(.dos-container .btn) { padding: 8px 14px; font-size: 0.85rem; min-height: 40px; }
.btn { padding: 8px 14px; font-size: 0.85rem; min-height: 40px; }
input, textarea, select { font-size: 16px; } /* prevents iOS zoom */
}
+2 -4
View File
@@ -1,5 +1,5 @@
<script>
let { onUploadClick = () => {}, hideUpload = false } = $props();
let { onUploadClick = () => {} } = $props();
</script>
<header class="header">
@@ -17,9 +17,7 @@
<span class="logo-text">DOSTALGIA</span>
</a>
<nav>
{#if !hideUpload}
<button class="btn" onclick={onUploadClick}>+ Upload</button>
{/if}
<button class="btn" onclick={onUploadClick}>+ Upload</button>
</nav>
</div>
</header>
@@ -1,71 +0,0 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/svelte";
import GameCard from "../GameCard.svelte";
function makeGame(overrides = {}) {
return {
id: "test-1",
title: "Test Game",
year: 1995,
genre: "Adventure",
platform: "dos",
has_cover: false,
...overrides,
};
}
describe("GameCard", () => {
it("renders the game title", () => {
render(GameCard, { props: { game: makeGame() } });
expect(screen.getByText("Test Game")).toBeTruthy();
});
it("renders the year when provided", () => {
render(GameCard, { props: { game: makeGame({ year: 1997 }) } });
expect(screen.getByText("1997")).toBeTruthy();
});
it("does not render year when absent", () => {
render(GameCard, { props: { game: makeGame({ year: null }) } });
expect(screen.queryByText("1995")).toBeNull();
});
it("renders the genre when provided", () => {
render(GameCard, { props: { game: makeGame({ genre: "RPG" }) } });
expect(screen.getByText("RPG")).toBeTruthy();
});
it("shows a cover image when has_cover is true", () => {
const game = makeGame({ has_cover: true });
render(GameCard, { props: { game } });
const img = screen.getByRole("img");
expect(img).toBeTruthy();
expect(img.getAttribute("src")).toBe(`/games/${game.id}/cover.jpg`);
expect(img.getAttribute("alt")).toBe(game.title);
});
it("shows a placeholder when has_cover is false", () => {
render(GameCard, { props: { game: makeGame({ has_cover: false }) } });
expect(screen.getByText("💾")).toBeTruthy();
});
it("shows Windows badge for windows platform", () => {
render(GameCard, {
props: { game: makeGame({ platform: "windows" }) },
});
expect(screen.getByText(/🪟/)).toBeTruthy();
});
it("does not show Windows badge for DOS platform", () => {
render(GameCard, { props: { game: makeGame({ platform: "dos" }) } });
expect(screen.queryByText(/🪟/)).toBeNull();
});
it("navigates on click", async () => {
window.location.hash = "";
render(GameCard, { props: { game: makeGame() } });
const btn = screen.getByRole("button");
btn.click();
expect(window.location.hash).toBe("#/game/test-1");
});
});
-68
View File
@@ -1,68 +0,0 @@
import { describe, it, expect } from "vitest";
import { artworkUrl, bundleUrl, setupBundleUrl } from "../api.js";
describe("artworkUrl", () => {
it("returns null for null input", () => {
expect(artworkUrl(null)).toBeNull();
});
it("returns null for undefined input", () => {
expect(artworkUrl(undefined)).toBeNull();
});
it("passes through absolute HTTP URLs", () => {
expect(artworkUrl("https://example.com/cover.jpg")).toBe(
"https://example.com/cover.jpg"
);
});
it("proxies relative paths through /artwork/", () => {
expect(artworkUrl("images/game.png")).toBe("/artwork/images/game.png");
});
it("preserves nested relative paths", () => {
expect(artworkUrl("some/deep/path/file.webp")).toBe(
"/artwork/some/deep/path/file.webp"
);
});
});
describe("bundleUrl", () => {
it("returns null for null game", () => {
expect(bundleUrl(null)).toBeNull();
});
it("returns null for undefined game", () => {
expect(bundleUrl(undefined)).toBeNull();
});
it("builds URL from bundle_file", () => {
expect(bundleUrl({ bundle_file: "mygame.zip" })).toBe("/games/mygame.zip");
});
it("preserves subdirectory bundle paths", () => {
expect(bundleUrl({ bundle_file: "subdir/game.zip" })).toBe(
"/games/subdir/game.zip"
);
});
});
describe("setupBundleUrl", () => {
it("returns null for null game", () => {
expect(setupBundleUrl(null)).toBeNull();
});
it("returns null for undefined game", () => {
expect(setupBundleUrl(undefined)).toBeNull();
});
it("builds API setup URL from game id", () => {
expect(setupBundleUrl({ id: "game-1" })).toBe(
"/api/games/game-1/setup-bundle"
);
});
it("handles games with numeric id", () => {
expect(setupBundleUrl({ id: 42 })).toBe("/api/games/42/setup-bundle");
});
});
-33
View File
@@ -1,33 +0,0 @@
import { describe, it, expect, vi } from "vitest";
import { push, replace } from "../router.js";
describe("router", () => {
describe("push", () => {
it("sets window.location.hash with # prefix", () => {
window.location.hash = "";
push("/game/123");
expect(window.location.hash).toBe("#/game/123");
});
it("handles root path", () => {
window.location.hash = "";
push("/");
expect(window.location.hash).toBe("#/");
});
it("replaces hash on subsequent calls", () => {
window.location.hash = "";
push("/first");
push("/second");
expect(window.location.hash).toBe("#/second");
});
});
describe("replace", () => {
it("sets hash via replace (no back-navigation allowed)", () => {
window.location.hash = "#/old";
replace("/new");
expect(window.location.hash).toBe("#/new");
});
});
});
+1 -1
View File
@@ -1,4 +1,4 @@
/** API client for DOStalgia backend. */
/** API client for Dostalgia backend. */
const BASE = import.meta.env.PROD ? "" : ""; // Proxy handles it in dev
+9 -1
View File
@@ -134,7 +134,7 @@
{isSetup ? "Back" : game.title}
</button>
{#if running}
<button class="btn btn-danger" onclick={stopEmulator}> Stop</button>
<button class="btn stop-btn" onclick={stopEmulator}> Stop</button>
{/if}
{/if}
</div>
@@ -211,6 +211,14 @@
font-family: var(--font-sans);
}
.link-button:hover { color: var(--phosphor); }
.stop-btn {
border-color: #cc3333;
color: #ff4444;
}
.stop-btn:hover {
background: #331111;
box-shadow: 0 0 12px #331111;
}
/* Emulator canvas — always mounted */
.dos-container {
@@ -1,185 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/svelte";
import GameDetail from "../GameDetail.svelte";
// Mock api.js
vi.mock("../../lib/api.js", () => ({
getGame: vi.fn(),
deleteGame: vi.fn(),
updateGame: vi.fn(),
searchIGDB: vi.fn(),
scrapeIGDB: vi.fn(),
downloadGame: vi.fn(),
bundleUrl: vi.fn(),
setupBundleUrl: vi.fn(),
artworkUrl: vi.fn(),
}));
// Mock router
vi.mock("../../lib/router.js", () => ({
push: vi.fn(),
}));
import * as api from "../../lib/api.js";
function makeGame(overrides = {}) {
return {
id: "game-1",
title: "Commander Keen",
year: 1991,
genre: "Platformer",
developer: "id Software",
publisher: "Apogee",
description: "A classic DOS platformer.",
platform: "dos",
ready: true,
has_cover: false,
has_setup: false,
bundle_file: "commander-keen.zip",
bundle_size: 512000,
executables: ["keen.exe"],
executable: null,
videos: [],
screenshots: [],
...overrides,
};
}
describe("GameDetail", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("shows loading state initially", () => {
api.getGame.mockReturnValue(new Promise(() => {}));
render(GameDetail, { props: { id: "game-1" } });
expect(screen.getByText("Loading...")).toBeTruthy();
});
it("shows error when game is not found", async () => {
api.getGame.mockRejectedValue(new Error("Game not found"));
render(GameDetail, { props: { id: "missing" } });
await vi.waitFor(() => {
expect(screen.getByText("Game not found")).toBeTruthy();
});
});
it("renders game title", async () => {
api.getGame.mockResolvedValue(makeGame());
render(GameDetail, { props: { id: "game-1" } });
await vi.waitFor(() => {
expect(screen.getByText("Commander Keen")).toBeTruthy();
});
});
it("renders developer and publisher", async () => {
api.getGame.mockResolvedValue(
makeGame({ developer: "id Software", publisher: "Apogee" })
);
render(GameDetail, { props: { id: "game-1" } });
await vi.waitFor(() => {
expect(screen.getByText(/id Software/)).toBeTruthy();
expect(screen.getByText(/Apogee/)).toBeTruthy();
});
});
it("renders year and genre as links", async () => {
api.getGame.mockResolvedValue(
makeGame({ year: 1991, genre: "Platformer" })
);
render(GameDetail, { props: { id: "game-1" } });
await vi.waitFor(() => {
expect(screen.getByText("1991").closest("a")).toBeTruthy();
expect(screen.getByText("Platformer").closest("a")).toBeTruthy();
});
});
it("shows DOS badge for dos platform", async () => {
api.getGame.mockResolvedValue(makeGame({ platform: "dos" }));
render(GameDetail, { props: { id: "game-1" } });
await vi.waitFor(() => {
expect(screen.getByText("💾 DOS")).toBeTruthy();
});
});
it("shows Windows badge for windows platform", async () => {
api.getGame.mockResolvedValue(makeGame({ platform: "windows" }));
render(GameDetail, { props: { id: "game-1" } });
await vi.waitFor(() => {
expect(screen.getByText(/Windows 3\.1/)).toBeTruthy();
});
});
it("shows Play button for ready DOS games", async () => {
api.getGame.mockResolvedValue(
makeGame({ ready: true, platform: "dos" })
);
render(GameDetail, { props: { id: "game-1" } });
await vi.waitFor(() => {
expect(screen.getByText("▶ Play")).toBeTruthy();
});
});
it("shows disabled Play button for Windows games", async () => {
api.getGame.mockResolvedValue(
makeGame({ ready: true, platform: "windows" })
);
render(GameDetail, { props: { id: "game-1" } });
await vi.waitFor(() => {
expect(screen.getByText("▶ Unplayable")).toBeTruthy();
});
});
it("shows Setup button when has_setup is true", async () => {
api.getGame.mockResolvedValue(
makeGame({ has_setup: true })
);
render(GameDetail, { props: { id: "game-1" } });
await vi.waitFor(() => {
expect(screen.getByText("🛠 Setup")).toBeTruthy();
});
});
it("shows Edit, Download, Delete buttons", async () => {
api.getGame.mockResolvedValue(makeGame());
render(GameDetail, { props: { id: "game-1" } });
await vi.waitFor(() => {
expect(screen.getByText("✏ Edit")).toBeTruthy();
expect(screen.getByText("⬇ Download")).toBeTruthy();
expect(screen.getByText("✕ Delete")).toBeTruthy();
});
});
it("enters edit mode when Edit button is clicked", async () => {
api.getGame.mockResolvedValue(makeGame());
render(GameDetail, { props: { id: "game-1" } });
await vi.waitFor(() => {
expect(screen.getByText("✏ Edit")).toBeTruthy();
});
screen.getByText("✏ Edit").click();
await vi.waitFor(() => {
expect(screen.getByText("Save")).toBeTruthy();
expect(screen.getByText("Cancel")).toBeTruthy();
});
});
it("shows cover placeholder when no cover", async () => {
api.getGame.mockResolvedValue(makeGame({ has_cover: false }));
render(GameDetail, { props: { id: "game-1" } });
await vi.waitFor(() => {
expect(screen.getByText("💾")).toBeTruthy();
});
});
it("shows description when provided", async () => {
api.getGame.mockResolvedValue(
makeGame({ description: "A classic DOS platformer." })
);
render(GameDetail, { props: { id: "game-1" } });
await vi.waitFor(() => {
expect(
screen.getByText("A classic DOS platformer.")
).toBeTruthy();
});
});
});
@@ -1,143 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/svelte";
import Library from "../Library.svelte";
// Mock the API module that Library imports
vi.mock("../../lib/api.js", () => ({
listGames: vi.fn(),
uploadGame: vi.fn(),
searchIGDB: vi.fn(),
}));
import * as api from "../../lib/api.js";
function makeGame(overrides = {}) {
return {
id: "g1",
title: "Test Game",
year: 1995,
genre: "Adventure",
platform: "dos",
has_cover: false,
...overrides,
};
}
describe("Library", () => {
beforeEach(() => {
vi.clearAllMocks();
window.location.hash = "#/";
});
it("shows loading state initially", () => {
api.listGames.mockReturnValue(new Promise(() => {})); // never resolves
render(Library);
expect(screen.getByText("Scanning library...")).toBeTruthy();
});
it("shows empty state when no games", async () => {
api.listGames.mockResolvedValue({ games: [] });
render(Library);
await vi.waitFor(() => {
expect(screen.getByText("No games yet")).toBeTruthy();
});
});
it("renders game cards when games load", async () => {
api.listGames.mockResolvedValue({
games: [
makeGame({ id: "g1", title: "Game One" }),
makeGame({ id: "g2", title: "Game Two" }),
],
});
render(Library);
await vi.waitFor(() => {
expect(screen.getByText("Game One")).toBeTruthy();
expect(screen.getByText("Game Two")).toBeTruthy();
});
});
it("shows game count", async () => {
api.listGames.mockResolvedValue({
games: [
makeGame({ id: "g1", title: "One" }),
makeGame({ id: "g2", title: "Two" }),
],
});
render(Library);
await vi.waitFor(() => {
expect(screen.getByText("2 games")).toBeTruthy();
});
});
it("shows singular game count for one game", async () => {
api.listGames.mockResolvedValue({
games: [makeGame({ id: "g1", title: "Only" })],
});
render(Library);
await vi.waitFor(() => {
expect(screen.getByText("1 game")).toBeTruthy();
});
});
describe("filters", () => {
it("shows filter options derived from loaded games", async () => {
api.listGames.mockResolvedValue({
games: [
makeGame({ id: "g1", genre: "RPG", year: 1997 }),
makeGame({ id: "g2", genre: "RPG", year: 1998 }),
makeGame({ id: "g3", genre: "Adventure", year: 1995 }),
],
});
render(Library);
await vi.waitFor(() => {
// Year labels appear both as filter options and in game cards
expect(screen.getAllByText("1995").length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText("1997").length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText("1998").length).toBeGreaterThanOrEqual(1);
});
});
it("applies genre filter from props", async () => {
api.listGames.mockResolvedValue({
games: [
makeGame({ id: "g1", title: "RPG Game", genre: "RPG" }),
makeGame({ id: "g2", title: "Adventure Game", genre: "Adventure" }),
],
});
render(Library, { props: { genreFilter: "RPG" } });
await vi.waitFor(() => {
expect(screen.getByText("RPG Game")).toBeTruthy();
expect(screen.queryByText("Adventure Game")).toBeNull();
});
});
it("applies year filter from props", async () => {
api.listGames.mockResolvedValue({
games: [
makeGame({ id: "g1", title: "Old Game", year: 1993 }),
makeGame({ id: "g2", title: "New Game", year: 1997 }),
],
});
render(Library, { props: { yearFilter: "1997" } });
await vi.waitFor(() => {
expect(screen.getByText("New Game")).toBeTruthy();
expect(screen.queryByText("Old Game")).toBeNull();
});
});
it("shows filter-empty message when no filter options exist", async () => {
api.listGames.mockResolvedValue({
games: [
makeGame({ id: "g1", genre: null, year: null }),
makeGame({ id: "g2", genre: null, year: null }),
],
});
render(Library);
await vi.waitFor(() => {
const dashes = screen.getAllByText("—");
expect(dashes.length).toBeGreaterThanOrEqual(2);
});
});
});
});
-77
View File
@@ -1,77 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/svelte";
import Play from "../Play.svelte";
// Mock the API module that Play imports
vi.mock("../../lib/api.js", () => ({
getGame: vi.fn(),
bundleUrl: vi.fn(),
setupBundleUrl: vi.fn(),
}));
// Mock router
vi.mock("../../lib/router.js", () => ({
push: vi.fn(),
}));
import * as api from "../../lib/api.js";
function makeGame(overrides = {}) {
return {
id: "game-1",
title: "Commander Keen",
platform: "dos",
ready: true,
...overrides,
};
}
describe("Play", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("shows loading state initially", () => {
api.getGame.mockReturnValue(new Promise(() => {}));
render(Play, { props: { id: "game-1" } });
expect(screen.getByText("Loading game data...")).toBeTruthy();
});
it("shows not-ready state when game is not processed", async () => {
api.getGame.mockResolvedValue(makeGame({ ready: false }));
render(Play, { props: { id: "game-1" } });
await vi.waitFor(() => {
expect(screen.getByText("Not ready")).toBeTruthy();
});
});
it("shows unsupported warning for Windows games", async () => {
api.getGame.mockResolvedValue(
makeGame({ platform: "windows", ready: true })
);
render(Play, { props: { id: "game-1" } });
await vi.waitFor(() => {
expect(screen.getByText("Windows game")).toBeTruthy();
});
});
it("shows error box when game fetch fails", async () => {
api.getGame.mockRejectedValue(new Error("Game not found"));
render(Play, { props: { id: "missing" } });
// The error should appear in the overlay as well
await vi.waitFor(() => {
expect(screen.getByText("⚠️")).toBeTruthy();
});
});
it("shows emulator booting state for ready DOS games", async () => {
api.getGame.mockResolvedValue(
makeGame({ platform: "dos", ready: true })
);
api.bundleUrl.mockReturnValue("/games/game-1.jsdos");
render(Play, { props: { id: "game-1" } });
await vi.waitFor(() => {
expect(screen.getByText("Starting emulator...")).toBeTruthy();
});
});
});
-9
View File
@@ -1,4 +1,3 @@
/// <reference types="vitest" />
import { defineConfig } from "vite";
import { svelte } from "@sveltejs/vite-plugin-svelte";
@@ -16,12 +15,4 @@ export default defineConfig({
outDir: "dist",
emptyOutDir: true,
},
resolve: {
conditions: process.env.VITEST ? ["browser", "module", "import"] : [],
},
test: {
environment: "jsdom",
globals: true,
include: ["src/**/*.test.js"],
},
});
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 261 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 488 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 869 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 314 KiB

+2 -55
View File
@@ -4,9 +4,9 @@
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>org.dostalgia</groupId>
<groupId>com.dostalgia</groupId>
<artifactId>dostalgia</artifactId>
<version>0.1.1</version>
<version>0.1.0</version>
<properties>
<maven.compiler.release>21</maven.compiler.release>
@@ -15,10 +15,6 @@
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<quarkus.platform.version>3.19.2</quarkus.platform.version>
<quarkus.platform.group-id>io.quarkus.platform</quarkus.platform.group-id>
<!-- Set to true to skip all frontend npm tasks (install/build/test) -->
<skipFrontend>false</skipFrontend>
<!-- Set to true to skip only frontend tests (backend tests still run) -->
<skipFrontendTests>false</skipFrontendTests>
</properties>
<dependencyManagement>
@@ -101,55 +97,6 @@
</execution>
</executions>
</plugin>
<!-- Run frontend npm tasks during the Maven lifecycle -->
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>exec-maven-plugin</artifactId>
<version>3.5.0</version>
<executions>
<execution>
<id>npm-install</id>
<phase>initialize</phase>
<goals><goal>exec</goal></goals>
<configuration>
<workingDirectory>frontend</workingDirectory>
<executable>npm</executable>
<arguments>
<argument>ci</argument>
<argument>--prefer-offline</argument>
</arguments>
<skip>${skipFrontend}</skip>
</configuration>
</execution>
<execution>
<id>frontend-build</id>
<phase>generate-resources</phase>
<goals><goal>exec</goal></goals>
<configuration>
<workingDirectory>frontend</workingDirectory>
<executable>npm</executable>
<arguments>
<argument>run</argument>
<argument>build</argument>
</arguments>
<skip>${skipFrontend}</skip>
</configuration>
</execution>
<execution>
<id>frontend-test</id>
<phase>test</phase>
<goals><goal>exec</goal></goals>
<configuration>
<workingDirectory>frontend</workingDirectory>
<executable>npm</executable>
<arguments>
<argument>test</argument>
</arguments>
<skip>${skipFrontendTests}</skip>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
@@ -1,4 +1,4 @@
package org.dostalgia;
package com.dostalgia;
import jakarta.enterprise.context.ApplicationScoped;
@@ -1,4 +1,4 @@
package org.dostalgia;
package com.dostalgia;
import jakarta.enterprise.context.ApplicationScoped;
@@ -51,7 +51,7 @@ public class ConfigBuilder {
/** Build jsdos.json for a CD-only game. */
public byte[] buildCdOnlyJsdosJson(List<String> cdImages) {
String script = buildCdMountScript(cdImages) + "c:\n";
String script = buildCdOnlyAutoexecScript(cdImages);
return buildJsdosJsonRaw(script);
}
@@ -188,7 +188,7 @@ public class ConfigBuilder {
return "path=c:\\\ncd " + dir + "\n" + exe + "\n";
}
private static String buildCdMountScript(List<String> cdImages) {
private static String buildAutoexecScript(String dir, String exe, List<String> cdImages) {
StringBuilder script = new StringBuilder();
script.append("mount c .\n");
char driveLetter = 'D';
@@ -200,11 +200,6 @@ 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");
@@ -214,6 +209,22 @@ public class ConfigBuilder {
return script.toString();
}
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++;
}
script.append("c:\n");
return script.toString();
}
private static byte[] buildJsdosJsonRaw(String script) {
String json = "{\"autoexec\":{\"options\":{\"script\":{\"value\":\""
+ escapeJson(script) + "\"}}},"
@@ -1,4 +1,4 @@
package org.dostalgia;
package com.dostalgia;
import jakarta.enterprise.context.ApplicationScoped;
@@ -1,6 +1,5 @@
package org.dostalgia;
package com.dostalgia;
import jakarta.annotation.Nonnull;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
@@ -25,6 +24,7 @@ public class ExecutableDetector {
@Inject
PlatformDetector platform;
/** Executable stems to never select as main game executable. */
private static final Set<String> SKIP_EXE_NAMES = Set.of(
"dos4gw", "dos32a", "dos4gw2", "pmode", "pmodew", "cwsdpmi",
"emm386", "himem", "himemx", "debug",
@@ -35,17 +35,31 @@ public class ExecutableDetector {
"ace", "unace", "zoo", "arc", "ha", "cab"
);
/** Collect all discoverable executables (relative paths) from the extracted directory. */
public List<String> findAll(Path dir) throws IOException {
return FileUtils.findFilesByExtensions(dir, Set.of(".exe", ".com", ".bat"));
List<String> result = new ArrayList<>();
Files.walkFileTree(dir, new SimpleFileVisitor<>() {
@Override
public FileVisitResult visitFile(Path f, 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('\\', '/'));
}
return FileVisitResult.CONTINUE;
}
});
result.sort(String::compareTo);
return result;
}
/** Find the best main executable. */
public String findMain(Path dir) throws IOException {
record Candidate(int depth, boolean isInstaller, long size, String path, String platform) {}
List<Candidate> candidates = new ArrayList<>();
Files.walkFileTree(dir, new SimpleFileVisitor<>() {
@Override
public @Nonnull FileVisitResult visitFile(@Nonnull Path f, @Nonnull BasicFileAttributes a) {
public FileVisitResult visitFile(Path f, BasicFileAttributes a) {
String name = f.getFileName().toString().toLowerCase();
if (!name.endsWith(".exe") && !name.endsWith(".com") && !name.endsWith(".bat"))
return FileVisitResult.CONTINUE;
@@ -81,23 +95,23 @@ public class ExecutableDetector {
return candidates.getFirst().path();
}
/** Find a setup/install/config executable. */
public String findSetup(Path dir) throws IOException {
record Candidate(int depth, boolean isWindows, long size, String path) {}
record Candidate(int depth, long size, String path) {}
List<Candidate> candidates = new ArrayList<>();
List<String> patterns = List.of("setup", "setmain", "install", "config", "custom");
List<String> patterns = List.of("setup", "install", "config", "custom");
Files.walkFileTree(dir, new SimpleFileVisitor<>() {
@Override
public @Nonnull FileVisitResult visitFile(@Nonnull Path f, @Nonnull BasicFileAttributes a) {
public FileVisitResult visitFile(Path f, BasicFileAttributes a) {
String name = f.getFileName().toString().toLowerCase();
String stem = name.contains(".") ? name.substring(0, name.lastIndexOf('.')) : name;
if (!name.endsWith(".exe") && !name.endsWith(".com") && !name.endsWith(".bat"))
return FileVisitResult.CONTINUE;
for (String pat : patterns) {
if (stem.equals(pat) || stem.startsWith(pat + ".") || stem.startsWith(pat + "-")) {
boolean isWin = "windows".equals(platform.detect(f));
candidates.add(new Candidate(
f.getNameCount() - dir.getNameCount(), isWin, a.size(), f.toString()));
f.getNameCount() - dir.getNameCount(), a.size(), f.toString()));
break;
}
}
@@ -106,13 +120,11 @@ public class ExecutableDetector {
});
if (candidates.isEmpty()) return null;
candidates.sort(Comparator
.comparingInt((Candidate c) -> c.isWindows() ? 1 : 0) // prefer DOS over Windows
.thenComparingInt(Candidate::depth)
.thenComparingLong(Candidate::size));
candidates.sort(Comparator.comparingInt(Candidate::depth).thenComparingLong(Candidate::size));
return candidates.getFirst().path();
}
/** Check if an executable is likely a self-extracting archive (PKSFX stub, etc.). */
public static boolean isLikelySelfExtractor(Path exePath) {
try (var fis = Files.newInputStream(exePath)) {
byte[] buf = new byte[32768];
@@ -1,4 +1,4 @@
package org.dostalgia;
package com.dostalgia;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonInclude;
@@ -1,4 +1,4 @@
package org.dostalgia;
package com.dostalgia;
import jakarta.inject.Inject;
import jakarta.ws.rs.Consumes;
@@ -15,7 +15,7 @@ import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.StreamingOutput;
import java.nio.file.NoSuchFileException;
import java.nio.file.Files;
import java.util.Map;
import java.util.*;
@Path("/api/games")
@Produces(MediaType.APPLICATION_JSON)
@@ -1,4 +1,4 @@
package org.dostalgia;
package com.dostalgia;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -12,10 +12,13 @@ import org.eclipse.microprofile.config.inject.ConfigProperty;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.file.FileVisitResult;
import java.nio.file.Files;
import java.nio.file.NoSuchFileException;
import java.nio.file.Path;
import java.nio.file.SimpleFileVisitor;
import java.nio.file.StandardCopyOption;
import java.nio.file.attribute.BasicFileAttributes;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Comparator;
@@ -77,12 +80,9 @@ public class GameService implements GameStore {
);
// Detect bundle size
try {
String bf = game.getBundleFile();
if (bf != null) {
Path bundlePath = gamesDir.resolve(bf);
if (Files.exists(bundlePath)) {
game.setBundleSize(Files.size(bundlePath));
}
Path bundlePath = gamesDir.resolve(game.getBundleFile());
if (Files.exists(bundlePath)) {
game.setBundleSize(Files.size(bundlePath));
}
} catch (IOException ignored) {}
return game;
@@ -121,7 +121,21 @@ public class GameService implements GameStore {
/** Delete a game and all its files. */
public void delete(String id) throws IOException {
FileUtils.deleteDirectory(gameDir(id));
Path dir = gameDir(id);
if (Files.exists(dir)) {
Files.walkFileTree(dir, new SimpleFileVisitor<>() {
@Override
public FileVisitResult visitFile(Path f, BasicFileAttributes a) throws IOException {
Files.delete(f);
return FileVisitResult.CONTINUE;
}
@Override
public FileVisitResult postVisitDirectory(Path d, IOException e) throws IOException {
Files.delete(d);
return FileVisitResult.CONTINUE;
}
});
}
// Clean up old flat .jsdos locations (pre-game-dir layout backward compat)
Files.deleteIfExists(gamesDir.resolve(id + ".jsdos"));
Files.deleteIfExists(gamesDir.resolve(id + ".setup.jsdos"));
@@ -211,7 +225,18 @@ public class GameService implements GameStore {
java.util.zip.ZipEntry entry;
while ((entry = zis.getNextEntry()) != null) {
if (entry.getName().equals(relExe.replace('\\', '/'))) {
return PlatformDetector.detectFromBytes(PlatformDetector.readHeaderBytes(zis));
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";
}
zis.closeEntry();
}
@@ -1,4 +1,4 @@
package org.dostalgia;
package com.dostalgia;
import java.io.IOException;
import java.nio.file.Path;
@@ -1,4 +1,4 @@
package org.dostalgia;
package com.dostalgia;
import jakarta.inject.Inject;
import jakarta.ws.rs.Consumes;
@@ -1,4 +1,4 @@
package org.dostalgia;
package com.dostalgia;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -54,10 +54,8 @@ public class IgdbService {
if (accessToken != null && tokenExpiry != null && Instant.now().isBefore(tokenExpiry)) {
return accessToken;
}
String cid = clientId.orElseThrow(() -> new RuntimeException("TWITCH_CLIENT_ID not configured"));
String secret = clientSecret.orElseThrow(() -> new RuntimeException("TWITCH_CLIENT_SECRET not configured"));
String body = "client_id=" + cid
+ "&client_secret=" + secret
String body = "client_id=" + clientId.get()
+ "&client_secret=" + clientSecret.get()
+ "&grant_type=client_credentials";
HttpRequest req = HttpRequest.newBuilder()
@@ -74,34 +72,27 @@ 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);
tokenExpiry = Instant.now().plusSeconds(expiresIn - 120); // 2 min buffer
LOG.info("IGDB: acquired new Twitch token (expires in " + expiresIn + "s)");
return accessToken;
}
private HttpRequest.Builder igdbRequest(String path) throws Exception {
String cid = clientId.orElseThrow(() -> new RuntimeException("TWITCH_CLIENT_ID not configured"));
return HttpRequest.newBuilder()
.uri(URI.create(IGDB_API + path))
.header("Client-ID", cid)
.header("Client-ID", clientId.get())
.header("Authorization", "Bearer " + getToken())
.header("Content-Type", "text/plain");
}
/** POST an APQL query to an IGDB endpoint and return the JSON array, or null on failure. */
private JsonNode postAndGet(String endpoint, String apql) throws Exception {
HttpRequest req = igdbRequest(endpoint)
.POST(HttpRequest.BodyPublishers.ofString(apql))
.build();
HttpResponse<String> res = http.send(req, HttpResponse.BodyHandlers.ofString());
if (res.statusCode() != 200) return null;
JsonNode results = mapper.readTree(res.body());
return results.isArray() ? results : null;
}
/**
* Search IGDB for games matching the given query.
* Returns a list of simplified result maps suitable for JSON serialization.
*/
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,"
@@ -109,9 +100,19 @@ public class IgdbService {
+ " where platforms = [13];"
+ " limit 20;";
JsonNode results = postAndGet("/games", apql);
if (results == null || results.isEmpty()) {
LOG.info("IGDB search: no results for '" + query + "'");
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());
return List.of();
}
@@ -121,13 +122,22 @@ public class IgdbService {
entry.put("igdb_id", game.get("id").asInt());
entry.put("name", game.has("name") ? game.get("name").asText() : query);
epochToYear(game).ifPresent(y -> entry.put("year", y));
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));
}
List<String> genres = extractGenres(game);
if (!genres.isEmpty()) {
if (game.has("genres") && game.get("genres").isArray()) {
List<String> genres = new ArrayList<>();
for (JsonNode g : game.get("genres")) {
if (g.has("name")) genres.add(g.get("name").asText());
}
entry.put("genres", genres);
}
// Extract developer and publisher
if (game.has("involved_companies") && game.get("involved_companies").isArray()) {
String developer = null;
String publisher = null;
@@ -148,104 +158,216 @@ 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);
entry.put("is_dos", platformIds.contains(13));
boolean isDos = platformIds.contains(13); // 13 = DOS
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")) {
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 {
JsonNode results = postAndGet("/covers", "fields url; where id = " + coverId + ";");
if (results == null || results.isEmpty()) return null;
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;
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);
}
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();
/**
* 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());
if (v.has("video_id")) {
videos.add(v.get("video_id").asText());
}
}
return videos;
}
private List<String> fetchScreenshots(int igdbId) throws Exception {
JsonNode results = postAndGet("/screenshots", "fields url; where game = " + igdbId + ";");
if (results == null) return List.of();
/**
* 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 fullUrl = "https:" + s.get("url").asText().replace("t_thumb", "t_1080p");
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-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) {
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> 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()); }
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) {
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 + ";";
JsonNode results = postAndGet("/games", apql);
if (results == null || results.isEmpty()) {
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()) {
throw new RuntimeException("IGDB: no game found with ID " + igdbId);
}
applyMatch(game, results.get(0));
try { List<String> v = fetchVideos(igdbId); if (!v.isEmpty()) game.setVideos(v); }
catch (Exception e) { LOG.warning("IGDB: failed to fetch videos for game " + igdbId + ": " + e.getMessage()); }
try { List<String> s = fetchScreenshots(igdbId); if (!s.isEmpty()) { game.setScreenshots(s); game.setHasScreenshots(true); } }
catch (Exception e) { LOG.warning("IGDB: failed to fetch screenshots for game " + igdbId + ": " + e.getMessage()); }
// 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());
}
}
/**
* 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;
@@ -256,26 +378,56 @@ public class IgdbService {
} else {
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"));
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"));
if (data.containsKey("igdb_id")) {
game.setIgdbId((Integer) data.get("igdb_id"));
}
// Download cover
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() + "'");
@@ -284,28 +436,6 @@ public class IgdbService {
}
}
/** Convert IGDB epoch (seconds) to year, from 'first_release_date' field. */
private static Optional<Integer> epochToYear(JsonNode node) {
if (node.has("first_release_date")) {
long epoch = node.get("first_release_date").asLong();
Calendar cal = Calendar.getInstance();
cal.setTimeInMillis(epoch * 1000);
return Optional.of(cal.get(Calendar.YEAR));
}
return Optional.empty();
}
/** Extract genre names from a JsonNode that may have a 'genres' array. */
private static List<String> extractGenres(JsonNode node) {
List<String> genres = new ArrayList<>();
if (node.has("genres") && node.get("genres").isArray()) {
for (JsonNode g : node.get("genres")) {
if (g.has("name")) genres.add(g.get("name").asText());
}
}
return genres;
}
private String sanitize(String s) {
return s.replace("\\", "\\\\").replace("\"", "\\\"");
}
@@ -314,12 +444,22 @@ 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() : "");
epochToYear(node).ifPresent(y -> map.put("year", y));
List<String> genres = extractGenres(node);
if (!genres.isEmpty()) {
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()) {
List<String> genres = new ArrayList<>();
for (JsonNode g : node.get("genres")) {
if (g.has("name")) genres.add(g.get("name").asText());
}
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")) {
@@ -331,12 +471,18 @@ 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")) {
JsonNode cover = node.get("cover");
try {
if (cover.has("url")) {
String coverUrl = "https:" + cover.get("url").asText().replace("t_thumb", COVER_SIZE);
String thumbUrl = cover.get("url").asText();
String coverUrl = "https:" + thumbUrl.replace("t_thumb", COVER_SIZE);
map.put("cover_url", coverUrl);
} else if (cover.isInt() || cover.isLong()) {
String coverUrl = fetchCoverUrl(cover.asInt());
@@ -347,6 +493,7 @@ public class IgdbService {
}
} catch (Exception ignored) {}
}
return map;
}
}
@@ -0,0 +1,67 @@
package com.dostalgia;
import jakarta.enterprise.context.ApplicationScoped;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.logging.Logger;
/**
* Detects platform type (DOS vs Windows) by reading executable headers.
* Pure logic — no I/O beyond reading the file header bytes.
*/
@ApplicationScoped
public class PlatformDetector {
private static final Logger LOG = Logger.getLogger(PlatformDetector.class.getName());
/**
* Detect whether an executable targets DOS or Windows by reading its PE/NE header.
* <ul>
* <li>Pure MZ, LE (DOS/4GW extender), LX (OS/2) → "dos"</li>
* <li>NE (Windows 3.x), PE (Win32) → "windows"</li>
* <li>Cannot read → null</li>
* </ul>
*/
public String detect(Path exePath) {
String name = exePath.getFileName().toString().toLowerCase();
if (name.endsWith(".com") || name.endsWith(".bat")) return "dos";
try (var fis = Files.newInputStream(exePath)) {
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";
} catch (IOException e) {
LOG.warning("Could not read EXE header for " + exePath + ": " + e.getMessage());
return null;
}
}
}
@@ -1,4 +1,4 @@
package org.dostalgia;
package com.dostalgia;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.PathParam;
@@ -20,40 +20,49 @@ public class StaticResource {
@ConfigProperty(name = "dostalgia.data.dir", defaultValue = "data")
String dataDir;
/** Serve a file from a subdirectory of the data dir with path traversal protection. */
private Response serveFile(Path base, String path, String cacheHeader) {
Path file = base.resolve(path).normalize();
@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();
if (!file.startsWith(base)) {
return Response.status(403).build();
}
if (!Files.exists(file) || Files.isDirectory(file)) {
return Response.status(404).build();
}
String contentType = guessContentType(file.getFileName().toString());
var builder = Response.ok(new FileStream(file)).type(contentType);
if (cacheHeader != null) {
builder.header(cacheHeader.split(":")[0], cacheHeader.split(":", 2)[1].trim());
}
return builder.build();
}
@GET
@jakarta.ws.rs.Path("/games/{path: .*}")
public Response getGameFile(@PathParam("path") String path) {
return serveFile(Path.of(dataDir, "games"), path, "Accept-Ranges: bytes");
String contentType = guessContentType(file.getFileName().toString());
return Response.ok(new FileStream(file))
.type(contentType)
.header("Accept-Ranges", "bytes")
.build();
}
@GET
@jakarta.ws.rs.Path("/artwork/{path: .*}")
public Response getArtwork(@PathParam("path") String path) {
return serveFile(Path.of(dataDir, "artwork"), path, "Cache-Control: public, max-age=86400");
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();
}
/** Health check */
@GET
@jakarta.ws.rs.Path("/api/health")
public Response health() {
return Response.ok(Map.of("status", "ok", "version", "0.1.1")).build();
return Response.ok(Map.of("status", "ok", "version", "0.1.0")).build();
}
private String guessContentType(String name) {
@@ -1,4 +1,4 @@
package org.dostalgia;
package com.dostalgia;
import jakarta.inject.Inject;
import jakarta.ws.rs.Consumes;
@@ -11,9 +11,12 @@ import org.jboss.resteasy.reactive.RestForm;
import org.jboss.resteasy.reactive.multipart.FileUpload;
import java.io.IOException;
import java.nio.file.FileVisitResult;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.SimpleFileVisitor;
import java.nio.file.StandardCopyOption;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.List;
import java.util.Map;
import java.util.logging.Logger;
@@ -136,8 +139,7 @@ public class UploadResource {
zip.createBundle(extractDir, mainExe, cdImages, bundlePath);
// Detect platform (DOS vs Windows)
Path mainExePath = mainExe != null ? Path.of(mainExe) : null;
String platformType = mainExe != null ? platform.detect(mainExePath) : "dos";
String platformType = mainExe != null ? platform.detect(Path.of(mainExe)) : "dos";
// Save metadata
Game game = new Game(gameId, title);
@@ -146,15 +148,14 @@ public class UploadResource {
// Store the selected executable and the full list for the UI picker
String relMain = mainExe != null
? extractDir.relativize(mainExePath).toString().replace('\\', '/')
? extractDir.relativize(Path.of(mainExe)).toString().replace('\\', '/')
: "";
game.setExecutable(relMain);
game.setExecutables(exeDetector.findAll(extractDir));
if (setupExe != null) {
Path setupExePath = Path.of(setupExe);
String setupPlatform = platform.detect(setupExePath);
String setupPlatform = platform.detect(Path.of(setupExe));
if (!"windows".equals(setupPlatform)) {
game.setSetupExe(extractDir.relativize(setupExePath).toString());
game.setSetupExe(extractDir.relativize(Path.of(setupExe)).toString());
}
}
game.setReady(true);
@@ -217,6 +218,18 @@ public class UploadResource {
}
private void deleteDir(Path dir) throws IOException {
FileUtils.deleteDirectory(dir);
if (!Files.exists(dir)) return;
Files.walkFileTree(dir, new SimpleFileVisitor<>() {
@Override
public FileVisitResult visitFile(Path f, BasicFileAttributes a) throws IOException {
Files.delete(f);
return FileVisitResult.CONTINUE;
}
@Override
public FileVisitResult postVisitDirectory(Path d, IOException e) throws IOException {
Files.delete(d);
return FileVisitResult.CONTINUE;
}
});
}
}
@@ -1,4 +1,4 @@
package org.dostalgia;
package com.dostalgia;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
@@ -13,8 +13,6 @@ 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.
@@ -27,6 +25,7 @@ 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"
);
@@ -51,13 +50,14 @@ 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;
if (singleDir != null) return; // multiple directories abort
singleDir = entry;
}
}
@@ -85,8 +85,10 @@ 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) {
@@ -98,7 +100,8 @@ public class ZipService {
Files.write(jsdos.resolve("jsdos.json"), config.buildCdOnlyJsdosJson(cdImages));
}
try (var zos = new ZipOutputStream(Files.newOutputStream(bundlePath))) {
// ZIP it up from extractDir
try (var zos = new java.util.zip.ZipOutputStream(Files.newOutputStream(bundlePath))) {
// Phase 1: Collect all directory paths
var allDirs = new TreeSet<String>();
@@ -113,16 +116,24 @@ public class ZipService {
});
}
// Phase 2: Write directory entries first
// Phase 2: Write directory entries first (sorted shallowest first)
for (String dir : allDirs) {
zos.putNextEntry(new ZipEntry(dir));
zos.putNextEntry(new java.util.zip.ZipEntry(dir));
zos.closeEntry();
}
// Phase 3: .jsdos config files (before game files)
// Phase 3: .jsdos config files (must come before game files)
try (var walk = Files.walk(jsdos)) {
walk.filter(Files::isRegularFile).sorted()
.forEach(f -> zipEntry(zos, extractDir, f));
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);
}
});
}
// Phase 4: Game files (excluding skipped extensions)
@@ -135,27 +146,22 @@ public class ZipService {
return dot < 0 || !SKIP_EXT.contains(n.substring(dot));
})
.sorted()
.forEach(f -> zipEntry(zos, extractDir, f));
.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);
}
});
}
}
// Clean up .jsdos config files from the extract dir
try (var cleanup = Files.walk(jsdos)) {
cleanup.sorted(Comparator.reverseOrder()).forEach(p -> {
try { Files.deleteIfExists(p); } catch (IOException ignored) {}
});
}
}
/** Write a single file into a ZIP output stream, computing entry name relative to extractDir. */
private static void zipEntry(ZipOutputStream zos, Path extractDir, Path file) {
try {
String entryName = extractDir.relativize(file).toString().replace('\\', '/');
zos.putNextEntry(new ZipEntry(entryName));
Files.copy(file, zos);
zos.closeEntry();
} catch (IOException e) {
throw new UncheckedIOException(e);
}
Files.walk(jsdos).sorted(Comparator.reverseOrder()).forEach(p -> {
try { Files.deleteIfExists(p); } catch (IOException ignored) {}
});
}
}
@@ -1,60 +0,0 @@
package org.dostalgia;
import jakarta.annotation.Nonnull;
import java.io.IOException;
import java.nio.file.FileVisitResult;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.SimpleFileVisitor;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
/** Shared file-system utilities. */
public final class FileUtils {
private FileUtils() {}
/** Recursively delete a directory tree (walk in reverse order so dirs are empty when deleted). */
public static void deleteDirectory(final Path dir) throws IOException {
if (!Files.exists(dir)) return;
Files.walkFileTree(dir, new SimpleFileVisitor<>() {
@Override
public @Nonnull FileVisitResult visitFile(@Nonnull final Path f, @Nonnull final BasicFileAttributes a) throws IOException {
Files.delete(f);
return FileVisitResult.CONTINUE;
}
@Override
public @Nonnull FileVisitResult postVisitDirectory(@Nonnull final Path d, final IOException e) throws IOException {
Files.delete(d);
return FileVisitResult.CONTINUE;
}
});
}
/**
* Walk a directory tree and collect relative paths of files whose extension matches
* one of the given extensions. Extensions should include the dot, e.g. {@code ".exe"}.
* Results are sorted alphabetically.
*/
public static List<String> findFilesByExtensions(Path dir, Set<String> extensions) throws IOException {
List<String> result = new ArrayList<>();
Files.walkFileTree(dir, new SimpleFileVisitor<>() {
@Override
public @Nonnull FileVisitResult visitFile(@Nonnull Path f, @Nonnull BasicFileAttributes a) {
String name = f.getFileName().toString().toLowerCase();
for (String ext : extensions) {
if (name.endsWith(ext)) {
result.add(dir.relativize(f).toString().replace('\\', '/'));
break;
}
}
return FileVisitResult.CONTINUE;
}
});
result.sort(String::compareTo);
return result;
}
}
@@ -1,65 +0,0 @@
package org.dostalgia;
import jakarta.enterprise.context.ApplicationScoped;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.logging.Logger;
/**
* Detects platform type (DOS vs Windows) by reading executable headers.
*/
@ApplicationScoped
public class PlatformDetector {
private static final Logger LOG = Logger.getLogger(PlatformDetector.class.getName());
public String detect(Path exePath) {
String name = exePath.getFileName().toString().toLowerCase();
if (name.endsWith(".com") || name.endsWith(".bat")) return "dos";
try (var fis = Files.newInputStream(exePath)) {
return detectFromBytes(readHeaderBytes(fis));
} catch (IOException e) {
LOG.warning("Could not read EXE header for " + exePath + ": " + e.getMessage());
return null;
}
}
/** Read enough PE header bytes from any InputStream (shared by Path and ZIP callers). */
static byte[] readHeaderBytes(InputStream is) throws IOException {
byte[] buf = new byte[0x1000];
int read = is.readNBytes(buf, 0, buf.length);
return read < buf.length ? java.util.Arrays.copyOf(buf, read) : buf;
}
/** Detect platform from pre-read MZ/PE header bytes. */
static String detectFromBytes(byte[] buf) {
return detectFromBytes(buf, buf.length);
}
/** Detect platform from pre-read MZ/PE header bytes with explicit read length. */
static String detectFromBytes(byte[] buf, int read) {
if (read < 2) return null;
if (buf[0] != 0x4D || buf[1] != 0x5A) return null;
if (read < 0x40) return "dos";
int peOffset = (buf[0x3C] & 0xFF)
| ((buf[0x3D] & 0xFF) << 8)
| ((buf[0x3E] & 0xFF) << 16)
| ((buf[0x3F] & 0xFF) << 24);
if (peOffset < 0 || peOffset + 2 > read) return "dos";
byte sig1 = buf[peOffset];
byte sig2 = buf[peOffset + 1];
if ((sig1 == 0x50 && sig2 == 0x45) // PE
|| (sig1 == 0x4E && sig2 == 0x45)) // NE
return "windows";
return "dos";
}
}
@@ -5,6 +5,7 @@ quarkus.http.cors=true
# Allow large game uploads (DOS games can be 500MB+)
quarkus.http.limits.max-body-size=2048M
quarkus.resteasy-reactive.multipart.input-part.max-size=2048M
# ─── Jackson: snake_case to match frontend expectations ─
quarkus.jackson.property-naming-strategy=SNAKE_CASE
@@ -1,4 +1,4 @@
package org.dostalgia;
package com.dostalgia;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
@@ -1,4 +1,4 @@
package org.dostalgia;
package com.dostalgia;
import org.junit.jupiter.api.Test;
import java.util.List;
@@ -1,4 +1,4 @@
package org.dostalgia;
package com.dostalgia;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
@@ -1,4 +1,4 @@
package org.dostalgia;
package com.dostalgia;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
@@ -9,7 +9,6 @@ import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -137,69 +136,6 @@ class ExecutableDetectorTest {
assertFalse(ExecutableDetector.isLikelySelfExtractor(f));
}
@Test
void findMain_prefersDosOverWindows(@TempDir Path dir) throws Exception {
// Windows PE executable: MZ header + PE signature at offset 0x80
byte[] windowsExe = new byte[0x100];
windowsExe[0] = 0x4D; windowsExe[1] = 0x5A; // MZ
windowsExe[0x3C] = (byte) 0x80; // PE offset at 0x80
windowsExe[0x80] = 0x50; windowsExe[0x81] = 0x45; // "PE" signature
Files.write(dir.resolve("WIN.EXE"), windowsExe);
// DOS executable: just MZ header, no PE
byte[] dosExe = new byte[0x40];
dosExe[0] = 0x4D; dosExe[1] = 0x5A; // MZ
Files.write(dir.resolve("DOS.EXE"), dosExe);
String main = detector.findMain(dir);
assertNotNull(main);
assertTrue(main.endsWith("DOS.EXE"),
"DOS executable should be preferred over Windows, but got: " + main);
}
@Test
void findMain_returnsNullForNoExecutables(@TempDir Path dir) throws Exception {
Files.writeString(dir.resolve("readme.txt"), "text");
Files.writeString(dir.resolve("notes.md"), "markdown");
assertNull(detector.findMain(dir));
}
@Test
void findMain_skipsSelfExtractorsWithPkSignature(@TempDir Path dir) throws Exception {
// Self-extractor with PK\x03\x04 signature bytes (ZIP local header)
byte[] sfx = new byte[0x80];
sfx[0] = 0x4D; sfx[1] = 0x5A; // MZ header
sfx[0x60] = 0x50; sfx[0x61] = 0x4B; sfx[0x62] = 0x03; sfx[0x63] = 0x04; // PK\x03\x04
Files.write(dir.resolve("SFX.EXE"), sfx);
// Real game executable
Files.write(dir.resolve("GAME.EXE"), createMZ(2000));
String main = detector.findMain(dir);
assertNotNull(main);
assertTrue(main.endsWith("GAME.EXE"),
"Self-extractor with PK signature bytes should be skipped, but got: " + main);
}
@Test
void findMain_handlesMixedContent(@TempDir Path dir) throws Exception {
Files.writeString(dir.resolve("readme.txt"), "text");
Files.write(dir.resolve("DOS4GW.EXE"), createMZ(200)); // extender, skipped
Files.write(dir.resolve("INSTALL.EXE"), createMZ(150)); // installer, deprioritized
Files.write(dir.resolve("GAME.EXE"), createMZ(5000)); // real game, should win
Files.write(dir.resolve("helper.com"), createMZ(100)); // small .com
String main = detector.findMain(dir);
assertNotNull(main);
assertTrue(main.endsWith("GAME.EXE"),
"Main game executable should be selected, but got: " + main);
}
@Test
void findAll_returnsEmptyForEmptyDir(@TempDir Path dir) throws Exception {
assertTrue(detector.findAll(dir).isEmpty());
}
private static byte[] createMZ(int size) {
byte[] buf = new byte[size];
buf[0] = 0x4D; // M
@@ -1,4 +1,4 @@
package org.dostalgia;
package com.dostalgia;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
@@ -1,94 +0,0 @@
package org.dostalgia;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.*;
class FileUtilsTest {
@Test
void deleteDirectory_nonExistentDir_doesNotThrow(@TempDir Path dir) {
Path nonExistent = dir.resolve("doesnotexist");
assertDoesNotThrow(() -> FileUtils.deleteDirectory(nonExistent));
}
@Test
void deleteDirectory_deletesFilesRecursively(@TempDir Path dir) throws Exception {
Files.writeString(dir.resolve("file1.txt"), "hello");
Files.writeString(dir.resolve("file2.txt"), "world");
Path sub = Files.createDirectories(dir.resolve("sub"));
Files.writeString(sub.resolve("nested.txt"), "deep");
assertTrue(Files.exists(dir.resolve("file1.txt")));
FileUtils.deleteDirectory(dir);
assertFalse(Files.exists(dir));
}
@Test
void deleteDirectory_deletesSubdirectories(@TempDir Path dir) throws Exception {
Path a = Files.createDirectories(dir.resolve("a/b/c"));
Files.writeString(a.resolve("deep.txt"), "deep");
Path sub2 = Files.createDirectories(dir.resolve("sub2"));
Files.writeString(sub2.resolve("file.txt"), "data");
FileUtils.deleteDirectory(dir);
assertFalse(Files.exists(dir));
}
@Test
void findFilesByExtensions_findsExeComBat(@TempDir Path dir) throws Exception {
Files.writeString(dir.resolve("game.exe"), "data");
Files.writeString(dir.resolve("helper.com"), "data");
Files.writeString(dir.resolve("run.bat"), "data");
Files.writeString(dir.resolve("readme.txt"), "data");
List<String> result = FileUtils.findFilesByExtensions(dir, Set.of(".exe", ".com", ".bat"));
assertEquals(3, result.size());
assertTrue(result.contains("game.exe"));
assertTrue(result.contains("helper.com"));
assertTrue(result.contains("run.bat"));
}
@Test
void findFilesByExtensions_caseInsensitive(@TempDir Path dir) throws Exception {
Files.writeString(dir.resolve("GAME.EXE"), "data");
Files.writeString(dir.resolve("Helper.Com"), "data");
Files.writeString(dir.resolve("RUN.BAT"), "data");
List<String> result = FileUtils.findFilesByExtensions(dir, Set.of(".exe", ".com", ".bat"));
assertEquals(3, result.size());
}
@Test
void findFilesByExtensions_emptyDir_returnsEmpty(@TempDir Path dir) throws Exception {
List<String> result = FileUtils.findFilesByExtensions(dir, Set.of(".exe"));
assertTrue(result.isEmpty());
}
@Test
void findFilesByExtensions_noMatches_returnsEmpty(@TempDir Path dir) throws Exception {
Files.writeString(dir.resolve("readme.txt"), "data");
Files.writeString(dir.resolve("notes.md"), "data");
List<String> result = FileUtils.findFilesByExtensions(dir, Set.of(".exe", ".com", ".bat"));
assertTrue(result.isEmpty());
}
@Test
void findFilesByExtensions_includesSubdirectories(@TempDir Path dir) throws Exception {
Path sub = Files.createDirectories(dir.resolve("subdir"));
Files.writeString(sub.resolve("game.exe"), "data");
Files.writeString(dir.resolve("root.exe"), "data");
List<String> result = FileUtils.findFilesByExtensions(dir, Set.of(".exe"));
assertEquals(2, result.size());
assertTrue(result.contains("root.exe"));
assertTrue(result.contains("subdir/game.exe"));
}
}
@@ -1,291 +0,0 @@
package org.dostalgia;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.PropertyNamingStrategies;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Files;
import java.nio.file.NoSuchFileException;
import java.nio.file.Path;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
class GameServiceTest {
private static final ObjectMapper MAPPER = new ObjectMapper()
.registerModule(new JavaTimeModule())
.setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE)
.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
.enable(SerializationFeature.INDENT_OUTPUT);
// -- sanitizeId tests --
@Test
void sanitizeId_normalString_kept() {
assertEquals("hello", GameService.sanitizeId("hello"));
}
@Test
void sanitizeId_uppercaseToLowercase() {
assertEquals("hello", GameService.sanitizeId("HELLO"));
}
@Test
void sanitizeId_spacesToHyphens() {
assertEquals("hello-world", GameService.sanitizeId("hello world"));
}
@Test
void sanitizeId_specialCharsStripped() {
assertEquals("helloworld", GameService.sanitizeId("hello!@#$world"));
}
@Test
void sanitizeId_tripleHyphenStripped() {
assertEquals("test", GameService.sanitizeId("---test---"));
}
@Test
void sanitizeId_emptyReturnsUnknown() {
assertEquals("unknown", GameService.sanitizeId(""));
}
@Test
void sanitizeId_onlySpecialCharsReturnsUnknown() {
assertEquals("unknown", GameService.sanitizeId("!@#$%^&*()"));
}
@Test
void sanitizeId_mixedCaseAndSpaces() {
assertEquals("doom-ii-hell-on-earth", GameService.sanitizeId("DOOM II: Hell on Earth"));
}
@Test
void sanitizeId_underscoresToHyphens() {
assertEquals("my-game", GameService.sanitizeId("my_game"));
}
@Test
void sanitizeId_numbersPreserved() {
assertEquals("game2", GameService.sanitizeId("Game2"));
}
// -- gameDir / gameJsonPath tests --
@Test
void gameDir_returnsCorrectPath(@TempDir Path tempDir) throws Exception {
GameService svc = new GameService();
svc.dataDir = tempDir.toString();
svc.init();
Path dir = svc.gameDir("test-game");
assertEquals(tempDir.resolve("games/test-game"), dir);
}
@Test
void gameJsonPath_returnsCorrectPath(@TempDir Path tempDir) throws Exception {
GameService svc = new GameService();
svc.dataDir = tempDir.toString();
svc.init();
Path path = svc.gameJsonPath("test-game");
assertEquals(tempDir.resolve("games/test-game/game.json"), path);
}
// -- init tests --
@Test
void init_createsDirectories(@TempDir Path tempDir) throws Exception {
GameService svc = new GameService();
svc.dataDir = tempDir.toString();
svc.init();
assertTrue(Files.isDirectory(tempDir.resolve("games")));
assertTrue(Files.isDirectory(tempDir.resolve("artwork")));
assertTrue(Files.isDirectory(tempDir.resolve("saves")));
}
// -- save / load tests --
@Test
void saveAndLoad_roundTrip(@TempDir Path tempDir) throws Exception {
GameService svc = new GameService();
svc.dataDir = tempDir.toString();
svc.init();
Game game = new Game("test-game", "Test Game");
game.setYear(1995);
game.setGenre("FPS");
svc.save(game);
Game loaded = svc.load("test-game");
assertEquals("test-game", loaded.getId());
assertEquals("Test Game", loaded.getTitle());
assertEquals(1995, loaded.getYear());
assertEquals("FPS", loaded.getGenre());
assertNotNull(loaded.getCreatedAt());
assertNotNull(loaded.getUpdatedAt());
}
@Test
void save_updatesUpdatedAt(@TempDir Path tempDir) throws Exception {
GameService svc = new GameService();
svc.dataDir = tempDir.toString();
svc.init();
Game game = new Game("game1", "Game One");
svc.save(game);
Thread.sleep(10); // ensure timestamp advances
game.setGenre("Adventure");
svc.save(game);
Game loaded = svc.load("game1");
assertNotNull(loaded.getUpdatedAt());
assertTrue(loaded.getUpdatedAt().isAfter(loaded.getCreatedAt())
|| loaded.getUpdatedAt().equals(loaded.getCreatedAt()),
"updatedAt should be >= createdAt");
}
@Test
void load_nonExistent_throwsNoSuchFileException(@TempDir Path tempDir) throws Exception {
GameService svc = new GameService();
svc.dataDir = tempDir.toString();
svc.init();
assertThrows(NoSuchFileException.class, () -> svc.load("nonexistent"));
}
// -- list tests --
@Test
void list_emptyDir_returnsEmpty(@TempDir Path tempDir) throws Exception {
GameService svc = new GameService();
svc.dataDir = tempDir.toString();
svc.init();
List<Game> games = svc.list();
assertTrue(games.isEmpty());
}
@Test
void saveAndList_returnsGame(@TempDir Path tempDir) throws Exception {
GameService svc = new GameService();
svc.dataDir = tempDir.toString();
svc.init();
svc.save(new Game("game1", "Game One"));
svc.save(new Game("game2", "Game Two"));
List<Game> games = svc.list();
assertEquals(2, games.size());
}
@Test
void list_sortsByTitle(@TempDir Path tempDir) throws Exception {
GameService svc = new GameService();
svc.dataDir = tempDir.toString();
svc.init();
svc.save(new Game("zzz", "Z Game"));
svc.save(new Game("aaa", "A Game"));
List<Game> games = svc.list();
assertEquals(2, games.size());
assertEquals("A Game", games.get(0).getTitle());
assertEquals("Z Game", games.get(1).getTitle());
}
// -- delete tests --
@Test
void delete_removesGameDir(@TempDir Path tempDir) throws Exception {
GameService svc = new GameService();
svc.dataDir = tempDir.toString();
svc.init();
svc.save(new Game("game1", "Game One"));
assertTrue(Files.exists(svc.gameDir("game1")));
svc.delete("game1");
assertFalse(Files.exists(svc.gameDir("game1")));
}
@Test
void delete_nonExistent_doesNotThrow(@TempDir Path tempDir) throws Exception {
GameService svc = new GameService();
svc.dataDir = tempDir.toString();
svc.init();
assertDoesNotThrow(() -> svc.delete("nonexistent"));
}
@Test
void delete_removesOldJsdosFiles(@TempDir Path tempDir) throws Exception {
GameService svc = new GameService();
svc.dataDir = tempDir.toString();
svc.init();
// Create backward-compat files that delete should clean up
Files.writeString(tempDir.resolve("games/game1.jsdos"), "old");
Files.writeString(tempDir.resolve("games/game1.setup.jsdos"), "old-setup");
svc.delete("game1");
assertFalse(Files.exists(tempDir.resolve("games/game1.jsdos")));
assertFalse(Files.exists(tempDir.resolve("games/game1.setup.jsdos")));
}
// -- JSON serialization tests --
@Test
void gameSerialization_snakeCase(@TempDir Path tempDir) throws Exception {
Game g = new Game("my-game", "My Game");
g.setIgdbCoverId("abc123");
g.setHasCover(true);
g.setHasScreenshots(false);
g.setIgdbId(42);
g.setBundleSize(1024L);
g.setSetupExe("setup.exe");
String json = MAPPER.writeValueAsString(g);
assertTrue(json.contains("\"igdb_cover_id\""));
assertTrue(json.contains("\"has_cover\""));
assertTrue(json.contains("\"has_screenshots\""));
assertTrue(json.contains("\"bundle_size\""));
assertTrue(json.contains("\"setup_exe\""));
assertFalse(json.contains("igdbCoverId"));
assertFalse(json.contains("hasCover"));
assertFalse(json.contains("hasScreenshots"));
assertFalse(json.contains("bundleSize"));
assertFalse(json.contains("setupExe"));
Game deserialized = MAPPER.readValue(json, Game.class);
assertEquals("my-game", deserialized.getId());
assertEquals("My Game", deserialized.getTitle());
assertEquals("abc123", deserialized.getIgdbCoverId());
assertTrue(deserialized.isHasCover());
assertFalse(deserialized.isHasScreenshots());
assertEquals(Integer.valueOf(42), deserialized.getIgdbId());
assertEquals(Long.valueOf(1024L), deserialized.getBundleSize());
assertEquals("setup.exe", deserialized.getSetupExe());
}
@Test
void gameSerialization_timestamps(@TempDir Path tempDir) throws Exception {
Game g = new Game("ts-game", "Timestamp Test");
String json = MAPPER.writeValueAsString(g);
assertTrue(json.contains("\"created_at\""));
assertTrue(json.contains("\"updated_at\""));
Game deserialized = MAPPER.readValue(json, Game.class);
assertNotNull(deserialized.getCreatedAt());
assertNotNull(deserialized.getUpdatedAt());
}
}
-154
View File
@@ -1,154 +0,0 @@
package org.dostalgia;
import org.junit.jupiter.api.Test;
import java.time.Instant;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
class GameTest {
@Test
void noArgConstructor_setsNothing() {
Game g = new Game();
assertNull(g.getId());
assertNull(g.getTitle());
assertNull(g.getYear());
assertNull(g.getGenre());
assertNull(g.getDeveloper());
assertNull(g.getPublisher());
assertNull(g.getDescription());
assertNull(g.getRating());
assertNull(g.getBundleFile());
assertNull(g.getSetupExe());
assertNull(g.getBundleSize());
assertNull(g.getExecutable());
assertNull(g.getExecutables());
assertNull(g.getPlatform());
assertNull(g.getIgdbId());
assertNull(g.getIgdbCoverId());
assertFalse(g.isHasCover());
assertFalse(g.isHasScreenshots());
assertNull(g.getScreenshots());
assertNull(g.getVideos());
assertFalse(g.isReady());
assertNull(g.getCreatedAt());
assertNull(g.getUpdatedAt());
}
@Test
void twoArgConstructor_setsIdTitleAndTimestamps() {
Game g = new Game("test-id", "Test Game");
assertEquals("test-id", g.getId());
assertEquals("Test Game", g.getTitle());
assertFalse(g.isReady());
assertNotNull(g.getCreatedAt());
assertNotNull(g.getUpdatedAt());
}
@Test
void settersAndGetters_roundTrip() {
Game g = new Game();
g.setId("id1");
g.setTitle("Title");
g.setYear(1995);
g.setGenre("Action");
g.setDeveloper("DevCo");
g.setPublisher("PubCo");
g.setDescription("A great game");
g.setRating(4.5);
g.setBundleFile("game.zip");
g.setSetupExe("setup.exe");
g.setBundleSize(12345L);
g.setExecutable("game.exe");
g.setExecutables(List.of("game.exe", "setup.exe"));
g.setPlatform("dos");
g.setIgdbId(42);
g.setIgdbCoverId("abc123");
g.setHasCover(true);
g.setHasScreenshots(true);
g.setScreenshots(List.of("shot1.png"));
g.setVideos(List.of("vid1"));
g.setReady(true);
Instant now = Instant.now();
g.setCreatedAt(now);
g.setUpdatedAt(now);
assertEquals("id1", g.getId());
assertEquals("Title", g.getTitle());
assertEquals(1995, g.getYear());
assertEquals("Action", g.getGenre());
assertEquals("DevCo", g.getDeveloper());
assertEquals("PubCo", g.getPublisher());
assertEquals("A great game", g.getDescription());
assertEquals(4.5, g.getRating());
assertEquals("game.zip", g.getBundleFile());
assertEquals("setup.exe", g.getSetupExe());
assertEquals(12345L, g.getBundleSize());
assertEquals("game.exe", g.getExecutable());
assertEquals(List.of("game.exe", "setup.exe"), g.getExecutables());
assertEquals("dos", g.getPlatform());
assertEquals(42, g.getIgdbId());
assertEquals("abc123", g.getIgdbCoverId());
assertTrue(g.isHasCover());
assertTrue(g.isHasScreenshots());
assertEquals(List.of("shot1.png"), g.getScreenshots());
assertEquals(List.of("vid1"), g.getVideos());
assertTrue(g.isReady());
assertEquals(now, g.getCreatedAt());
assertEquals(now, g.getUpdatedAt());
}
@Test
void isHasSetup_nullSetupExe_returnsFalse() {
Game g = new Game();
g.setSetupExe(null);
assertFalse(g.isHasSetup());
}
@Test
void isHasSetup_blankSetupExe_returnsFalse() {
Game g = new Game();
g.setSetupExe(" ");
assertFalse(g.isHasSetup());
}
@Test
void isHasSetup_nonBlankSetupExe_returnsTrue() {
Game g = new Game();
g.setSetupExe("setup.exe");
assertTrue(g.isHasSetup());
}
@Test
void isPlayable_notReady_returnsFalse() {
Game g = new Game();
g.setReady(false);
assertFalse(g.isPlayable());
}
@Test
void isPlayable_readyNullPlatform_returnsTrue() {
Game g = new Game();
g.setReady(true);
g.setPlatform(null);
assertTrue(g.isPlayable());
}
@Test
void isPlayable_readyDosPlatform_returnsTrue() {
Game g = new Game();
g.setReady(true);
g.setPlatform("dos");
assertTrue(g.isPlayable());
}
@Test
void isPlayable_readyWindowsPlatform_returnsFalse() {
Game g = new Game();
g.setReady(true);
g.setPlatform("windows");
assertFalse(g.isPlayable());
}
}
@@ -1,265 +0,0 @@
package org.dostalgia;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.lang.reflect.Method;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
class IgdbServiceTest {
private IgdbService service;
private ObjectMapper mapper;
private Method epochToYearMethod;
private Method extractGenresMethod;
private Method sanitizeMethod;
private Method jsonNodeToMapMethod;
@BeforeEach
@SuppressWarnings("unchecked")
void setUp() throws Exception {
service = new IgdbService();
service.clientId = Optional.of("test-client-id");
service.clientSecret = Optional.of("test-client-secret");
mapper = new ObjectMapper();
epochToYearMethod = IgdbService.class.getDeclaredMethod("epochToYear", JsonNode.class);
epochToYearMethod.setAccessible(true);
extractGenresMethod = IgdbService.class.getDeclaredMethod("extractGenres", JsonNode.class);
extractGenresMethod.setAccessible(true);
sanitizeMethod = IgdbService.class.getDeclaredMethod("sanitize", String.class);
sanitizeMethod.setAccessible(true);
jsonNodeToMapMethod = IgdbService.class.getDeclaredMethod("jsonNodeToMap", JsonNode.class);
jsonNodeToMapMethod.setAccessible(true);
}
// -- isConfigured tests --
@Test
void isConfigured_returnsTrueWhenBothPresentAndNonBlank() {
assertTrue(service.isConfigured());
}
@Test
void isConfigured_returnsFalseWhenClientIdEmpty() {
service.clientId = Optional.empty();
assertFalse(service.isConfigured());
}
@Test
void isConfigured_returnsFalseWhenClientSecretEmpty() {
service.clientSecret = Optional.empty();
assertFalse(service.isConfigured());
}
@Test
void isConfigured_returnsFalseWhenClientIdBlank() {
service.clientId = Optional.of("");
assertFalse(service.isConfigured());
}
@Test
void isConfigured_returnsFalseWhenClientSecretBlank() {
service.clientSecret = Optional.of("");
assertFalse(service.isConfigured());
}
// -- epochToYear tests --
@Test
void epochToYear_withReleaseDate_returnsYear() throws Exception {
// 946684800 = 2000-01-01T00:00:00Z
JsonNode node = mapper.readTree("{\"first_release_date\": 946684800}");
Optional<Integer> year = (Optional<Integer>) epochToYearMethod.invoke(null, node);
assertTrue(year.isPresent());
int y = year.get();
assertTrue(y >= 1999 && y <= 2001, "Year should be around 2000 but was " + y);
}
@Test
void epochToYear_withoutReleaseDate_returnsEmpty() throws Exception {
JsonNode node = mapper.readTree("{\"name\": \"Test Game\"}");
Optional<Integer> year = (Optional<Integer>) epochToYearMethod.invoke(null, node);
assertFalse(year.isPresent());
}
@Test
void epochToYear_withEpochZero_returnsYear() throws Exception {
// epoch 0 = 1970-01-01T00:00:00Z
JsonNode node = mapper.readTree("{\"first_release_date\": 0}");
Optional<Integer> year = (Optional<Integer>) epochToYearMethod.invoke(null, node);
assertTrue(year.isPresent());
assertEquals(1970, year.get());
}
@Test
void epochToYear_negativeEpoch_returnsYear() throws Exception {
// negative epoch is before 1970
JsonNode node = mapper.readTree("{\"first_release_date\": -315619200}");
Optional<Integer> year = (Optional<Integer>) epochToYearMethod.invoke(null, node);
assertTrue(year.isPresent());
// Calendar may handle negative values differently; just verify it returns something
assertNotNull(year.get());
}
// -- extractGenres tests --
@Test
void extractGenres_withGenresArray_returnsList() throws Exception {
JsonNode node = mapper.readTree("{\"genres\": [{\"name\": \"Action\"}, {\"name\": \"Adventure\"}]}");
List<String> genres = (List<String>) extractGenresMethod.invoke(null, node);
assertEquals(2, genres.size());
assertTrue(genres.contains("Action"));
assertTrue(genres.contains("Adventure"));
}
@Test
void extractGenres_emptyArray_returnsEmptyList() throws Exception {
JsonNode node = mapper.readTree("{\"genres\": []}");
List<String> genres = (List<String>) extractGenresMethod.invoke(null, node);
assertTrue(genres.isEmpty());
}
@Test
void extractGenres_missingField_returnsEmptyList() throws Exception {
JsonNode node = mapper.readTree("{\"name\": \"Test\"}");
List<String> genres = (List<String>) extractGenresMethod.invoke(null, node);
assertTrue(genres.isEmpty());
}
@Test
void extractGenres_genreWithoutName_skipsEntry() throws Exception {
JsonNode node = mapper.readTree("{\"genres\": [{\"name\": \"Action\"}, {\"id\": 5}]}");
List<String> genres = (List<String>) extractGenresMethod.invoke(null, node);
assertEquals(1, genres.size());
assertEquals("Action", genres.get(0));
}
// -- sanitize tests --
@Test
void sanitize_escapesBackslashes() throws Exception {
String result = (String) sanitizeMethod.invoke(service, "path\\to\\file");
assertEquals("path\\\\to\\\\file", result);
}
@Test
void sanitize_escapesQuotes() throws Exception {
String result = (String) sanitizeMethod.invoke(service, "say \"hello\"");
assertEquals("say \\\"hello\\\"", result);
}
@Test
void sanitize_escapesBothBackslashesAndQuotes() throws Exception {
String result = (String) sanitizeMethod.invoke(service, "\\\"mixed\\\"");
assertEquals("\\\\\\\"mixed\\\\\\\"", result);
}
@Test
void sanitize_normalString_unchanged() throws Exception {
String result = (String) sanitizeMethod.invoke(service, "hello world");
assertEquals("hello world", result);
}
@Test
void sanitize_emptyString_unchanged() throws Exception {
String result = (String) sanitizeMethod.invoke(service, "");
assertEquals("", result);
}
// -- jsonNodeToMap tests --
@Test
void jsonNodeToMap_basicFields() throws Exception {
JsonNode node = mapper.readTree("{\"id\": 123, \"name\": \"Test Game\"}");
Map<String, Object> map = (Map<String, Object>) jsonNodeToMapMethod.invoke(service, node);
assertEquals(123, map.get("igdb_id"));
assertEquals("Test Game", map.get("name"));
}
@Test
void jsonNodeToMap_withGenres() throws Exception {
JsonNode node = mapper.readTree("{\"id\": 1, \"name\": \"G\", \"genres\": [{\"name\": \"RPG\"}]}");
Map<String, Object> map = (Map<String, Object>) jsonNodeToMapMethod.invoke(service, node);
assertTrue(map.containsKey("genres"));
assertEquals(List.of("RPG"), map.get("genres"));
}
@Test
void jsonNodeToMap_withInvolvedCompanies() throws Exception {
JsonNode node = mapper.readTree("""
{
"id": 1,
"name": "G",
"involved_companies": [
{ "company": { "name": "DevStudio" }, "developer": true, "publisher": false },
{ "company": { "name": "PubStudio" }, "developer": false, "publisher": true }
]
}
""");
Map<String, Object> map = (Map<String, Object>) jsonNodeToMapMethod.invoke(service, node);
assertEquals("DevStudio", map.get("developer"));
assertEquals("PubStudio", map.get("publisher"));
}
@Test
void jsonNodeToMap_withSummary() throws Exception {
JsonNode node = mapper.readTree("{\"id\": 1, \"name\": \"G\", \"summary\": \"A great game\"}");
Map<String, Object> map = (Map<String, Object>) jsonNodeToMapMethod.invoke(service, node);
assertEquals("A great game", map.get("summary"));
}
@Test
void jsonNodeToMap_withCoverUrl() throws Exception {
JsonNode node = mapper.readTree("{\"id\": 1, \"name\": \"G\", \"cover\": { \"url\": \"//images.igdb.com/igdb/image/upload/t_thumb/abc.jpg\" }}");
Map<String, Object> map = (Map<String, Object>) jsonNodeToMapMethod.invoke(service, node);
String coverUrl = (String) map.get("cover_url");
assertNotNull(coverUrl);
assertTrue(coverUrl.startsWith("https:"));
assertTrue(coverUrl.contains("t_cover_big"));
}
@Test
void jsonNodeToMap_emptyObject_returnsDefaults() throws Exception {
JsonNode node = mapper.readTree("{}");
Map<String, Object> map = (Map<String, Object>) jsonNodeToMapMethod.invoke(service, node);
assertEquals(0, map.get("igdb_id"));
assertEquals("", map.get("name"));
}
@Test
void jsonNodeToMap_noInvolvedCompanies_skipsDevPub() throws Exception {
JsonNode node = mapper.readTree("{\"id\": 1, \"name\": \"G\"}");
Map<String, Object> map = (Map<String, Object>) jsonNodeToMapMethod.invoke(service, node);
assertFalse(map.containsKey("developer"));
assertFalse(map.containsKey("publisher"));
}
@Test
void jsonNodeToMap_firstDevAndPubOnly() throws Exception {
// Multiple developers/publishers — only first of each should be recorded
JsonNode node = mapper.readTree("""
{
"id": 1, "name": "G",
"involved_companies": [
{ "company": { "name": "FirstDev" }, "developer": true, "publisher": false },
{ "company": { "name": "SecondDev" }, "developer": true, "publisher": false },
{ "company": { "name": "FirstPub" }, "developer": false, "publisher": true }
]
}
""");
Map<String, Object> map = (Map<String, Object>) jsonNodeToMapMethod.invoke(service, node);
assertEquals("FirstDev", map.get("developer"));
assertEquals("FirstPub", map.get("publisher"));
}
}
@@ -1,198 +0,0 @@
package org.dostalgia;
import jakarta.ws.rs.core.Response;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
class StaticResourceTest {
@Test
void health_returnsOkStatusAndVersion(@TempDir Path tempDir) {
StaticResource sr = new StaticResource();
sr.dataDir = tempDir.toString();
Response resp = sr.health();
assertEquals(200, resp.getStatus());
Object entity = resp.getEntity();
assertInstanceOf(Map.class, entity);
Map<?, ?> map = (Map<?, ?>) entity;
assertEquals("ok", map.get("status"));
assertEquals("0.1.1", map.get("version"));
}
@Test
void getGameFile_jsdosContentType(@TempDir Path tempDir) throws Exception {
Path gamesDir = Files.createDirectories(tempDir.resolve("games"));
Files.writeString(gamesDir.resolve("test.jsdos"), "zip content");
StaticResource sr = new StaticResource();
sr.dataDir = tempDir.toString();
Response resp = sr.getGameFile("test.jsdos");
assertEquals(200, resp.getStatus());
assertNotNull(resp.getMediaType());
assertEquals("application/zip", resp.getMediaType().toString());
}
@Test
void getGameFile_zipContentType(@TempDir Path tempDir) throws Exception {
Path gamesDir = Files.createDirectories(tempDir.resolve("games"));
Files.writeString(gamesDir.resolve("archive.zip"), "zip content");
StaticResource sr = new StaticResource();
sr.dataDir = tempDir.toString();
Response resp = sr.getGameFile("archive.zip");
assertEquals(200, resp.getStatus());
assertNotNull(resp.getMediaType());
assertEquals("application/zip", resp.getMediaType().toString());
}
@Test
void getGameFile_jpgContentType(@TempDir Path tempDir) throws Exception {
Path gamesDir = Files.createDirectories(tempDir.resolve("games"));
Files.writeString(gamesDir.resolve("cover.jpg"), "image data");
StaticResource sr = new StaticResource();
sr.dataDir = tempDir.toString();
Response resp = sr.getGameFile("cover.jpg");
assertEquals(200, resp.getStatus());
assertNotNull(resp.getMediaType());
assertEquals("image/jpeg", resp.getMediaType().toString());
}
@Test
void getGameFile_jpegContentType(@TempDir Path tempDir) throws Exception {
Path gamesDir = Files.createDirectories(tempDir.resolve("games"));
Files.writeString(gamesDir.resolve("photo.jpeg"), "image data");
StaticResource sr = new StaticResource();
sr.dataDir = tempDir.toString();
Response resp = sr.getGameFile("photo.jpeg");
assertEquals(200, resp.getStatus());
assertNotNull(resp.getMediaType());
assertEquals("image/jpeg", resp.getMediaType().toString());
}
@Test
void getGameFile_pngContentType(@TempDir Path tempDir) throws Exception {
Path gamesDir = Files.createDirectories(tempDir.resolve("games"));
Files.writeString(gamesDir.resolve("screenshot.png"), "png data");
StaticResource sr = new StaticResource();
sr.dataDir = tempDir.toString();
Response resp = sr.getGameFile("screenshot.png");
assertEquals(200, resp.getStatus());
assertNotNull(resp.getMediaType());
assertEquals("image/png", resp.getMediaType().toString());
}
@Test
void getGameFile_webpContentType(@TempDir Path tempDir) throws Exception {
Path gamesDir = Files.createDirectories(tempDir.resolve("games"));
Files.writeString(gamesDir.resolve("image.webp"), "webp data");
StaticResource sr = new StaticResource();
sr.dataDir = tempDir.toString();
Response resp = sr.getGameFile("image.webp");
assertEquals(200, resp.getStatus());
assertNotNull(resp.getMediaType());
assertEquals("image/webp", resp.getMediaType().toString());
}
@Test
void getGameFile_gifContentType(@TempDir Path tempDir) throws Exception {
Path gamesDir = Files.createDirectories(tempDir.resolve("games"));
Files.writeString(gamesDir.resolve("anim.gif"), "gif data");
StaticResource sr = new StaticResource();
sr.dataDir = tempDir.toString();
Response resp = sr.getGameFile("anim.gif");
assertEquals(200, resp.getStatus());
assertNotNull(resp.getMediaType());
assertEquals("image/gif", resp.getMediaType().toString());
}
@Test
void getGameFile_jsonContentType(@TempDir Path tempDir) throws Exception {
Path gamesDir = Files.createDirectories(tempDir.resolve("games"));
Files.writeString(gamesDir.resolve("data.json"), "{}");
StaticResource sr = new StaticResource();
sr.dataDir = tempDir.toString();
Response resp = sr.getGameFile("data.json");
assertEquals(200, resp.getStatus());
assertNotNull(resp.getMediaType());
assertEquals("application/json", resp.getMediaType().toString());
}
@Test
void getGameFile_exeContentType(@TempDir Path tempDir) throws Exception {
Path gamesDir = Files.createDirectories(tempDir.resolve("games"));
Files.writeString(gamesDir.resolve("game.exe"), "MZ");
StaticResource sr = new StaticResource();
sr.dataDir = tempDir.toString();
Response resp = sr.getGameFile("game.exe");
assertEquals(200, resp.getStatus());
assertNotNull(resp.getMediaType());
assertEquals("application/octet-stream", resp.getMediaType().toString());
}
@Test
void getGameFile_pathTraversal_returns403(@TempDir Path tempDir) {
StaticResource sr = new StaticResource();
sr.dataDir = tempDir.toString();
Response resp = sr.getGameFile("../etc/passwd");
assertEquals(403, resp.getStatus());
}
@Test
void getGameFile_nonExistentFile_returns404(@TempDir Path tempDir) throws Exception {
Files.createDirectories(tempDir.resolve("games"));
StaticResource sr = new StaticResource();
sr.dataDir = tempDir.toString();
Response resp = sr.getGameFile("nonexistent.exe");
assertEquals(404, resp.getStatus());
}
@Test
void getArtwork_contentTypeAndCacheHeader(@TempDir Path tempDir) throws Exception {
Path artworkDir = Files.createDirectories(tempDir.resolve("artwork"));
Files.writeString(artworkDir.resolve("cover.jpg"), "image data");
StaticResource sr = new StaticResource();
sr.dataDir = tempDir.toString();
Response resp = sr.getArtwork("cover.jpg");
assertEquals(200, resp.getStatus());
assertNotNull(resp.getMediaType());
assertEquals("image/jpeg", resp.getMediaType().toString());
assertTrue(resp.getHeaders().containsKey("Cache-Control"));
}
@Test
void getArtwork_pathTraversal_returns403(@TempDir Path tempDir) {
StaticResource sr = new StaticResource();
sr.dataDir = tempDir.toString();
Response resp = sr.getArtwork("../../etc/passwd");
assertEquals(403, resp.getStatus());
}
}
@@ -1,230 +0,0 @@
package org.dostalgia;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import java.util.zip.ZipOutputStream;
import static org.junit.jupiter.api.Assertions.*;
class ZipServiceTest {
private final ZipService zip = new ZipService();
// Wire the config dependency manually (no CDI)
{
zip.config = new ConfigBuilder();
}
@Test
void unzip_extractsFiles(@TempDir Path dir) throws Exception {
Path zipFile = dir.resolve("test.zip");
Path dest = dir.resolve("output");
try (ZipOutputStream zos = new ZipOutputStream(Files.newOutputStream(zipFile))) {
zos.putNextEntry(new ZipEntry("game.exe"));
zos.write("MZ".getBytes());
zos.closeEntry();
zos.putNextEntry(new ZipEntry("readme.txt"));
zos.write("Hello".getBytes());
zos.closeEntry();
}
zip.unzip(zipFile, dest);
assertTrue(Files.exists(dest.resolve("game.exe")));
assertTrue(Files.exists(dest.resolve("readme.txt")));
assertEquals("MZ", Files.readString(dest.resolve("game.exe")));
assertEquals("Hello", Files.readString(dest.resolve("readme.txt")));
}
@Test
void unzip_handlesSubdirectories(@TempDir Path dir) throws Exception {
Path zipFile = dir.resolve("test.zip");
Path dest = dir.resolve("output");
try (ZipOutputStream zos = new ZipOutputStream(Files.newOutputStream(zipFile))) {
zos.putNextEntry(new ZipEntry("subdir/game.exe"));
zos.write("MZ".getBytes());
zos.closeEntry();
zos.putNextEntry(new ZipEntry("subdir/nested/deep.txt"));
zos.write("deep".getBytes());
zos.closeEntry();
}
zip.unzip(zipFile, dest);
assertTrue(Files.exists(dest.resolve("subdir/game.exe")));
assertTrue(Files.exists(dest.resolve("subdir/nested/deep.txt")));
}
@Test
void unzip_pathTraversal_skipsMaliciousEntries(@TempDir Path dir) throws Exception {
Path zipFile = dir.resolve("test.zip");
Path dest = dir.resolve("output");
Files.createDirectories(dest);
try (ZipOutputStream zos = new ZipOutputStream(Files.newOutputStream(zipFile))) {
zos.putNextEntry(new ZipEntry("../evil.txt"));
zos.write("malicious".getBytes());
zos.closeEntry();
zos.putNextEntry(new ZipEntry("good.txt"));
zos.write("good".getBytes());
zos.closeEntry();
}
zip.unzip(zipFile, dest);
// Malicious entry should be skipped (path traversal)
assertFalse(Files.exists(dir.resolve("evil.txt")));
// Good entry should be extracted
assertTrue(Files.exists(dest.resolve("good.txt")));
}
@Test
void unzip_directoryEntries_createDirs(@TempDir Path dir) throws Exception {
Path zipFile = dir.resolve("test.zip");
Path dest = dir.resolve("output");
try (ZipOutputStream zos = new ZipOutputStream(Files.newOutputStream(zipFile))) {
zos.putNextEntry(new ZipEntry("adir/"));
zos.closeEntry();
zos.putNextEntry(new ZipEntry("adir/file.txt"));
zos.write("data".getBytes());
zos.closeEntry();
}
zip.unzip(zipFile, dest);
assertTrue(Files.isDirectory(dest.resolve("adir")));
assertTrue(Files.exists(dest.resolve("adir/file.txt")));
}
@Test
void flattenSingleDir_noOpWhenMultipleDirs(@TempDir Path dir) throws Exception {
Files.createDirectories(dir.resolve("sub1"));
Files.createDirectories(dir.resolve("sub2"));
Files.writeString(dir.resolve("sub1/file.txt"), "data");
zip.flattenSingleDir(dir);
assertTrue(Files.exists(dir.resolve("sub1/file.txt")));
assertTrue(Files.isDirectory(dir.resolve("sub1")));
assertTrue(Files.isDirectory(dir.resolve("sub2")));
}
@Test
void flattenSingleDir_noOpWhenNoSingleDir(@TempDir Path dir) throws Exception {
Files.writeString(dir.resolve("file.txt"), "data");
Files.writeString(dir.resolve("other.txt"), "data");
zip.flattenSingleDir(dir);
assertTrue(Files.exists(dir.resolve("file.txt")));
assertTrue(Files.exists(dir.resolve("other.txt")));
}
@Test
void flattenSingleDir_flattensOneDir(@TempDir Path dir) throws Exception {
Path single = Files.createDirectories(dir.resolve("single"));
Files.createDirectories(single.resolve("nested"));
Files.writeString(single.resolve("file.txt"), "data");
Files.writeString(single.resolve("nested/deep.txt"), "deep");
zip.flattenSingleDir(dir);
// Files should now be directly under dir
assertTrue(Files.exists(dir.resolve("file.txt")));
assertTrue(Files.exists(dir.resolve("nested/deep.txt")));
// The intermediate single dir should be gone
assertFalse(Files.exists(single));
}
@Test
void flattenSingleDir_emptyDir_noOp(@TempDir Path dir) throws Exception {
zip.flattenSingleDir(dir);
assertTrue(Files.exists(dir));
}
@Test
void createBundle_createsValidJsdosZip(@TempDir Path dir) throws Exception {
Path extractDir = dir.resolve("extract");
Files.createDirectories(extractDir.resolve("sub"));
Files.writeString(extractDir.resolve("game.exe"), "MZ");
Files.writeString(extractDir.resolve("sub/data.bin"), "binary");
Files.writeString(extractDir.resolve("readme.txt"), "info");
Path bundlePath = dir.resolve("output.jsdos");
zip.createBundle(extractDir, extractDir.resolve("game.exe").toString(), List.of(), bundlePath);
assertTrue(Files.exists(bundlePath));
assertTrue(Files.size(bundlePath) > 0);
try (ZipInputStream zis = new ZipInputStream(Files.newInputStream(bundlePath))) {
boolean hasGameExe = false;
boolean hasJsdosDir = false;
boolean hasJsdosConf = false;
boolean hasJsdosJson = false;
boolean hasDataBin = false;
ZipEntry entry;
while ((entry = zis.getNextEntry()) != null) {
String name = entry.getName();
if (name.equals("game.exe")) hasGameExe = true;
if (name.startsWith(".jsdos/")) hasJsdosDir = true;
if (name.equals(".jsdos/dosbox.conf")) hasJsdosConf = true;
if (name.equals(".jsdos/jsdos.json")) hasJsdosJson = true;
if (name.equals("sub/data.bin")) hasDataBin = true;
zis.closeEntry();
}
assertTrue(hasGameExe, "Should contain game.exe");
assertTrue(hasJsdosDir, "Should contain .jsdos/ entries");
assertTrue(hasJsdosConf, "Should contain .jsdos/dosbox.conf");
assertTrue(hasJsdosJson, "Should contain .jsdos/jsdos.json");
assertTrue(hasDataBin, "Should contain sub/data.bin");
}
}
@Test
void createBundle_respectsSkipExt(@TempDir Path dir) throws Exception {
Path extractDir = dir.resolve("extract");
Files.createDirectories(extractDir);
Files.writeString(extractDir.resolve("game.exe"), "MZ");
Files.writeString(extractDir.resolve("cdimage.nrg"), "image");
Files.writeString(extractDir.resolve("cdimage.mdf"), "image");
Path bundlePath = dir.resolve("output.jsdos");
zip.createBundle(extractDir, extractDir.resolve("game.exe").toString(), List.of(), bundlePath);
try (ZipInputStream zis = new ZipInputStream(Files.newInputStream(bundlePath))) {
ZipEntry entry;
while ((entry = zis.getNextEntry()) != null) {
String name = entry.getName();
assertNotEquals("cdimage.nrg", name, "Should not contain .nrg");
assertNotEquals("cdimage.mdf", name, "Should not contain .mdf");
zis.closeEntry();
}
}
}
@Test
void createBundle_noExe_createsCdOnlyConfig(@TempDir Path dir) throws Exception {
Path extractDir = dir.resolve("extract");
Files.createDirectories(extractDir);
Files.writeString(extractDir.resolve("somefile.bin"), "data");
Path bundlePath = dir.resolve("output.jsdos");
// null exePath triggers CD-only config generation
zip.createBundle(extractDir, null, List.of("cd.iso"), bundlePath);
try (ZipInputStream zis = new ZipInputStream(Files.newInputStream(bundlePath))) {
boolean hasJsdosConf = false;
ZipEntry entry;
while ((entry = zis.getNextEntry()) != null) {
if (entry.getName().equals(".jsdos/dosbox.conf")) hasJsdosConf = true;
zis.closeEntry();
}
assertTrue(hasJsdosConf, "CD-only bundle should still have dosbox.conf");
}
}
}