1efd10f81f
- 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
157 lines
3.9 KiB
Go
157 lines
3.9 KiB
Go
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)
|
|
}
|