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:
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user