From b82090ed29d65bfa6424402b290cbe5d883daf5d Mon Sep 17 00:00:00 2001 From: mithral Date: Fri, 9 Oct 2026 21:54:49 -0400 Subject: [PATCH] =?UTF-8?q?Phase=207:=20games=20=E2=80=94=20ROM=20scanning?= =?UTF-8?q?,=20platforms,=20cloud=20saves,=20EmulatorJS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- cmd/vellum/main.go | 7 + internal/core/config.go | 10 +- internal/library/registry.go | 7 + internal/metadata/enrich.go | 30 +++- internal/metadata/enrich_test.go | 17 ++ internal/metadata/igdb.go | 178 +++++++++++++++++++ internal/metadata/igdb_test.go | 95 +++++++++++ internal/modules/games/games.go | 89 ++++++++++ internal/modules/games/games_test.go | 176 +++++++++++++++++++ internal/modules/games/handlers.go | 209 +++++++++++++++++++++++ internal/modules/games/saves.go | 88 ++++++++++ migrations/0005_games.sql | 17 ++ web/src/lib/api.ts | 14 ++ web/src/lib/components/GamePlayer.svelte | 194 +++++++++++++++++++++ web/src/lib/components/Reader.svelte | 3 + 15 files changed, 1132 insertions(+), 2 deletions(-) create mode 100644 internal/metadata/igdb.go create mode 100644 internal/metadata/igdb_test.go create mode 100644 internal/modules/games/games.go create mode 100644 internal/modules/games/games_test.go create mode 100644 internal/modules/games/handlers.go create mode 100644 internal/modules/games/saves.go create mode 100644 migrations/0005_games.sql create mode 100644 web/src/lib/components/GamePlayer.svelte diff --git a/cmd/vellum/main.go b/cmd/vellum/main.go index 2a352aa..fa8b72c 100644 --- a/cmd/vellum/main.go +++ b/cmd/vellum/main.go @@ -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()) diff --git a/internal/core/config.go b/internal/core/config.go index cc8141e..b7746d6 100644 --- a/internal/core/config.go +++ b/internal/core/config.go @@ -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. diff --git a/internal/library/registry.go b/internal/library/registry.go index b20538a..b6702a0 100644 --- a/internal/library/registry.go +++ b/internal/library/registry.go @@ -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}, } diff --git a/internal/metadata/enrich.go b/internal/metadata/enrich.go index 49e8fb5..a443e6a 100644 --- a/internal/metadata/enrich.go +++ b/internal/metadata/enrich.go @@ -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 diff --git a/internal/metadata/enrich_test.go b/internal/metadata/enrich_test.go index fc64858..66e52e7 100644 --- a/internal/metadata/enrich_test.go +++ b/internal/metadata/enrich_test.go @@ -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"}, diff --git a/internal/metadata/igdb.go b/internal/metadata/igdb.go new file mode 100644 index 0000000..87ac46e --- /dev/null +++ b/internal/metadata/igdb.go @@ -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 +} diff --git a/internal/metadata/igdb_test.go b/internal/metadata/igdb_test.go new file mode 100644 index 0000000..dbbb2b9 --- /dev/null +++ b/internal/metadata/igdb_test.go @@ -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)) + } +} diff --git a/internal/modules/games/games.go b/internal/modules/games/games.go new file mode 100644 index 0000000..f6c3430 --- /dev/null +++ b/internal/modules/games/games.go @@ -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 +} diff --git a/internal/modules/games/games_test.go b/internal/modules/games/games_test.go new file mode 100644 index 0000000..4a692e9 --- /dev/null +++ b/internal/modules/games/games_test.go @@ -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") + } +} diff --git a/internal/modules/games/handlers.go b/internal/modules/games/handlers.go new file mode 100644 index 0000000..31ff5fc --- /dev/null +++ b/internal/modules/games/handlers.go @@ -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") +} diff --git a/internal/modules/games/saves.go b/internal/modules/games/saves.go new file mode 100644 index 0000000..da5cdf8 --- /dev/null +++ b/internal/modules/games/saves.go @@ -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 +} diff --git a/migrations/0005_games.sql b/migrations/0005_games.sql new file mode 100644 index 0000000..0aa006c --- /dev/null +++ b/migrations/0005_games.sql @@ -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); diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index cf68dcb..92a57e6 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -137,6 +137,20 @@ export const api = { home: () => request('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('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) diff --git a/web/src/lib/components/GamePlayer.svelte b/web/src/lib/components/GamePlayer.svelte new file mode 100644 index 0000000..01d0b31 --- /dev/null +++ b/web/src/lib/components/GamePlayer.svelte @@ -0,0 +1,194 @@ + + +{#if error} +
{error}
+{:else if loading} +
+{:else if !playable} +
+

{platform || 'Unrecognised ROM'}

+

+ {#if platform} + There is no in-browser emulator for {platform} yet. + {:else} + The file extension does not identify a known platform. + {/if} +

+ Download the ROM +
+{:else} +
+ {platform} + + + +
+ + {#if status} +

{status}

+ {/if} + +
+
+
+{/if} + + diff --git a/web/src/lib/components/Reader.svelte b/web/src/lib/components/Reader.svelte index a9b5f3c..5bcc590 100644 --- a/web/src/lib/components/Reader.svelte +++ b/web/src/lib/components/Reader.svelte @@ -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}
{error}
+{:else if item && item.kind === 'games'} + {:else if item && fileContainer === 'pdf'} {:else if item && item.kind === 'comics'}