Files
canon/internal/store/store.go
T
cdnutterandClaude Opus 5.5 33053d1c5f Add refresh from source for entries and whole lists
- Every provider can look an entry up again by its ID (TMDB, TheTVDB,
  IGDB, Deezer, MusicBrainz, Wikipedia); search and lookup share parsing
- POST /api/items/{id}/refresh re-applies title, subtitle, year,
  description, link, details and artwork. Kept: the "why" note, rank,
  theater production/venue/cast and a theater entry's year, the Deezer
  preview match. Empty source fields don't erase existing values
- New items.image_custom column (migration 4; existing uploads marked)
  records artwork the owner chose; refresh keeps it unless asked
- Refused for custom lists and hand-added entries; entries the source
  dropped are reported, and a refresh whose title shares no words with
  the current one is rejected as a likely bad ID
- Editor: refresh button per row (asks about the owner's own artwork),
  and "Refresh all" with a confirm step, replace-my-art checkbox,
  progress with Stop, and a summary of problems

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 12:41:22 -07:00

146 lines
4.4 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 '';`,
// Whether the owner chose the artwork (upload, pasted URL, or removed it)
// rather than taking the source's; refresh leaves it alone unless asked.
`ALTER TABLE items ADD COLUMN image_custom INTEGER NOT NULL DEFAULT 0;
UPDATE items SET image_custom = 1 WHERE image_source = 'upload';`,
}
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
}