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) }