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
This commit is contained in:
David Alvarez
2026-05-23 11:18:41 +02:00
parent c3c154ec65
commit 1efd10f81f
24 changed files with 3314 additions and 2 deletions
+156
View File
@@ -0,0 +1,156 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"sort"
"time"
)
// Game represents the metadata for a single DOS game.
// Stored as game.json in data/games/{id}/
type Game struct {
ID string `json:"id"`
Title string `json:"title"`
Year int `json:"year,omitempty"`
Genre string `json:"genre,omitempty"`
Developer string `json:"developer,omitempty"`
Publisher string `json:"publisher,omitempty"`
Description string `json:"description,omitempty"`
Rating float64 `json:"rating,omitempty"`
Platforms []string `json:"platforms,omitempty"`
// Bundle info
BundleType string `json:"bundle_type,omitempty"` // "standard" or "sockdrive"
BundleFile string `json:"bundle_file,omitempty"` // path relative to game dir
// IGDB
IGDBID int `json:"igdb_id,omitempty"`
IGDBCoverID string `json:"igdb_cover_id,omitempty"`
// Local file hints (cover exists if cover.jpg/png/webp is present)
HasCover bool `json:"has_cover"`
HasScreenshot bool `json:"has_screenshots"`
Screenshots []string `json:"screenshots,omitempty"`
Ready bool `json:"ready"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// gameDir returns the directory for a game by ID.
func gameDir(id string) string {
return filepath.Join(dataDir, "games", id)
}
// gameJSONPath returns the path to a game's game.json.
func gameJSONPath(id string) string {
return filepath.Join(gameDir(id), "game.json")
}
// loadGame reads a game.json file and returns the Game.
func loadGame(id string) (*Game, error) {
path := gameJSONPath(id)
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
g := &Game{}
if err := json.Unmarshal(data, g); err != nil {
return nil, err
}
// Detect if cover file exists
g.HasCover = fileExists(filepath.Join(gameDir(id), "cover.jpg")) ||
fileExists(filepath.Join(gameDir(id), "cover.png")) ||
fileExists(filepath.Join(gameDir(id), "cover.webp"))
return g, nil
}
// saveGame writes a game's metadata to its game.json.
func saveGame(g *Game) error {
g.UpdatedAt = time.Now()
if g.CreatedAt.IsZero() {
g.CreatedAt = time.Now()
}
dir := gameDir(g.ID)
if err := os.MkdirAll(dir, 0755); err != nil {
return err
}
data, err := json.MarshalIndent(g, "", " ")
if err != nil {
return err
}
return os.WriteFile(gameJSONPath(g.ID), data, 0644)
}
// listGames scans the games directory and returns all games, sorted by title.
func listGames() ([]*Game, error) {
gamesDir := filepath.Join(dataDir, "games")
entries, err := os.ReadDir(gamesDir)
if err != nil {
if os.IsNotExist(err) {
return []*Game{}, nil
}
return nil, err
}
var games []*Game
if len(entries) > 0 {
games = make([]*Game, 0, len(entries))
} else {
games = make([]*Game, 0)
}
for _, e := range entries {
if !e.IsDir() {
continue
}
g, err := loadGame(e.Name())
if err != nil {
continue // skip broken entries
}
games = append(games, g)
}
sort.Slice(games, func(i, j int) bool {
return games[i].Title < games[j].Title
})
return games, nil
}
// fileExists checks if a file exists.
func fileExists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
// sanitizeID cleans a string for use as a game directory name.
func sanitizeID(s string) string {
result := make([]byte, 0, len(s))
for i := 0; i < len(s); i++ {
c := s[i]
if (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') {
result = append(result, c)
} else if c >= 'A' && c <= 'Z' {
result = append(result, c+32) // lowercase
} else if c == ' ' || c == '-' || c == '_' {
result = append(result, '-')
}
}
// Trim leading/trailing hyphens
for len(result) > 0 && result[0] == '-' {
result = result[1:]
}
for len(result) > 0 && result[len(result)-1] == '-' {
result = result[:len(result)-1]
}
if len(result) == 0 {
return "unknown"
}
return string(result)
}