Phase 7: games — ROM scanning, platforms, cloud saves, EmulatorJS
The RomM replacement. - registry: a games media type with ~35 ROM extensions across NES, SNES, GB/GBA, N64, NDS, Genesis/Master System/Game Gear, PC Engine, Neo Geo Pocket, WonderSwan, Atari, Coleco, Intellivision, and PlayStation. - games: platform detection from the file extension, mapping each to an EmulatorJS core. Platforms with no browser core are recognised but marked not-playable so the UI offers a download instead of a broken player. - migration 0005: game_saves keyed by (user, item, slot). Save states are user data, not media — two people playing the same ROM keep their own progress. - save endpoints with a 16 MiB cap and a validated slot name; the upload is read through a LimitReader so an unbounded body is refused rather than buffered. - igdb provider: Twitch client-credentials flow with the token cached for its ~2 month lifetime, Apicalypse query body, and cover URLs normalised from protocol-relative t_thumb to https t_cover_big. - searchTitle: strips "(USA)", "[!]", "(Rev 1)" and similar release tags before searching. "Sonic the Hedgehog (USA)" matched nothing; "Sonic the Hedgehog" matches immediately. Stored titles keep the original. - web: GamePlayer embeds EmulatorJS and adds Load/Save-to-server buttons. Verified live against IGDB: Sonic matched (id 316900) with real cover art; a 4 KB save uploaded, listed, downloaded byte-identical, and deleted.
This commit is contained in:
parent
5a2c3fa090
commit
b82090ed29
15 changed files with 1132 additions and 2 deletions
|
|
@ -21,6 +21,7 @@ import (
|
|||
"github.com/mithral/vellum/internal/metadata"
|
||||
"github.com/mithral/vellum/internal/modules/comics"
|
||||
"github.com/mithral/vellum/internal/modules/ebooks"
|
||||
"github.com/mithral/vellum/internal/modules/games"
|
||||
_ "github.com/mithral/vellum/internal/modules/photos" // registers the thumbnail extractor
|
||||
"github.com/mithral/vellum/internal/search"
|
||||
"github.com/mithral/vellum/migrations"
|
||||
|
|
@ -81,6 +82,7 @@ func run() error {
|
|||
media.NewHandlers(db, log, authenticator, cfg.ConfigDir).Register(api.Mux())
|
||||
ebooks.NewHandlers(db, log, authenticator).Register(api.Mux())
|
||||
comics.NewHandlers(db, log, authenticator).Register(api.Mux())
|
||||
games.NewHandlers(db, log, authenticator).Register(api.Mux())
|
||||
search.NewHandlers(db, log, authenticator).Register(api.Mux())
|
||||
|
||||
// Background work: a table-backed queue in the same database. Providers
|
||||
|
|
@ -95,6 +97,11 @@ func run() error {
|
|||
} else {
|
||||
log.Info("comic metadata disabled: set VELLUM_COMICVINE_API_KEY to enable ComicVine lookups")
|
||||
}
|
||||
if cfg.IGDBClientID != "" && cfg.IGDBClientSecret != "" {
|
||||
metadata.Register(metadata.NewIGDB(cfg.IGDBClientID, cfg.IGDBClientSecret))
|
||||
} else {
|
||||
log.Info("game metadata disabled: set VELLUM_IGDB_CLIENT_ID and VELLUM_IGDB_CLIENT_SECRET to enable IGDB lookups")
|
||||
}
|
||||
enricher := metadata.NewEnricher(db, log, cfg.ConfigDir)
|
||||
queue.Register(metadata.JobKind, enricher.Handle)
|
||||
metadata.NewHandlers(db, log, authenticator, queue).Register(api.Mux())
|
||||
|
|
|
|||
|
|
@ -36,6 +36,11 @@ type Config struct {
|
|||
// the provider is simply absent when this is empty.
|
||||
ComicVineAPIKey string
|
||||
|
||||
// IGDBClientID / IGDBClientSecret enable game metadata via Twitch's
|
||||
// client-credentials flow. Both must be set for the provider to appear.
|
||||
IGDBClientID string
|
||||
IGDBClientSecret string
|
||||
|
||||
CookieName string
|
||||
CookieSecure bool
|
||||
|
||||
|
|
@ -60,7 +65,10 @@ func LoadConfig() (Config, error) {
|
|||
AdminPassword: env("VELLUM_ADMIN_PASSWORD", ""),
|
||||
|
||||
ComicVineAPIKey: env("VELLUM_COMICVINE_API_KEY", ""),
|
||||
CookieName: env("VELLUM_COOKIE_NAME", "vellum_session"),
|
||||
|
||||
IGDBClientID: env("VELLUM_IGDB_CLIENT_ID", ""),
|
||||
IGDBClientSecret: env("VELLUM_IGDB_CLIENT_SECRET", ""),
|
||||
CookieName: env("VELLUM_COOKIE_NAME", "vellum_session"),
|
||||
// Secure defaults to false so plain-HTTP development works. Production
|
||||
// behind TLS must set VELLUM_COOKIE_SECURE=true; boot logs a warning
|
||||
// when it is off.
|
||||
|
|
|
|||
|
|
@ -46,6 +46,13 @@ var builtinTypes = []MediaType{
|
|||
{Kind: "audiobooks", Name: "Audiobooks", Extensions: []string{".m4b", ".mp3", ".m4a", ".opus", ".flac", ".ogg"}, Group: GroupByDirectory},
|
||||
{Kind: "music", Name: "Music", Extensions: []string{".mp3", ".flac", ".m4a", ".opus", ".ogg", ".wav"}, Group: GroupByDirectory},
|
||||
{Kind: "photos", Name: "Photos", Extensions: []string{".jpg", ".jpeg", ".png", ".webp", ".heic", ".gif", ".avif"}, Group: GroupByFile},
|
||||
{Kind: "games", Name: "Games", Extensions: []string{
|
||||
".nes", ".fds", ".snes", ".sfc", ".smc", ".gba", ".gb", ".gbc",
|
||||
".n64", ".z64", ".v64", ".nds", ".3ds", ".cia",
|
||||
".md", ".gen", ".smd", ".sms", ".gg", ".pce", ".ngp", ".ngc", ".ws", ".wsc",
|
||||
".a26", ".a78", ".lnx", ".vec", ".col", ".int",
|
||||
".psx", ".ps1", ".cue", ".chd", ".pbp",
|
||||
}, Group: GroupByFile},
|
||||
{Kind: "video", Name: "Video", Extensions: []string{".mkv", ".mp4", ".webm", ".avi", ".mov"}, Group: GroupByFile},
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -98,7 +98,7 @@ func (e *Enricher) EnrichItem(ctx context.Context, itemID int64) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
q := Query{Title: it.Title, Author: artistHint(it), Kind: it.Kind}
|
||||
q := Query{Title: searchTitle(it.Title), Author: artistHint(it), Kind: it.Kind}
|
||||
for _, p := range providers {
|
||||
if e.triedRecently(ctx, itemID, p.Name()) {
|
||||
continue
|
||||
|
|
@ -160,6 +160,34 @@ func artistHint(it media.Item) string {
|
|||
return path.Base(dir)
|
||||
}
|
||||
|
||||
// searchTitle strips the region, language, and version tags that ROM and
|
||||
// release filenames carry — "(USA)", "[!]", "(Rev 1)", "(En,Fr,De)" — because
|
||||
// they are noise to a metadata search. "Sonic the Hedgehog (USA)" matches
|
||||
// nothing; "Sonic the Hedgehog" matches immediately.
|
||||
func searchTitle(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
for {
|
||||
trimmed := strings.TrimSpace(s)
|
||||
var cut int
|
||||
switch {
|
||||
case strings.HasSuffix(trimmed, ")"):
|
||||
cut = strings.LastIndexByte(trimmed, '(')
|
||||
case strings.HasSuffix(trimmed, "]"):
|
||||
cut = strings.LastIndexByte(trimmed, '[')
|
||||
default:
|
||||
return trimmed
|
||||
}
|
||||
// cut <= 0 would mean the whole title is a bracketed group; leave it.
|
||||
if cut <= 0 {
|
||||
return trimmed
|
||||
}
|
||||
s = strings.TrimSpace(trimmed[:cut])
|
||||
if s == "" {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// pickBest returns the first candidate whose title corresponds to want.
|
||||
//
|
||||
// The bar is deliberately loose (normalised equality or containment in either
|
||||
|
|
|
|||
|
|
@ -259,6 +259,23 @@ func TestNormalizeTitle(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestSearchTitleStripsReleaseTags(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"Sonic the Hedgehog (USA)": "Sonic the Hedgehog",
|
||||
"Super Mario Bros. (World)": "Super Mario Bros.",
|
||||
"Game [!]": "Game",
|
||||
"Pokemon (USA) (Rev 1)": "Pokemon",
|
||||
"The Legend of Zelda": "The Legend of Zelda",
|
||||
"Chrono Trigger (USA) [En,Fr]": "Chrono Trigger",
|
||||
"(Just Brackets)": "(Just Brackets)",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := searchTitle(in); got != want {
|
||||
t.Errorf("searchTitle(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPickBestRejectsUnrelated(t *testing.T) {
|
||||
candidates := []Candidate{
|
||||
{Title: "Random Access Memories", ExternalID: "right"},
|
||||
|
|
|
|||
178
internal/metadata/igdb.go
Normal file
178
internal/metadata/igdb.go
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
package metadata
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// IGDB matches games. It uses Twitch's client-credentials flow: the app
|
||||
// exchanges its client id and secret for a bearer token that lasts about two
|
||||
// months, so the token is cached rather than re-fetched per search.
|
||||
type IGDB struct {
|
||||
ClientID string
|
||||
ClientSecret string
|
||||
|
||||
// BaseURL and TokenURL are overridable so tests can point at a local server.
|
||||
BaseURL string
|
||||
TokenURL string
|
||||
|
||||
Client *http.Client
|
||||
|
||||
mu sync.Mutex
|
||||
token string
|
||||
tokenExpiry time.Time
|
||||
}
|
||||
|
||||
// NewIGDB builds the provider.
|
||||
func NewIGDB(clientID, clientSecret string) *IGDB {
|
||||
return &IGDB{
|
||||
ClientID: clientID,
|
||||
ClientSecret: clientSecret,
|
||||
BaseURL: "https://api.igdb.com/v4",
|
||||
TokenURL: "https://id.twitch.tv/oauth2/token",
|
||||
Client: &http.Client{Timeout: 15 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *IGDB) Name() string { return "igdb" }
|
||||
|
||||
func (p *IGDB) Kinds() []string { return []string{"games"} }
|
||||
|
||||
type igdbGame struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Summary string `json:"summary"`
|
||||
FirstReleaseDate int64 `json:"first_release_date"`
|
||||
Cover struct {
|
||||
URL string `json:"url"`
|
||||
} `json:"cover"`
|
||||
}
|
||||
|
||||
func (p *IGDB) Search(ctx context.Context, q Query) ([]Candidate, error) {
|
||||
if p.ClientID == "" || p.ClientSecret == "" || strings.TrimSpace(q.Title) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
token, err := p.accessToken(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// IGDB uses its own query language (Apicalypse) sent as the request body.
|
||||
body := fmt.Sprintf(
|
||||
`search %q; fields name,summary,first_release_date,cover.url; limit 5;`,
|
||||
q.Title)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||
strings.TrimRight(p.BaseURL, "/")+"/games", strings.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Client-ID", p.ClientID)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := p.Client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("igdb: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusUnauthorized {
|
||||
// The cached token was rejected; drop it so the next call re-auths.
|
||||
p.mu.Lock()
|
||||
p.token, p.tokenExpiry = "", time.Time{}
|
||||
p.mu.Unlock()
|
||||
return nil, fmt.Errorf("igdb: unauthorized (token rejected)")
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("igdb: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var games []igdbGame
|
||||
if err := json.NewDecoder(resp.Body).Decode(&games); err != nil {
|
||||
return nil, fmt.Errorf("igdb: decode: %w", err)
|
||||
}
|
||||
|
||||
out := make([]Candidate, 0, len(games))
|
||||
for _, g := range games {
|
||||
if g.Name == "" {
|
||||
continue
|
||||
}
|
||||
c := Candidate{
|
||||
Provider: p.Name(),
|
||||
ExternalID: strconv.Itoa(g.ID),
|
||||
Title: g.Name,
|
||||
Description: g.Summary,
|
||||
}
|
||||
if g.FirstReleaseDate > 0 {
|
||||
c.Year = time.Unix(g.FirstReleaseDate, 0).UTC().Year()
|
||||
}
|
||||
if g.Cover.URL != "" {
|
||||
c.CoverURL = igdbCover(g.Cover.URL)
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// accessToken returns a cached bearer token, fetching a new one when absent or
|
||||
// near expiry.
|
||||
func (p *IGDB) accessToken(ctx context.Context) (string, error) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
// Refresh a minute early to avoid racing the expiry.
|
||||
if p.token != "" && time.Now().Before(p.tokenExpiry.Add(-time.Minute)) {
|
||||
return p.token, nil
|
||||
}
|
||||
|
||||
params := url.Values{}
|
||||
params.Set("client_id", p.ClientID)
|
||||
params.Set("client_secret", p.ClientSecret)
|
||||
params.Set("grant_type", "client_credentials")
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||
p.TokenURL+"?"+params.Encode(), nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
resp, err := p.Client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("igdb: token request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("igdb: token status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var parsed struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&parsed); err != nil {
|
||||
return "", fmt.Errorf("igdb: token decode: %w", err)
|
||||
}
|
||||
if parsed.AccessToken == "" {
|
||||
return "", fmt.Errorf("igdb: empty access token")
|
||||
}
|
||||
p.token = parsed.AccessToken
|
||||
p.tokenExpiry = time.Now().Add(time.Duration(parsed.ExpiresIn) * time.Second)
|
||||
return p.token, nil
|
||||
}
|
||||
|
||||
// igdbCover normalises an IGDB image URL: protocol-relative to https, and the
|
||||
// thumbnail size swapped for a larger one.
|
||||
func igdbCover(u string) string {
|
||||
u = strings.Replace(u, "t_thumb", "t_cover_big", 1)
|
||||
if strings.HasPrefix(u, "//") {
|
||||
u = "https:" + u
|
||||
}
|
||||
return u
|
||||
}
|
||||
95
internal/metadata/igdb_test.go
Normal file
95
internal/metadata/igdb_test.go
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
package metadata
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestIGDBSearch(t *testing.T) {
|
||||
var tokenHits int32
|
||||
tokenSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt32(&tokenHits, 1)
|
||||
if r.URL.Query().Get("grant_type") != "client_credentials" {
|
||||
t.Errorf("grant_type = %q", r.URL.Query().Get("grant_type"))
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"access_token":"tok-123","expires_in":5184000}`))
|
||||
}))
|
||||
defer tokenSrv.Close()
|
||||
|
||||
var gotBody, gotClientID, gotAuth string
|
||||
apiSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
gotBody = string(b)
|
||||
gotClientID = r.Header.Get("Client-ID")
|
||||
gotAuth = r.Header.Get("Authorization")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`[
|
||||
{
|
||||
"id": 1029,
|
||||
"name": "Sonic the Hedgehog",
|
||||
"summary": "A fast blue hedgehog.",
|
||||
"first_release_date": 677635200,
|
||||
"cover": {"url": "//images.igdb.com/igdb/image/upload/t_thumb/co1234.jpg"}
|
||||
}
|
||||
]`))
|
||||
}))
|
||||
defer apiSrv.Close()
|
||||
|
||||
p := NewIGDB("client-id", "client-secret")
|
||||
p.TokenURL = tokenSrv.URL
|
||||
p.BaseURL = apiSrv.URL
|
||||
|
||||
cands, err := p.Search(context.Background(), Query{Title: "Sonic"})
|
||||
if err != nil {
|
||||
t.Fatalf("Search: %v", err)
|
||||
}
|
||||
if len(cands) != 1 {
|
||||
t.Fatalf("got %d candidates, want 1", len(cands))
|
||||
}
|
||||
|
||||
c := cands[0]
|
||||
if c.Title != "Sonic the Hedgehog" {
|
||||
t.Errorf("Title = %q", c.Title)
|
||||
}
|
||||
if c.ExternalID != "1029" {
|
||||
t.Errorf("ExternalID = %q", c.ExternalID)
|
||||
}
|
||||
if c.Year != 1991 {
|
||||
t.Errorf("Year = %d, want 1991", c.Year)
|
||||
}
|
||||
if c.CoverURL != "https://images.igdb.com/igdb/image/upload/t_cover_big/co1234.jpg" {
|
||||
t.Errorf("CoverURL = %q (want https and t_cover_big)", c.CoverURL)
|
||||
}
|
||||
|
||||
if !strings.Contains(gotBody, `search "Sonic"`) {
|
||||
t.Errorf("query body = %q", gotBody)
|
||||
}
|
||||
if gotClientID != "client-id" || gotAuth != "Bearer tok-123" {
|
||||
t.Errorf("headers: Client-ID=%q Authorization=%q", gotClientID, gotAuth)
|
||||
}
|
||||
|
||||
// A second search must reuse the cached token, not re-authenticate.
|
||||
if _, err := p.Search(context.Background(), Query{Title: "Sonic 2"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n := atomic.LoadInt32(&tokenHits); n != 1 {
|
||||
t.Errorf("token endpoint hit %d times, want 1 (token should be cached)", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIGDBWithoutCredentialsDoesNothing(t *testing.T) {
|
||||
p := NewIGDB("", "")
|
||||
cands, err := p.Search(context.Background(), Query{Title: "Sonic"})
|
||||
if err != nil {
|
||||
t.Fatalf("Search: %v", err)
|
||||
}
|
||||
if len(cands) != 0 {
|
||||
t.Errorf("got %d candidates with no credentials, want 0", len(cands))
|
||||
}
|
||||
}
|
||||
89
internal/modules/games/games.go
Normal file
89
internal/modules/games/games.go
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
// Package games serves emulated games: platform detection from the ROM file
|
||||
// and per-user save states.
|
||||
//
|
||||
// The emulator itself runs in the browser (EmulatorJS); the server's job is to
|
||||
// identify the platform, hand over the ROM, and keep each user's saves.
|
||||
package games
|
||||
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Kind is the media kind this module owns.
|
||||
const Kind = "games"
|
||||
|
||||
// Platform is what a ROM extension tells us.
|
||||
type Platform struct {
|
||||
// Name is the human label, e.g. "Super Nintendo".
|
||||
Name string
|
||||
// Core is the EmulatorJS core id, e.g. "snes". Empty when EmulatorJS has
|
||||
// no core for this platform, in which case the UI offers a download
|
||||
// instead of a player.
|
||||
Core string
|
||||
}
|
||||
|
||||
// platforms maps a file extension to its platform. Extensions are the only
|
||||
// signal available without parsing ROM headers, and they are reliable enough
|
||||
// in practice because emulator collections are named consistently.
|
||||
var platforms = map[string]Platform{
|
||||
".nes": {"Nintendo Entertainment System", "nes"},
|
||||
".fds": {"Famicom Disk System", "nes"},
|
||||
".snes": {"Super Nintendo", "snes"},
|
||||
".sfc": {"Super Nintendo", "snes"},
|
||||
".smc": {"Super Nintendo", "snes"},
|
||||
".gba": {"Game Boy Advance", "gba"},
|
||||
".gb": {"Game Boy", "gb"},
|
||||
".gbc": {"Game Boy Color", "gb"},
|
||||
".n64": {"Nintendo 64", "n64"},
|
||||
".z64": {"Nintendo 64", "n64"},
|
||||
".v64": {"Nintendo 64", "n64"},
|
||||
".nds": {"Nintendo DS", "nds"},
|
||||
".3ds": {"Nintendo 3DS", ""},
|
||||
".cia": {"Nintendo 3DS", ""},
|
||||
".md": {"Sega Genesis", "segaMD"},
|
||||
".gen": {"Sega Genesis", "segaMD"},
|
||||
".smd": {"Sega Genesis", "segaMD"},
|
||||
".sms": {"Sega Master System", "segaMS"},
|
||||
".gg": {"Game Gear", "segaGG"},
|
||||
".pce": {"PC Engine", "pce"},
|
||||
".ngp": {"Neo Geo Pocket", "ngp"},
|
||||
".ngc": {"Neo Geo Pocket Color", "ngp"},
|
||||
".ws": {"WonderSwan", "ws"},
|
||||
".wsc": {"WonderSwan Color", "ws"},
|
||||
".a26": {"Atari 2600", "atari2600"},
|
||||
".a78": {"Atari 7800", "atari7800"},
|
||||
".lnx": {"Atari Lynx", "lynx"},
|
||||
".col": {"ColecoVision", "coleco"},
|
||||
".int": {"Intellivision", "intv"},
|
||||
".psx": {"PlayStation", "psx"},
|
||||
".ps1": {"PlayStation", "psx"},
|
||||
".cue": {"PlayStation", "psx"},
|
||||
".chd": {"PlayStation", "psx"},
|
||||
".pbp": {"PlayStation", "psx"},
|
||||
}
|
||||
|
||||
// PlatformFor returns the platform for a file path, or a zero Platform with
|
||||
// ok=false when the extension is unrecognised.
|
||||
func PlatformFor(path string) (Platform, bool) {
|
||||
ext := strings.ToLower(path)
|
||||
if i := strings.LastIndexByte(ext, '.'); i >= 0 {
|
||||
ext = ext[i:]
|
||||
} else {
|
||||
return Platform{}, false
|
||||
}
|
||||
p, ok := platforms[ext]
|
||||
return p, ok
|
||||
}
|
||||
|
||||
// Playable reports whether EmulatorJS can run this platform in the browser.
|
||||
func (p Platform) Playable() bool { return p.Core != "" }
|
||||
|
||||
// Extensions returns every ROM extension the module recognises, for tests and
|
||||
// diagnostics.
|
||||
func Extensions() []string {
|
||||
out := make([]string, 0, len(platforms))
|
||||
for ext := range platforms {
|
||||
out = append(out, ext)
|
||||
}
|
||||
return out
|
||||
}
|
||||
176
internal/modules/games/games_test.go
Normal file
176
internal/modules/games/games_test.go
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
package games
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"io"
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/mithral/vellum/internal/core"
|
||||
"github.com/mithral/vellum/migrations"
|
||||
)
|
||||
|
||||
func testDB(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
db, err := core.OpenDB(filepath.Join(t.TempDir(), "games.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("OpenDB: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if err := core.Migrate(context.Background(), db, migrations.FS, log); err != nil {
|
||||
t.Fatalf("Migrate: %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
// seedGame creates a user, library, and game item, returning their ids.
|
||||
func seedGame(t *testing.T, db *sql.DB) (userID, itemID int64) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
|
||||
if err := db.QueryRowContext(ctx,
|
||||
`INSERT INTO users (username, password_hash) VALUES ('player','x') RETURNING id`).Scan(&userID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var libID int64
|
||||
if err := db.QueryRowContext(ctx,
|
||||
`INSERT INTO libraries (name, kind, root_path) VALUES ('Games','games','/roms') RETURNING id`).Scan(&libID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.QueryRowContext(ctx,
|
||||
`INSERT INTO media_items (library_id, kind, path, title) VALUES (?, 'games', 'Sonic.md', 'Sonic') RETURNING id`,
|
||||
libID).Scan(&itemID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return userID, itemID
|
||||
}
|
||||
|
||||
func TestPlatformFor(t *testing.T) {
|
||||
cases := []struct {
|
||||
path string
|
||||
platform string
|
||||
core string
|
||||
ok bool
|
||||
}{
|
||||
{"Mario.nes", "Nintendo Entertainment System", "nes", true},
|
||||
{"Zelda.SFC", "Super Nintendo", "snes", true},
|
||||
{"Pokemon.gba", "Game Boy Advance", "gba", true},
|
||||
{"Sonic.md", "Sega Genesis", "segaMD", true},
|
||||
{"Mario Kart.n64", "Nintendo 64", "n64", true},
|
||||
{"Final Fantasy VII.cue", "PlayStation", "psx", true},
|
||||
// A platform with no browser core: recognised, but not playable.
|
||||
{"Animal Crossing.3ds", "Nintendo 3DS", "", true},
|
||||
{"mystery.xyz", "", "", false},
|
||||
{"noextension", "", "", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
p, ok := PlatformFor(c.path)
|
||||
if ok != c.ok {
|
||||
t.Errorf("PlatformFor(%q) ok = %v, want %v", c.path, ok, c.ok)
|
||||
continue
|
||||
}
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if p.Name != c.platform {
|
||||
t.Errorf("PlatformFor(%q).Name = %q, want %q", c.path, p.Name, c.platform)
|
||||
}
|
||||
if p.Core != c.core {
|
||||
t.Errorf("PlatformFor(%q).Core = %q, want %q", c.path, p.Core, c.core)
|
||||
}
|
||||
if p.Playable() != (c.core != "") {
|
||||
t.Errorf("PlatformFor(%q).Playable() = %v", c.path, p.Playable())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveRoundTrip(t *testing.T) {
|
||||
db := testDB(t)
|
||||
ctx := context.Background()
|
||||
userID, itemID := seedGame(t, db)
|
||||
|
||||
// Nothing stored yet.
|
||||
if _, found, err := GetSave(ctx, db, userID, itemID, "default"); err != nil || found {
|
||||
t.Fatalf("expected no save: found=%v err=%v", found, err)
|
||||
}
|
||||
|
||||
state := []byte{0x01, 0x02, 0x03, 0x04}
|
||||
if err := PutSave(ctx, db, userID, itemID, "default", state); err != nil {
|
||||
t.Fatalf("PutSave: %v", err)
|
||||
}
|
||||
|
||||
got, found, err := GetSave(ctx, db, userID, itemID, "default")
|
||||
if err != nil || !found {
|
||||
t.Fatalf("GetSave: found=%v err=%v", found, err)
|
||||
}
|
||||
if len(got) != len(state) || got[0] != 0x01 {
|
||||
t.Errorf("save bytes = %v, want %v", got, state)
|
||||
}
|
||||
|
||||
// Overwrite.
|
||||
if err := PutSave(ctx, db, userID, itemID, "default", []byte("second")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, _, _ = GetSave(ctx, db, userID, itemID, "default")
|
||||
if string(got) != "second" {
|
||||
t.Errorf("overwrite failed: %q", got)
|
||||
}
|
||||
|
||||
// A second slot is independent.
|
||||
if err := PutSave(ctx, db, userID, itemID, "slot2", []byte("two")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
saves, err := ListSaves(ctx, db, userID, itemID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(saves) != 2 {
|
||||
t.Errorf("got %d saves, want 2", len(saves))
|
||||
}
|
||||
|
||||
// Deleting one leaves the other.
|
||||
if err := DeleteSave(ctx, db, userID, itemID, "default"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
saves, _ = ListSaves(ctx, db, userID, itemID)
|
||||
if len(saves) != 1 || saves[0].Slot != "slot2" {
|
||||
t.Errorf("after delete: %+v", saves)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSavesArePerUser(t *testing.T) {
|
||||
db := testDB(t)
|
||||
ctx := context.Background()
|
||||
userA, itemID := seedGame(t, db)
|
||||
|
||||
var userB int64
|
||||
if err := db.QueryRowContext(ctx,
|
||||
`INSERT INTO users (username, password_hash) VALUES ('other','x') RETURNING id`).Scan(&userB); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := PutSave(ctx, db, userA, itemID, "default", []byte("A's progress")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// The other user sees nothing; saves are not shared.
|
||||
if _, found, err := GetSave(ctx, db, userB, itemID, "default"); err != nil || found {
|
||||
t.Errorf("user B can see user A's save: found=%v err=%v", found, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPutSaveRejectsBadInput(t *testing.T) {
|
||||
db := testDB(t)
|
||||
ctx := context.Background()
|
||||
userID, itemID := seedGame(t, db)
|
||||
|
||||
if err := PutSave(ctx, db, userID, itemID, "default", nil); err == nil {
|
||||
t.Error("empty save was accepted")
|
||||
}
|
||||
if err := PutSave(ctx, db, userID, itemID, "default", make([]byte, MaxSaveBytes+1)); err == nil {
|
||||
t.Error("oversized save was accepted")
|
||||
}
|
||||
}
|
||||
209
internal/modules/games/handlers.go
Normal file
209
internal/modules/games/handlers.go
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
package games
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/mithral/vellum/internal/auth"
|
||||
"github.com/mithral/vellum/internal/httpx"
|
||||
"github.com/mithral/vellum/internal/media"
|
||||
)
|
||||
|
||||
// Handlers serves game-specific endpoints.
|
||||
type Handlers struct {
|
||||
db *sql.DB
|
||||
log *slog.Logger
|
||||
auth *auth.Authenticator
|
||||
}
|
||||
|
||||
// NewHandlers builds the games HTTP layer.
|
||||
func NewHandlers(db *sql.DB, log *slog.Logger, a *auth.Authenticator) *Handlers {
|
||||
return &Handlers{db: db, log: log, auth: a}
|
||||
}
|
||||
|
||||
// Register mounts the routes.
|
||||
func (h *Handlers) Register(mux *http.ServeMux) {
|
||||
mux.Handle("GET /api/v1/items/{id}/game", h.auth.Require(h.handleGame))
|
||||
mux.Handle("GET /api/v1/items/{id}/saves", h.auth.Require(h.handleListSaves))
|
||||
mux.Handle("GET /api/v1/items/{id}/saves/{slot}", h.auth.Require(h.handleGetSave))
|
||||
mux.Handle("PUT /api/v1/items/{id}/saves/{slot}", h.auth.Require(h.handlePutSave))
|
||||
mux.Handle("DELETE /api/v1/items/{id}/saves/{slot}", h.auth.Require(h.handleDeleteSave))
|
||||
}
|
||||
|
||||
// handleGame reports the platform and which emulator core to load.
|
||||
func (h *Handlers) handleGame(w http.ResponseWriter, r *http.Request, u auth.User) {
|
||||
it, ok := h.authorized(w, r, u)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
platform, found := PlatformFor(it.Path)
|
||||
saves, err := ListSaves(r.Context(), h.db, u.ID, it.ID)
|
||||
if err != nil {
|
||||
h.fail(w, "list saves", err)
|
||||
return
|
||||
}
|
||||
|
||||
resp := map[string]any{
|
||||
"item_id": it.ID,
|
||||
"saves": saves,
|
||||
}
|
||||
if found {
|
||||
resp["platform"] = platform.Name
|
||||
resp["core"] = platform.Core
|
||||
resp["playable"] = platform.Playable()
|
||||
} else {
|
||||
resp["platform"] = ""
|
||||
resp["core"] = ""
|
||||
resp["playable"] = false
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func (h *Handlers) handleListSaves(w http.ResponseWriter, r *http.Request, u auth.User) {
|
||||
it, ok := h.authorized(w, r, u)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
saves, err := ListSaves(r.Context(), h.db, u.ID, it.ID)
|
||||
if err != nil {
|
||||
h.fail(w, "list saves", err)
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, map[string]any{"saves": saves})
|
||||
}
|
||||
|
||||
func (h *Handlers) handleGetSave(w http.ResponseWriter, r *http.Request, u auth.User) {
|
||||
it, ok := h.authorized(w, r, u)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
slot, ok := slotName(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
data, found, err := GetSave(r.Context(), h.db, u.ID, it.ID, slot)
|
||||
if err != nil {
|
||||
h.fail(w, "get save", err)
|
||||
return
|
||||
}
|
||||
if !found {
|
||||
httpx.WriteError(w, http.StatusNotFound, "no save in that slot")
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
w.Header().Set("Content-Length", strconv.Itoa(len(data)))
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
|
||||
func (h *Handlers) handlePutSave(w http.ResponseWriter, r *http.Request, u auth.User) {
|
||||
it, ok := h.authorized(w, r, u)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
slot, ok := slotName(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
// Bounded read: refuse to buffer an unbounded body.
|
||||
data, err := io.ReadAll(io.LimitReader(r.Body, MaxSaveBytes+1))
|
||||
if err != nil {
|
||||
httpx.WriteError(w, http.StatusBadRequest, "could not read the save")
|
||||
return
|
||||
}
|
||||
if len(data) == 0 {
|
||||
httpx.WriteError(w, http.StatusBadRequest, "save is empty")
|
||||
return
|
||||
}
|
||||
if len(data) > MaxSaveBytes {
|
||||
httpx.WriteError(w, http.StatusRequestEntityTooLarge, "save is too large")
|
||||
return
|
||||
}
|
||||
|
||||
if err := PutSave(r.Context(), h.db, u.ID, it.ID, slot, data); err != nil {
|
||||
h.fail(w, "put save", err)
|
||||
return
|
||||
}
|
||||
h.log.Info("save stored", "item", it.ID, "slot", slot, "bytes", len(data), "user", u.Username)
|
||||
httpx.WriteJSON(w, http.StatusOK, map[string]any{"slot": slot, "size": len(data)})
|
||||
}
|
||||
|
||||
func (h *Handlers) handleDeleteSave(w http.ResponseWriter, r *http.Request, u auth.User) {
|
||||
it, ok := h.authorized(w, r, u)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
slot, ok := slotName(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := DeleteSave(r.Context(), h.db, u.ID, it.ID, slot); err != nil {
|
||||
h.fail(w, "delete save", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// ── helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
// slotName validates the slot path segment. Slots are user-visible labels, so
|
||||
// they are restricted to a safe character set rather than trusted as-is.
|
||||
func slotName(w http.ResponseWriter, r *http.Request) (string, bool) {
|
||||
slot := strings.TrimSpace(r.PathValue("slot"))
|
||||
if slot == "" {
|
||||
slot = "default"
|
||||
}
|
||||
if len(slot) > 64 {
|
||||
httpx.WriteError(w, http.StatusBadRequest, "slot name is too long")
|
||||
return "", false
|
||||
}
|
||||
for _, c := range slot {
|
||||
if !(c == '-' || c == '_' || c == '.' ||
|
||||
(c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9')) {
|
||||
httpx.WriteError(w, http.StatusBadRequest, "slot name has invalid characters")
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
return slot, true
|
||||
}
|
||||
|
||||
func (h *Handlers) authorized(w http.ResponseWriter, r *http.Request, u auth.User) (media.Item, bool) {
|
||||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
httpx.WriteError(w, http.StatusBadRequest, "invalid item id")
|
||||
return media.Item{}, false
|
||||
}
|
||||
|
||||
it, err := media.LoadItem(r.Context(), h.db, id)
|
||||
if errors.Is(err, media.ErrItemNotFound) {
|
||||
httpx.WriteError(w, http.StatusNotFound, "item not found")
|
||||
return media.Item{}, false
|
||||
}
|
||||
if err != nil {
|
||||
h.fail(w, "load item", err)
|
||||
return media.Item{}, false
|
||||
}
|
||||
|
||||
allowed, err := auth.CanAccessLibrary(r.Context(), h.db, u, it.LibraryID)
|
||||
if err != nil || !allowed {
|
||||
httpx.WriteError(w, http.StatusNotFound, "item not found")
|
||||
return media.Item{}, false
|
||||
}
|
||||
if it.Kind != Kind {
|
||||
httpx.WriteError(w, http.StatusBadRequest, "item is not a game")
|
||||
return media.Item{}, false
|
||||
}
|
||||
return it, true
|
||||
}
|
||||
|
||||
func (h *Handlers) fail(w http.ResponseWriter, what string, err error) {
|
||||
h.log.Error(what, "err", err)
|
||||
httpx.WriteError(w, http.StatusInternalServerError, "internal server error")
|
||||
}
|
||||
88
internal/modules/games/saves.go
Normal file
88
internal/modules/games/saves.go
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
package games
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// MaxSaveBytes caps an uploaded save state. EmulatorJS states are typically a
|
||||
// few hundred kilobytes; anything near this limit is a client bug.
|
||||
const MaxSaveBytes = 16 << 20 // 16 MiB
|
||||
|
||||
// Save is one stored save state.
|
||||
type Save struct {
|
||||
Slot string `json:"slot"`
|
||||
Size int64 `json:"size"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// ListSaves returns a user's save slots for an item, newest first.
|
||||
func ListSaves(ctx context.Context, db *sql.DB, userID, itemID int64) ([]Save, error) {
|
||||
rows, err := db.QueryContext(ctx, `
|
||||
SELECT slot, size, updated_at FROM game_saves
|
||||
WHERE user_id = ? AND item_id = ?
|
||||
ORDER BY updated_at DESC`, userID, itemID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("games: list saves: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := []Save{}
|
||||
for rows.Next() {
|
||||
var s Save
|
||||
var updated string
|
||||
if err := rows.Scan(&s.Slot, &s.Size, &updated); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.UpdatedAt, _ = time.Parse(time.RFC3339Nano, updated)
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// GetSave returns one save state's bytes. The bool is false when no save
|
||||
// exists for that slot.
|
||||
func GetSave(ctx context.Context, db *sql.DB, userID, itemID int64, slot string) ([]byte, bool, error) {
|
||||
var data []byte
|
||||
err := db.QueryRowContext(ctx, `
|
||||
SELECT data FROM game_saves
|
||||
WHERE user_id = ? AND item_id = ? AND slot = ?`, userID, itemID, slot).Scan(&data)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("games: get save: %w", err)
|
||||
}
|
||||
return data, true, nil
|
||||
}
|
||||
|
||||
// PutSave upserts a save state.
|
||||
func PutSave(ctx context.Context, db *sql.DB, userID, itemID int64, slot string, data []byte) error {
|
||||
if len(data) == 0 {
|
||||
return errors.New("games: save is empty")
|
||||
}
|
||||
if len(data) > MaxSaveBytes {
|
||||
return fmt.Errorf("games: save exceeds %d bytes", MaxSaveBytes)
|
||||
}
|
||||
_, err := db.ExecContext(ctx, `
|
||||
INSERT INTO game_saves (user_id, item_id, slot, data, size, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (user_id, item_id, slot)
|
||||
DO UPDATE SET data = excluded.data, size = excluded.size, updated_at = excluded.updated_at`,
|
||||
userID, itemID, slot, data, len(data), time.Now().UTC().Format(time.RFC3339Nano))
|
||||
if err != nil {
|
||||
return fmt.Errorf("games: put save: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteSave removes one save slot.
|
||||
func DeleteSave(ctx context.Context, db *sql.DB, userID, itemID int64, slot string) error {
|
||||
_, err := db.ExecContext(ctx, `
|
||||
DELETE FROM game_saves WHERE user_id = ? AND item_id = ? AND slot = ?`,
|
||||
userID, itemID, slot)
|
||||
return err
|
||||
}
|
||||
17
migrations/0005_games.sql
Normal file
17
migrations/0005_games.sql
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
-- 0005_games.sql — per-user save states for emulated games.
|
||||
--
|
||||
-- Save states are user data, not media: two people playing the same ROM keep
|
||||
-- their own saves. Keyed by (user, item, slot) so a game can have several save
|
||||
-- slots, the same way a console memory card would.
|
||||
|
||||
CREATE TABLE game_saves (
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
item_id INTEGER NOT NULL REFERENCES media_items(id) ON DELETE CASCADE,
|
||||
slot TEXT NOT NULL DEFAULT 'default',
|
||||
data BLOB NOT NULL,
|
||||
size INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||
PRIMARY KEY (user_id, item_id, slot)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_game_saves_item ON game_saves(item_id);
|
||||
|
|
@ -137,6 +137,20 @@ export const api = {
|
|||
|
||||
home: () => request<Home>('GET', '/api/v1/home'),
|
||||
|
||||
game: (id: number) =>
|
||||
request<{
|
||||
item_id: number
|
||||
platform: string
|
||||
core: string
|
||||
playable: boolean
|
||||
saves: { slot: string; size: number; updated_at: string }[]
|
||||
}>('GET', `/api/v1/items/${id}/game`),
|
||||
|
||||
saveUrl: (id: number, slot = 'default') => `/api/v1/items/${id}/saves/${slot}`,
|
||||
|
||||
deleteSave: (id: number, slot = 'default') =>
|
||||
request<void>('DELETE', `/api/v1/items/${id}/saves/${slot}`),
|
||||
|
||||
listItems: (opts: { kind?: string; libraryId?: number; limit?: number } = {}) => {
|
||||
const p = new URLSearchParams()
|
||||
if (opts.kind) p.set('kind', opts.kind)
|
||||
|
|
|
|||
194
web/src/lib/components/GamePlayer.svelte
Normal file
194
web/src/lib/components/GamePlayer.svelte
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte'
|
||||
import { api, ApiError } from '../api'
|
||||
|
||||
let { itemId }: { itemId: number } = $props()
|
||||
|
||||
// EmulatorJS loads its cores and data files from this path. It is a CDN by
|
||||
// default, so playing a game needs outbound internet; the ROM itself never
|
||||
// leaves the server.
|
||||
const DATA_PATH = 'https://cdn.emulatorjs.org/stable/data/'
|
||||
|
||||
let platform = $state('')
|
||||
let core = $state('')
|
||||
let playable = $state(false)
|
||||
let loading = $state(true)
|
||||
let error = $state<string | null>(null)
|
||||
let status = $state('')
|
||||
let busy = $state(false)
|
||||
|
||||
let scriptEl: HTMLScriptElement | null = null
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
const g = await api.game(itemId)
|
||||
platform = g.platform
|
||||
core = g.core
|
||||
playable = g.playable
|
||||
if (playable) {
|
||||
await boot()
|
||||
}
|
||||
} catch (err) {
|
||||
error = err instanceof ApiError ? err.message : 'could not open this game'
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
})
|
||||
|
||||
onDestroy(() => {
|
||||
scriptEl?.remove()
|
||||
const w = window as any
|
||||
w.EJS_emulator = undefined
|
||||
})
|
||||
|
||||
async function boot() {
|
||||
const w = window as any
|
||||
// EmulatorJS reads these globals when loader.js runs.
|
||||
w.EJS_player = '#ejs-host'
|
||||
w.EJS_core = core
|
||||
w.EJS_gameUrl = api.fileUrl(itemId)
|
||||
w.EJS_gameName = `vellum-${itemId}`
|
||||
w.EJS_pathtodata = DATA_PATH
|
||||
w.EJS_startOnLoaded = true
|
||||
w.EJS_color = '#6d7cff'
|
||||
w.EJS_backgroundColor = '#0a0a0c'
|
||||
|
||||
await loadScript(DATA_PATH + 'loader.js')
|
||||
}
|
||||
|
||||
function loadScript(src: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const s = document.createElement('script')
|
||||
s.src = src
|
||||
s.async = true
|
||||
s.onload = () => resolve()
|
||||
s.onerror = () => reject(new Error('could not load the emulator'))
|
||||
document.body.appendChild(s)
|
||||
scriptEl = s
|
||||
})
|
||||
}
|
||||
|
||||
function emulator(): any {
|
||||
return (window as any).EJS_emulator
|
||||
}
|
||||
|
||||
// Save states are the emulator's full snapshot, uploaded as raw bytes and
|
||||
// kept per user, so two people playing the same ROM keep separate progress.
|
||||
async function saveToServer() {
|
||||
const emu = emulator()
|
||||
if (!emu?.gameManager?.getState) {
|
||||
status = 'The emulator is still starting.'
|
||||
return
|
||||
}
|
||||
busy = true
|
||||
status = ''
|
||||
try {
|
||||
const state: Uint8Array = emu.gameManager.getState()
|
||||
const res = await fetch(api.saveUrl(itemId), {
|
||||
method: 'PUT',
|
||||
credentials: 'same-origin',
|
||||
// Cast: the DOM lib types BlobPart as ArrayBuffer-backed, while
|
||||
// Uint8Array is generic over ArrayBufferLike (which includes
|
||||
// SharedArrayBuffer). The runtime value is always a normal buffer.
|
||||
body: new Blob([state as BlobPart]),
|
||||
})
|
||||
if (!res.ok) throw new Error(`save failed (${res.status})`)
|
||||
const info = await res.json()
|
||||
status = `Saved ${Math.round(info.size / 1024)} KB to the server.`
|
||||
} catch (err) {
|
||||
status = err instanceof Error ? err.message : 'could not save'
|
||||
} finally {
|
||||
busy = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadFromServer() {
|
||||
const emu = emulator()
|
||||
if (!emu?.gameManager?.loadState) {
|
||||
status = 'The emulator is still starting.'
|
||||
return
|
||||
}
|
||||
busy = true
|
||||
status = ''
|
||||
try {
|
||||
const res = await fetch(api.saveUrl(itemId), { credentials: 'same-origin' })
|
||||
if (res.status === 404) {
|
||||
status = 'No save on the server yet.'
|
||||
return
|
||||
}
|
||||
if (!res.ok) throw new Error(`load failed (${res.status})`)
|
||||
const bytes = new Uint8Array(await res.arrayBuffer())
|
||||
emu.gameManager.loadState(bytes)
|
||||
status = 'Loaded the save from the server.'
|
||||
} catch (err) {
|
||||
status = err instanceof Error ? err.message : 'could not load the save'
|
||||
} finally {
|
||||
busy = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if error}
|
||||
<div class="alert">{error}</div>
|
||||
{:else if loading}
|
||||
<div class="stage center"><div class="spinner"></div></div>
|
||||
{:else if !playable}
|
||||
<div class="empty">
|
||||
<h3>{platform || 'Unrecognised ROM'}</h3>
|
||||
<p>
|
||||
{#if platform}
|
||||
There is no in-browser emulator for {platform} yet.
|
||||
{:else}
|
||||
The file extension does not identify a known platform.
|
||||
{/if}
|
||||
</p>
|
||||
<a class="btn" href={api.fileUrl(itemId)} download>Download the ROM</a>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="bar">
|
||||
<span class="badge">{platform}</span>
|
||||
<span class="spacer"></span>
|
||||
<button class="btn" onclick={loadFromServer} disabled={busy}>Load save</button>
|
||||
<button class="btn" onclick={saveToServer} disabled={busy}>Save to server</button>
|
||||
</div>
|
||||
|
||||
{#if status}
|
||||
<p class="status muted">{status}</p>
|
||||
{/if}
|
||||
|
||||
<div class="stage">
|
||||
<div id="ejs-host"></div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
.spacer {
|
||||
flex: 1;
|
||||
}
|
||||
.status {
|
||||
margin: 0 0 var(--space-3);
|
||||
font-size: 13px;
|
||||
}
|
||||
.stage {
|
||||
height: calc(100vh - 60px - 150px);
|
||||
min-height: 420px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: #000;
|
||||
overflow: hidden;
|
||||
}
|
||||
.stage :global(#ejs-host) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.center {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -6,6 +6,7 @@
|
|||
import AudiobookPlayer from './AudiobookPlayer.svelte'
|
||||
import PhotoViewer from './PhotoViewer.svelte'
|
||||
import PdfReader from './PdfReader.svelte'
|
||||
import GamePlayer from './GamePlayer.svelte'
|
||||
|
||||
let { itemId }: { itemId: number } = $props()
|
||||
|
||||
|
|
@ -95,6 +96,8 @@
|
|||
|
||||
{#if error}
|
||||
<div class="alert">{error}</div>
|
||||
{:else if item && item.kind === 'games'}
|
||||
<GamePlayer {itemId} />
|
||||
{:else if item && fileContainer === 'pdf'}
|
||||
<PdfReader {itemId} />
|
||||
{:else if item && item.kind === 'comics'}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue