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>
142 lines
4.1 KiB
Go
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
|
|
}
|