Vellum/internal/library/registry.go
mithral b82090ed29 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.
2026-10-09 21:54:49 -04:00

168 lines
5.2 KiB
Go

// Package library owns library definitions and the scanner that turns files on
// disk into rows in media_items / media_files.
//
// Modules declare what file extensions they care about via RegisterType; this
// package never imports a module.
package library
import (
"errors"
"fmt"
"path/filepath"
"sort"
"strings"
"sync"
)
// GroupMode says how files become items.
type GroupMode string
const (
// GroupByFile makes one item per file. Correct for epubs, comics, photos.
GroupByFile GroupMode = "file"
// GroupByDirectory makes one item per directory, with its files as tracks.
// Correct for audiobooks (a folder of chapters) and albums.
GroupByDirectory GroupMode = "directory"
)
// MediaType declares one kind of library and the file extensions it owns.
type MediaType struct {
// Kind is the value stored in libraries.kind and media_items.kind.
Kind string
// Name is the human label shown in the UI.
Name string
// Extensions are lowercase, with a leading dot (".epub").
Extensions []string
// Group controls whether files collapse into one item per directory.
Group GroupMode
}
// builtinTypes are the media types Vellum understands out of the box. Modules
// may add more at runtime with RegisterType; these exist so the core is
// useful before any module is wired up.
var builtinTypes = []MediaType{
{Kind: "ebooks", Name: "Ebooks", Extensions: []string{".epub", ".mobi", ".azw3", ".fb2"}, Group: GroupByFile},
{Kind: "comics", Name: "Comics", Extensions: []string{".cbz", ".cbr", ".pdf"}, Group: GroupByFile},
{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},
}
var (
typesMu sync.RWMutex
types = map[string]MediaType{}
extToKind = map[string]string{}
)
func init() {
for _, t := range builtinTypes {
registerTypeLocked(t)
}
}
// RegisterType adds or replaces a media type. Intended to be called from a
// module's init or wiring function.
func RegisterType(t MediaType) {
typesMu.Lock()
defer typesMu.Unlock()
registerTypeLocked(t)
}
func registerTypeLocked(t MediaType) {
t.Kind = strings.ToLower(strings.TrimSpace(t.Kind))
for i, e := range t.Extensions {
t.Extensions[i] = normalizeExt(e)
}
types[t.Kind] = t
for _, e := range t.Extensions {
// Last registration wins for a shared extension (e.g. .pdf is comics).
extToKind[e] = t.Kind
}
}
// SupportedTypes returns every registered media type, sorted by kind.
func SupportedTypes() []MediaType {
typesMu.RLock()
defer typesMu.RUnlock()
out := make([]MediaType, 0, len(types))
for _, t := range types {
out = append(out, t)
}
sort.Slice(out, func(i, j int) bool { return out[i].Kind < out[j].Kind })
return out
}
// KindForExtension maps a file extension to the media kind that owns it.
func KindForExtension(ext string) (string, bool) {
typesMu.RLock()
defer typesMu.RUnlock()
k, ok := extToKind[normalizeExt(ext)]
return k, ok
}
var (
// ErrUnknownKind means the requested media kind is not registered.
ErrUnknownKind = errors.New("library: unknown media kind")
)
// ValidateKind reports whether kind is a registered media type.
func ValidateKind(kind string) error {
typesMu.RLock()
defer typesMu.RUnlock()
k := strings.ToLower(strings.TrimSpace(kind))
if _, ok := types[k]; !ok {
return fmt.Errorf("%w %q", ErrUnknownKind, kind)
}
return nil
}
// GroupModeFor returns how a library kind turns files into items.
func GroupModeFor(kind string) GroupMode {
typesMu.RLock()
defer typesMu.RUnlock()
if t, ok := types[kind]; ok && t.Group != "" {
return t.Group
}
return GroupByFile
}
// extensionsFor returns the extension set owned by a library kind.
func extensionsFor(kind string) map[string]bool {
typesMu.RLock()
defer typesMu.RUnlock()
t, ok := types[kind]
if !ok {
return nil
}
set := make(map[string]bool, len(t.Extensions))
for _, e := range t.Extensions {
set[e] = true
}
return set
}
func normalizeExt(ext string) string {
ext = strings.ToLower(strings.TrimSpace(ext))
if ext != "" && !strings.HasPrefix(ext, ".") {
ext = "." + ext
}
return ext
}
// CleanTitle turns a filename into a human title: strips the extension,
// collapses separators, and trims noise.
func CleanTitle(filename string) string {
base := strings.TrimSuffix(filepath.Base(filename), filepath.Ext(filename))
base = strings.ReplaceAll(base, "_", " ")
base = strings.ReplaceAll(base, ".", " ")
base = strings.Join(strings.Fields(base), " ")
return strings.TrimSpace(base)
}