Files
canon/internal/store/store.go
T
cdnutterandClaude Opus 5.5 7320e3f707 Save source descriptions, add "Why it's #N", fix IGDB studio names
Descriptions
- Items keep the source's description (TMDB/TheTVDB overview, IGDB
  summary, Wikipedia intro) in a new items.description column
  (migration 3); editable in the item form
- Deezer/MusicBrainz track counts and album names are now display-only
  hints, not saved as descriptions

Why it's #N
- The owner's note is presented as "Why it's #N" in the editor and the
  public detail view, above an "About" section with the description

IGDB developers
- IGDB credits internal divisions ("Nintendo EAD", "Nintendo EPD
  Production Group No. 8"). A developer whose name shares its first word
  with its parent company is credited to the parent ("Nintendo");
  independently named studios keep their name (Retro Studios, Naughty
  Dog, Landfall Games)
- Fall back to the publisher when IGDB lists no developer

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 18:05:20 -07:00

142 lines
4.1 KiB
Go

// Package store is the SQLite persistence layer.
package store
import (
"context"
"database/sql"
"errors"
"fmt"
"os"
"path/filepath"
_ "modernc.org/sqlite"
)
var ErrNotFound = errors.New("not found")
type Store struct {
db *sql.DB
}
func Open(dataDir string) (*Store, error) {
path := filepath.Join(dataDir, "canon.db")
if err := adoptLegacyDB(dataDir, path); err != nil {
return nil, err
}
dsn := fmt.Sprintf("file:%s?_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)&_pragma=busy_timeout(5000)&_pragma=synchronous(NORMAL)&_txlock=immediate", path)
db, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, err
}
db.SetMaxOpenConns(4)
s := &Store{db: db}
if err := s.migrate(context.Background()); err != nil {
db.Close()
return nil, fmt.Errorf("migrate: %w", err)
}
return s, nil
}
func (s *Store) Close() error { return s.db.Close() }
// adoptLegacyDB renames a database from before the project was called
// Canon (topdeck.db, plus its WAL files) so upgrades keep their data.
func adoptLegacyDB(dataDir, path string) error {
legacy := filepath.Join(dataDir, "topdeck.db")
if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) {
return nil
}
if _, err := os.Stat(legacy); err != nil {
return nil
}
for _, suffix := range []string{"-wal", "-shm", ""} {
err := os.Rename(legacy+suffix, path+suffix)
if err != nil && !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("renaming %s to %s: %w", legacy+suffix, path+suffix, err)
}
}
return nil
}
// migrations are applied in order; the index into this slice (plus one) is
// the schema version stored in PRAGMA user_version.
var migrations = []string{
`
CREATE TABLE users (
id INTEGER PRIMARY KEY,
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
password_hash TEXT NOT NULL,
created_at INTEGER NOT NULL DEFAULT (unixepoch())
);
CREATE TABLE sessions (
token_hash TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at INTEGER NOT NULL
);
CREATE TABLE settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE lists (
id INTEGER PRIMARY KEY,
slug TEXT NOT NULL UNIQUE,
title TEXT NOT NULL,
category TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
max_items INTEGER NOT NULL DEFAULT 10 CHECK (max_items BETWEEN 1 AND 65535),
position INTEGER NOT NULL DEFAULT 0,
visible INTEGER NOT NULL DEFAULT 1,
layout TEXT NOT NULL DEFAULT 'grid',
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
);
CREATE TABLE items (
id INTEGER PRIMARY KEY,
list_id INTEGER NOT NULL REFERENCES lists(id) ON DELETE CASCADE,
rank INTEGER NOT NULL,
title TEXT NOT NULL,
subtitle TEXT NOT NULL DEFAULT '',
year TEXT NOT NULL DEFAULT '',
image TEXT NOT NULL DEFAULT '',
image_source TEXT NOT NULL DEFAULT '',
provider TEXT NOT NULL DEFAULT '',
provider_id TEXT NOT NULL DEFAULT '',
external_url TEXT NOT NULL DEFAULT '',
note TEXT NOT NULL DEFAULT '',
extra TEXT NOT NULL DEFAULT '{}',
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
);
CREATE INDEX items_list_rank ON items(list_id, rank);
`,
// Per-list artwork shape; '' uses the category's default.
`ALTER TABLE lists ADD COLUMN art TEXT NOT NULL DEFAULT '';`,
// The source's description of the item (TMDB overview, IGDB summary…).
`ALTER TABLE items ADD COLUMN description TEXT NOT NULL DEFAULT '';`,
}
func (s *Store) migrate(ctx context.Context) error {
var version int
if err := s.db.QueryRowContext(ctx, "PRAGMA user_version").Scan(&version); err != nil {
return err
}
for i := version; i < len(migrations); i++ {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
if _, err := tx.ExecContext(ctx, migrations[i]); err != nil {
tx.Rollback()
return fmt.Errorf("migration %d: %w", i+1, err)
}
if _, err := tx.ExecContext(ctx, fmt.Sprintf("PRAGMA user_version = %d", i+1)); err != nil {
tx.Rollback()
return err
}
if err := tx.Commit(); err != nil {
return err
}
}
return nil
}