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:
mithral 2026-10-09 21:54:49 -04:00
parent 5a2c3fa090
commit b82090ed29
15 changed files with 1132 additions and 2 deletions

View file

@ -21,6 +21,7 @@ import (
"github.com/mithral/vellum/internal/metadata" "github.com/mithral/vellum/internal/metadata"
"github.com/mithral/vellum/internal/modules/comics" "github.com/mithral/vellum/internal/modules/comics"
"github.com/mithral/vellum/internal/modules/ebooks" "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/modules/photos" // registers the thumbnail extractor
"github.com/mithral/vellum/internal/search" "github.com/mithral/vellum/internal/search"
"github.com/mithral/vellum/migrations" "github.com/mithral/vellum/migrations"
@ -81,6 +82,7 @@ func run() error {
media.NewHandlers(db, log, authenticator, cfg.ConfigDir).Register(api.Mux()) media.NewHandlers(db, log, authenticator, cfg.ConfigDir).Register(api.Mux())
ebooks.NewHandlers(db, log, authenticator).Register(api.Mux()) ebooks.NewHandlers(db, log, authenticator).Register(api.Mux())
comics.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()) search.NewHandlers(db, log, authenticator).Register(api.Mux())
// Background work: a table-backed queue in the same database. Providers // Background work: a table-backed queue in the same database. Providers
@ -95,6 +97,11 @@ func run() error {
} else { } else {
log.Info("comic metadata disabled: set VELLUM_COMICVINE_API_KEY to enable ComicVine lookups") 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) enricher := metadata.NewEnricher(db, log, cfg.ConfigDir)
queue.Register(metadata.JobKind, enricher.Handle) queue.Register(metadata.JobKind, enricher.Handle)
metadata.NewHandlers(db, log, authenticator, queue).Register(api.Mux()) metadata.NewHandlers(db, log, authenticator, queue).Register(api.Mux())

View file

@ -36,6 +36,11 @@ type Config struct {
// the provider is simply absent when this is empty. // the provider is simply absent when this is empty.
ComicVineAPIKey string 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 CookieName string
CookieSecure bool CookieSecure bool
@ -60,7 +65,10 @@ func LoadConfig() (Config, error) {
AdminPassword: env("VELLUM_ADMIN_PASSWORD", ""), AdminPassword: env("VELLUM_ADMIN_PASSWORD", ""),
ComicVineAPIKey: env("VELLUM_COMICVINE_API_KEY", ""), 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 // Secure defaults to false so plain-HTTP development works. Production
// behind TLS must set VELLUM_COOKIE_SECURE=true; boot logs a warning // behind TLS must set VELLUM_COOKIE_SECURE=true; boot logs a warning
// when it is off. // when it is off.

View file

@ -46,6 +46,13 @@ var builtinTypes = []MediaType{
{Kind: "audiobooks", Name: "Audiobooks", Extensions: []string{".m4b", ".mp3", ".m4a", ".opus", ".flac", ".ogg"}, Group: GroupByDirectory}, {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: "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: "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}, {Kind: "video", Name: "Video", Extensions: []string{".mkv", ".mp4", ".webm", ".avi", ".mov"}, Group: GroupByFile},
} }

View file

@ -98,7 +98,7 @@ func (e *Enricher) EnrichItem(ctx context.Context, itemID int64) error {
return nil 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 { for _, p := range providers {
if e.triedRecently(ctx, itemID, p.Name()) { if e.triedRecently(ctx, itemID, p.Name()) {
continue continue
@ -160,6 +160,34 @@ func artistHint(it media.Item) string {
return path.Base(dir) 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. // pickBest returns the first candidate whose title corresponds to want.
// //
// The bar is deliberately loose (normalised equality or containment in either // The bar is deliberately loose (normalised equality or containment in either

View file

@ -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) { func TestPickBestRejectsUnrelated(t *testing.T) {
candidates := []Candidate{ candidates := []Candidate{
{Title: "Random Access Memories", ExternalID: "right"}, {Title: "Random Access Memories", ExternalID: "right"},

178
internal/metadata/igdb.go Normal file
View 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
}

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

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

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

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

View 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
View 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);

View file

@ -137,6 +137,20 @@ export const api = {
home: () => request<Home>('GET', '/api/v1/home'), 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 } = {}) => { listItems: (opts: { kind?: string; libraryId?: number; limit?: number } = {}) => {
const p = new URLSearchParams() const p = new URLSearchParams()
if (opts.kind) p.set('kind', opts.kind) if (opts.kind) p.set('kind', opts.kind)

View 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>

View file

@ -6,6 +6,7 @@
import AudiobookPlayer from './AudiobookPlayer.svelte' import AudiobookPlayer from './AudiobookPlayer.svelte'
import PhotoViewer from './PhotoViewer.svelte' import PhotoViewer from './PhotoViewer.svelte'
import PdfReader from './PdfReader.svelte' import PdfReader from './PdfReader.svelte'
import GamePlayer from './GamePlayer.svelte'
let { itemId }: { itemId: number } = $props() let { itemId }: { itemId: number } = $props()
@ -95,6 +96,8 @@
{#if error} {#if error}
<div class="alert">{error}</div> <div class="alert">{error}</div>
{:else if item && item.kind === 'games'}
<GamePlayer {itemId} />
{:else if item && fileContainer === 'pdf'} {:else if item && fileContainer === 'pdf'}
<PdfReader {itemId} /> <PdfReader {itemId} />
{:else if item && item.kind === 'comics'} {:else if item && item.kind === 'comics'}