Initial TopDeck: self-hosted ranked favorites

A single Go binary with SQLite that serves a public page of the owner's
ranked lists (movies, TV, albums, songs, video games, theater) and a
login-gated editor.

- Lists of 1–65,535 entries (100 recommended), several per category
- Search-to-add via TMDB, TheTVDB, IGDB, Deezer/MusicBrainz, Wikipedia;
  manual entry as a fallback
- Theater entries carry a production (Broadway, tour, …), venue and cast
- Artwork cached locally; URL override and uploads
- Drag, arrow and type-a-rank reordering; paginated for long lists
- 12 theme presets plus custom CSS
- First-run setup code or env-provided admin; bcrypt, rate-limited login,
  CSRF-guarded JSON API
- Dockerfile and docker-compose.yml

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
cdnutterandClaude Opus 5.5 committed 2026-09-24 16:54:19 -07:00
commit e9a75a968a
53 files changed
+7078

No files matched your search

+51
View File
@@ -0,0 +1,51 @@
package store
// Category describes one kind of list. The Key is what's stored in the
// database; everything else is presentation.
type Category struct {
Key string `json:"key"`
Label string `json:"label"` // plural, e.g. "Movies"
Singular string `json:"singular"` // e.g. "Movie"
Icon string `json:"icon"` // name of an inline SVG icon
Aspect string `json:"aspect"` // "poster" (2:3) or "square" (1:1)
// SubtitleHint labels the free-text subtitle field in the editor.
SubtitleHint string `json:"subtitleHint"`
}
var Categories = []Category{
{Key: "movie", Label: "Movies", Singular: "Movie", Icon: "film", Aspect: "poster", SubtitleHint: "Director"},
{Key: "tv", Label: "TV Shows", Singular: "TV Show", Icon: "tv", Aspect: "poster", SubtitleHint: "Network"},
{Key: "album", Label: "Albums", Singular: "Album", Icon: "disc", Aspect: "square", SubtitleHint: "Artist"},
{Key: "song", Label: "Songs", Singular: "Song", Icon: "music", Aspect: "square", SubtitleHint: "Artist"},
{Key: "game", Label: "Video Games", Singular: "Video Game", Icon: "gamepad", Aspect: "poster", SubtitleHint: "Developer"},
{Key: "theater", Label: "Theater", Singular: "Show", Icon: "masks", Aspect: "poster", SubtitleHint: "Creators"},
}
func CategoryByKey(key string) (Category, bool) {
for _, c := range Categories {
if c.Key == key {
return c, true
}
}
return Category{}, false
}
// TheaterProductions are the suggested production labels for theater items.
// The owner can also type anything else.
var TheaterProductions = []string{
"Broadway",
"Broadway Revival",
"Off-Broadway",
"West End",
"West End Revival",
"Off-West End",
"North American Tour",
"US National Tour",
"UK & Ireland Tour",
"Australian Production",
"International Tour",
"Regional",
"Concert / Staged Reading",
"Filmed / Pro-shot",
"Original Cast Recording",
}
+268
View File
@@ -0,0 +1,268 @@
package store
import (
"context"
"database/sql"
"encoding/json"
"errors"
"strings"
)
type Item struct {
ID int64 `json:"id"`
ListID int64 `json:"listId"`
Rank int `json:"rank"`
Title string `json:"title"`
Subtitle string `json:"subtitle"`
Year string `json:"year"`
Image string `json:"image"` // cached file name under /images/
ImageSource string `json:"imageSource"` // where the image came from
Provider string `json:"provider"`
ProviderID string `json:"providerId"`
ExternalURL string `json:"externalUrl"`
Note string `json:"note"`
Extra map[string]string `json:"extra"`
}
const itemColumns = `id, list_id, rank, title, subtitle, year, image, image_source, provider, provider_id, external_url, note, extra`
func scanItem(row interface{ Scan(...any) error }) (Item, error) {
var it Item
var extra string
err := row.Scan(&it.ID, &it.ListID, &it.Rank, &it.Title, &it.Subtitle, &it.Year, &it.Image, &it.ImageSource,
&it.Provider, &it.ProviderID, &it.ExternalURL, &it.Note, &extra)
if errors.Is(err, sql.ErrNoRows) {
return it, ErrNotFound
}
if err != nil {
return it, err
}
it.Extra = map[string]string{}
_ = json.Unmarshal([]byte(extra), &it.Extra)
return it, nil
}
// Items returns a page of a list's items in rank order. limit <= 0 means all.
func (s *Store) Items(ctx context.Context, listID int64, offset, limit int) ([]Item, error) {
if limit <= 0 {
limit = -1
}
rows, err := s.db.QueryContext(ctx, `SELECT `+itemColumns+` FROM items WHERE list_id = ? ORDER BY rank LIMIT ? OFFSET ?`, listID, limit, offset)
if err != nil {
return nil, err
}
defer rows.Close()
out := []Item{}
for rows.Next() {
it, err := scanItem(rows)
if err != nil {
return nil, err
}
out = append(out, it)
}
return out, rows.Err()
}
func (s *Store) ItemByID(ctx context.Context, id int64) (Item, error) {
return scanItem(s.db.QueryRowContext(ctx, `SELECT `+itemColumns+` FROM items WHERE id = ?`, id))
}
// FindItemByProvider returns an item in the list that came from the same
// provider entry, if any.
func (s *Store) FindItemByProvider(ctx context.Context, listID int64, provider, providerID string) (Item, error) {
if provider == "" || providerID == "" {
return Item{}, ErrNotFound
}
return scanItem(s.db.QueryRowContext(ctx, `SELECT `+itemColumns+` FROM items WHERE list_id = ? AND provider = ? AND provider_id = ? LIMIT 1`,
listID, provider, providerID))
}
func (it *Item) normalize() error {
it.Title = strings.TrimSpace(it.Title)
it.Subtitle = strings.TrimSpace(it.Subtitle)
it.Year = strings.TrimSpace(it.Year)
it.Note = strings.TrimSpace(it.Note)
it.ExternalURL = strings.TrimSpace(it.ExternalURL)
if it.Title == "" {
return invalid("Title is required")
}
if len(it.Title) > 500 || len(it.Subtitle) > 500 || len(it.Year) > 20 || len(it.Note) > 10000 || len(it.ExternalURL) > 2000 {
return invalid("One of the fields is too long")
}
if it.ExternalURL != "" && !strings.HasPrefix(it.ExternalURL, "https://") && !strings.HasPrefix(it.ExternalURL, "http://") {
return invalid("Links must start with http:// or https://")
}
clean := map[string]string{}
for k, v := range it.Extra {
if v = strings.TrimSpace(v); v != "" && len(k) <= 50 && len(v) <= 500 {
clean[k] = v
}
}
it.Extra = clean
return nil
}
func extraJSON(m map[string]string) string {
b, _ := json.Marshal(m)
return string(b)
}
// AddItem inserts it into the list at rank (1-based); rank <= 0 or beyond the
// end appends. Items at or below the rank shift down by one.
func (s *Store) AddItem(ctx context.Context, it Item, rank int) (Item, error) {
if err := it.normalize(); err != nil {
return it, err
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return it, err
}
defer tx.Rollback()
var maxItems, count int
err = tx.QueryRowContext(ctx, `SELECT max_items, (SELECT COUNT(*) FROM items WHERE list_id = lists.id) FROM lists WHERE id = ?`, it.ListID).Scan(&maxItems, &count)
if errors.Is(err, sql.ErrNoRows) {
return it, ErrNotFound
}
if err != nil {
return it, err
}
if count >= maxItems {
return it, invalid("This list is full (%d of %d). Increase its size or remove an item first.", count, maxItems)
}
if rank <= 0 || rank > count+1 {
rank = count + 1
}
if _, err := tx.ExecContext(ctx, `UPDATE items SET rank = rank + 1 WHERE list_id = ? AND rank >= ?`, it.ListID, rank); err != nil {
return it, err
}
res, err := tx.ExecContext(ctx, `INSERT INTO items (list_id, rank, title, subtitle, year, image, image_source, provider, provider_id, external_url, note, extra)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
it.ListID, rank, it.Title, it.Subtitle, it.Year, it.Image, it.ImageSource, it.Provider, it.ProviderID, it.ExternalURL, it.Note, extraJSON(it.Extra))
if err != nil {
return it, err
}
if err := touchList(ctx, tx, it.ListID); err != nil {
return it, err
}
if err := tx.Commit(); err != nil {
return it, err
}
id, _ := res.LastInsertId()
return s.ItemByID(ctx, id)
}
// UpdateItem saves everything except rank and list.
func (s *Store) UpdateItem(ctx context.Context, it Item) (Item, error) {
if err := it.normalize(); err != nil {
return it, err
}
res, err := s.db.ExecContext(ctx, `UPDATE items SET title=?, subtitle=?, year=?, image=?, image_source=?, external_url=?, note=?, extra=?, updated_at=unixepoch() WHERE id=?`,
it.Title, it.Subtitle, it.Year, it.Image, it.ImageSource, it.ExternalURL, it.Note, extraJSON(it.Extra), it.ID)
if err != nil {
return it, err
}
if n, _ := res.RowsAffected(); n == 0 {
return it, ErrNotFound
}
cur, err := s.ItemByID(ctx, it.ID)
if err == nil {
_, err = s.db.ExecContext(ctx, `UPDATE lists SET updated_at = unixepoch() WHERE id = ?`, cur.ListID)
}
return cur, err
}
// MoveItem moves an item to a new 1-based rank, shifting the items between.
func (s *Store) MoveItem(ctx context.Context, id int64, newRank int) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
var listID int64
var oldRank, count int
err = tx.QueryRowContext(ctx, `SELECT list_id, rank, (SELECT COUNT(*) FROM items x WHERE x.list_id = items.list_id) FROM items WHERE id = ?`, id).Scan(&listID, &oldRank, &count)
if errors.Is(err, sql.ErrNoRows) {
return ErrNotFound
}
if err != nil {
return err
}
if newRank < 1 {
newRank = 1
}
if newRank > count {
newRank = count
}
if newRank == oldRank {
return nil
}
if newRank < oldRank {
_, err = tx.ExecContext(ctx, `UPDATE items SET rank = rank + 1 WHERE list_id = ? AND rank >= ? AND rank < ?`, listID, newRank, oldRank)
} else {
_, err = tx.ExecContext(ctx, `UPDATE items SET rank = rank - 1 WHERE list_id = ? AND rank > ? AND rank <= ?`, listID, oldRank, newRank)
}
if err != nil {
return err
}
if _, err := tx.ExecContext(ctx, `UPDATE items SET rank = ? WHERE id = ?`, newRank, id); err != nil {
return err
}
if err := touchList(ctx, tx, listID); err != nil {
return err
}
return tx.Commit()
}
func (s *Store) DeleteItem(ctx context.Context, id int64) (Item, error) {
it, err := s.ItemByID(ctx, id)
if err != nil {
return it, err
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return it, err
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx, `DELETE FROM items WHERE id = ?`, id); err != nil {
return it, err
}
if _, err := tx.ExecContext(ctx, `UPDATE items SET rank = rank - 1 WHERE list_id = ? AND rank > ?`, it.ListID, it.Rank); err != nil {
return it, err
}
if err := touchList(ctx, tx, it.ListID); err != nil {
return it, err
}
return it, tx.Commit()
}
// ImageInUse reports whether any item or setting still references a cached
// image file.
func (s *Store) ImageInUse(ctx context.Context, name string) (bool, error) {
var n int
err := s.db.QueryRowContext(ctx, `SELECT (SELECT COUNT(*) FROM items WHERE image = ?) + (SELECT COUNT(*) FROM settings WHERE value = ?)`, name, name).Scan(&n)
return n > 0, err
}
// ListImages returns the image file names used by a list's items.
func (s *Store) ListImages(ctx context.Context, listID int64) ([]string, error) {
rows, err := s.db.QueryContext(ctx, `SELECT DISTINCT image FROM items WHERE list_id = ? AND image != ''`, listID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []string
for rows.Next() {
var n string
if err := rows.Scan(&n); err != nil {
return nil, err
}
out = append(out, n)
}
return out, rows.Err()
}
func touchList(ctx context.Context, tx *sql.Tx, listID int64) error {
_, err := tx.ExecContext(ctx, `UPDATE lists SET updated_at = unixepoch() WHERE id = ?`, listID)
return err
}
+231
View File
@@ -0,0 +1,231 @@
package store
import (
"context"
"database/sql"
"errors"
"fmt"
"regexp"
"strings"
)
const (
MaxListSize = 65535
RecommendedListSize = 100
SoftListSizeWarning = 1000
)
type List struct {
ID int64 `json:"id"`
Slug string `json:"slug"`
Title string `json:"title"`
Category string `json:"category"`
Description string `json:"description"`
MaxItems int `json:"maxItems"`
Position int `json:"position"`
Visible bool `json:"visible"`
Layout string `json:"layout"` // "grid" or "rows"
ItemCount int `json:"itemCount"`
UpdatedAt int64 `json:"updatedAt"`
}
const listColumns = `l.id, l.slug, l.title, l.category, l.description, l.max_items, l.position, l.visible, l.layout, l.updated_at,
(SELECT COUNT(*) FROM items i WHERE i.list_id = l.id)`
func scanList(row interface{ Scan(...any) error }) (List, error) {
var l List
err := row.Scan(&l.ID, &l.Slug, &l.Title, &l.Category, &l.Description, &l.MaxItems, &l.Position, &l.Visible, &l.Layout, &l.UpdatedAt, &l.ItemCount)
if errors.Is(err, sql.ErrNoRows) {
return l, ErrNotFound
}
return l, err
}
func (s *Store) Lists(ctx context.Context, includeHidden bool) ([]List, error) {
q := `SELECT ` + listColumns + ` FROM lists l`
if !includeHidden {
q += ` WHERE l.visible = 1`
}
q += ` ORDER BY l.position, l.id`
rows, err := s.db.QueryContext(ctx, q)
if err != nil {
return nil, err
}
defer rows.Close()
var out []List
for rows.Next() {
l, err := scanList(rows)
if err != nil {
return nil, err
}
out = append(out, l)
}
return out, rows.Err()
}
func (s *Store) ListByID(ctx context.Context, id int64) (List, error) {
return scanList(s.db.QueryRowContext(ctx, `SELECT `+listColumns+` FROM lists l WHERE l.id = ?`, id))
}
func (s *Store) ListBySlug(ctx context.Context, slug string) (List, error) {
return scanList(s.db.QueryRowContext(ctx, `SELECT `+listColumns+` FROM lists l WHERE l.slug = ?`, slug))
}
// ValidationError is returned for bad user input; its message is safe to
// show to the user.
type ValidationError struct{ Msg string }
func (e ValidationError) Error() string { return e.Msg }
func invalid(format string, args ...any) error {
return ValidationError{Msg: fmt.Sprintf(format, args...)}
}
func (l *List) normalize() error {
l.Title = strings.TrimSpace(l.Title)
l.Description = strings.TrimSpace(l.Description)
if l.Title == "" {
return invalid("Title is required")
}
if len(l.Title) > 200 {
return invalid("Title is too long")
}
if _, ok := CategoryByKey(l.Category); !ok {
return invalid("Unknown category %q", l.Category)
}
if l.MaxItems < 1 || l.MaxItems > MaxListSize {
return invalid("List size must be between 1 and %d", MaxListSize)
}
if l.Layout != "rows" {
l.Layout = "grid"
}
return nil
}
var slugStrip = regexp.MustCompile(`[^a-z0-9]+`)
func slugify(s string) string {
s = slugStrip.ReplaceAllString(strings.ToLower(s), "-")
s = strings.Trim(s, "-")
if len(s) > 60 {
s = strings.Trim(s[:60], "-")
}
if s == "" {
s = "list"
}
return s
}
func (s *Store) uniqueSlug(ctx context.Context, base string, exceptID int64) (string, error) {
slug := base
for i := 2; ; i++ {
var n int
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM lists WHERE slug = ? AND id != ?`, slug, exceptID).Scan(&n); err != nil {
return "", err
}
if n == 0 {
return slug, nil
}
slug = fmt.Sprintf("%s-%d", base, i)
}
}
func (s *Store) CreateList(ctx context.Context, l List) (List, error) {
if err := l.normalize(); err != nil {
return l, err
}
// Default to the category name so the address stays sensible if the
// list's size (and so its "Top N" title) changes later.
base := slugify(l.Slug)
if strings.TrimSpace(l.Slug) == "" {
c, _ := CategoryByKey(l.Category)
base = slugify(c.Label)
}
slug, err := s.uniqueSlug(ctx, base, 0)
if err != nil {
return l, err
}
res, err := s.db.ExecContext(ctx, `INSERT INTO lists (slug, title, category, description, max_items, position, visible, layout)
VALUES (?, ?, ?, ?, ?, (SELECT COALESCE(MAX(position), 0) + 1 FROM lists), ?, ?)`,
slug, l.Title, l.Category, l.Description, l.MaxItems, l.Visible, l.Layout)
if err != nil {
return l, err
}
id, _ := res.LastInsertId()
return s.ListByID(ctx, id)
}
func (s *Store) UpdateList(ctx context.Context, l List) (List, error) {
if err := l.normalize(); err != nil {
return l, err
}
cur, err := s.ListByID(ctx, l.ID)
if err != nil {
return l, err
}
if l.MaxItems < cur.ItemCount {
return l, invalid("This list already has %d items; remove some before shrinking it to %d", cur.ItemCount, l.MaxItems)
}
if l.Category != cur.Category && cur.ItemCount > 0 {
return l, invalid("Can't change the category of a list that has items")
}
slug := cur.Slug
if strings.TrimSpace(l.Slug) != "" && slugify(l.Slug) != cur.Slug {
if slug, err = s.uniqueSlug(ctx, slugify(l.Slug), l.ID); err != nil {
return l, err
}
}
_, err = s.db.ExecContext(ctx, `UPDATE lists SET slug=?, title=?, category=?, description=?, max_items=?, visible=?, layout=?, updated_at=unixepoch() WHERE id=?`,
slug, l.Title, l.Category, l.Description, l.MaxItems, l.Visible, l.Layout, l.ID)
if err != nil {
return l, err
}
return s.ListByID(ctx, l.ID)
}
func (s *Store) DeleteList(ctx context.Context, id int64) error {
res, err := s.db.ExecContext(ctx, `DELETE FROM lists WHERE id = ?`, id)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
// ReorderLists sets list positions to match the order of ids.
func (s *Store) ReorderLists(ctx context.Context, ids []int64) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
for i, id := range ids {
if _, err := tx.ExecContext(ctx, `UPDATE lists SET position = ? WHERE id = ?`, i+1, id); err != nil {
return err
}
}
return tx.Commit()
}
// ListCovers returns up to n image file names per list, from the top ranks.
func (s *Store) ListCovers(ctx context.Context, n int) (map[int64][]string, error) {
rows, err := s.db.QueryContext(ctx, `SELECT list_id, image FROM (
SELECT list_id, image, ROW_NUMBER() OVER (PARTITION BY list_id ORDER BY rank) AS rn
FROM items WHERE image != '') WHERE rn <= ? ORDER BY list_id, rn`, n)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[int64][]string{}
for rows.Next() {
var id int64
var img string
if err := rows.Scan(&id, &img); err != nil {
return nil, err
}
out[id] = append(out[id], "/images/"+img)
}
return out, rows.Err()
}
+133
View File
@@ -0,0 +1,133 @@
package store
import (
"context"
"database/sql"
"errors"
"time"
)
// Setting keys.
const (
SettingSiteTitle = "site_title"
SettingOwnerName = "owner_name"
SettingTagline = "tagline"
SettingAvatar = "avatar" // cached image file name
SettingTheme = "theme"
SettingCustomCSS = "custom_css"
SettingPreviewCount = "preview_count" // items shown per list on the home page
SettingShowNotes = "show_notes"
SettingMusicProvider = "music_provider"
SettingTVProvider = "tv_provider"
SettingTMDBKey = "tmdb_api_key"
SettingTVDBKey = "tvdb_api_key"
SettingTVDBPin = "tvdb_pin"
SettingIGDBClientID = "igdb_client_id"
SettingIGDBClientSecret = "igdb_client_secret"
)
func (s *Store) Settings(ctx context.Context) (map[string]string, error) {
rows, err := s.db.QueryContext(ctx, `SELECT key, value FROM settings`)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[string]string{}
for rows.Next() {
var k, v string
if err := rows.Scan(&k, &v); err != nil {
return nil, err
}
out[k] = v
}
return out, rows.Err()
}
func (s *Store) SetSettings(ctx context.Context, kv map[string]string) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
for k, v := range kv {
if _, err := tx.ExecContext(ctx, `INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value`, k, v); err != nil {
return err
}
}
return tx.Commit()
}
// ---- users & sessions ----
type User struct {
ID int64
Username string
PasswordHash string
}
func (s *Store) UserCount(ctx context.Context) (int, error) {
var n int
err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users`).Scan(&n)
return n, err
}
func (s *Store) CreateUser(ctx context.Context, username, hash string) error {
_, err := s.db.ExecContext(ctx, `INSERT INTO users (username, password_hash) VALUES (?, ?)`, username, hash)
return err
}
func (s *Store) UserByName(ctx context.Context, username string) (User, error) {
var u User
err := s.db.QueryRowContext(ctx, `SELECT id, username, password_hash FROM users WHERE username = ?`, username).Scan(&u.ID, &u.Username, &u.PasswordHash)
if errors.Is(err, sql.ErrNoRows) {
return u, ErrNotFound
}
return u, err
}
func (s *Store) UserByID(ctx context.Context, id int64) (User, error) {
var u User
err := s.db.QueryRowContext(ctx, `SELECT id, username, password_hash FROM users WHERE id = ?`, id).Scan(&u.ID, &u.Username, &u.PasswordHash)
if errors.Is(err, sql.ErrNoRows) {
return u, ErrNotFound
}
return u, err
}
func (s *Store) UpdateUser(ctx context.Context, id int64, username, hash string) error {
_, err := s.db.ExecContext(ctx, `UPDATE users SET username = ?, password_hash = ? WHERE id = ?`, username, hash, id)
return err
}
func (s *Store) CreateSession(ctx context.Context, tokenHash string, userID int64, expires time.Time) error {
_, err := s.db.ExecContext(ctx, `INSERT INTO sessions (token_hash, user_id, expires_at) VALUES (?, ?, ?)`, tokenHash, userID, expires.Unix())
return err
}
// SessionUser returns the user for a live session.
func (s *Store) SessionUser(ctx context.Context, tokenHash string) (User, error) {
var u User
err := s.db.QueryRowContext(ctx, `SELECT u.id, u.username, u.password_hash FROM sessions s JOIN users u ON u.id = s.user_id
WHERE s.token_hash = ? AND s.expires_at > unixepoch()`, tokenHash).Scan(&u.ID, &u.Username, &u.PasswordHash)
if errors.Is(err, sql.ErrNoRows) {
return u, ErrNotFound
}
return u, err
}
func (s *Store) DeleteSession(ctx context.Context, tokenHash string) error {
_, err := s.db.ExecContext(ctx, `DELETE FROM sessions WHERE token_hash = ?`, tokenHash)
return err
}
// DeleteUserSessions logs a user out everywhere except the given session.
func (s *Store) DeleteUserSessions(ctx context.Context, userID int64, keepTokenHash string) error {
_, err := s.db.ExecContext(ctx, `DELETE FROM sessions WHERE user_id = ? AND token_hash != ?`, userID, keepTokenHash)
return err
}
func (s *Store) PruneSessions(ctx context.Context) error {
_, err := s.db.ExecContext(ctx, `DELETE FROM sessions WHERE expires_at <= unixepoch()`)
return err
}
+114
View File
@@ -0,0 +1,114 @@
// Package store is the SQLite persistence layer.
package store
import (
"context"
"database/sql"
"errors"
"fmt"
"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, "topdeck.db")
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() }
// 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);
`,
}
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
}
+153
View File
@@ -0,0 +1,153 @@
package store
import (
"context"
"errors"
"fmt"
"strings"
"testing"
)
func newTestStore(t *testing.T) *Store {
t.Helper()
s, err := Open(t.TempDir())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { s.Close() })
return s
}
func titles(t *testing.T, s *Store, listID int64) string {
t.Helper()
items, err := s.Items(context.Background(), listID, 0, 0)
if err != nil {
t.Fatal(err)
}
var out []string
for i, it := range items {
if it.Rank != i+1 {
t.Fatalf("rank gap: item %q has rank %d at position %d", it.Title, it.Rank, i+1)
}
out = append(out, it.Title)
}
return strings.Join(out, ",")
}
func TestRanking(t *testing.T) {
ctx := context.Background()
s := newTestStore(t)
l, err := s.CreateList(ctx, List{Title: "Top 5 Movies", Category: "movie", MaxItems: 5, Visible: true})
if err != nil {
t.Fatal(err)
}
if l.Slug != "movies" {
t.Errorf("slug = %q, want movies", l.Slug)
}
ids := map[string]int64{}
for _, title := range []string{"A", "B", "C", "D"} {
it, err := s.AddItem(ctx, Item{ListID: l.ID, Title: title}, 0)
if err != nil {
t.Fatal(err)
}
ids[title] = it.ID
}
if got := titles(t, s, l.ID); got != "A,B,C,D" {
t.Fatalf("append: %s", got)
}
e, err := s.AddItem(ctx, Item{ListID: l.ID, Title: "E"}, 2)
if err != nil {
t.Fatal(err)
}
ids["E"] = e.ID
if got := titles(t, s, l.ID); got != "A,E,B,C,D" {
t.Fatalf("insert at 2: %s", got)
}
var ve ValidationError
if _, err := s.AddItem(ctx, Item{ListID: l.ID, Title: "F"}, 0); !errors.As(err, &ve) {
t.Fatalf("expected full-list error, got %v", err)
}
steps := []struct {
id string
rank int
want string
}{
{"A", 5, "E,B,C,D,A"},
{"D", 1, "D,E,B,C,A"},
{"B", 99, "D,E,C,A,B"}, // clamped to the end
{"C", 0, "C,D,E,A,B"}, // clamped to the top
}
for _, st := range steps {
if err := s.MoveItem(ctx, ids[st.id], st.rank); err != nil {
t.Fatal(err)
}
if got := titles(t, s, l.ID); got != st.want {
t.Fatalf("move %s to %d: got %s, want %s", st.id, st.rank, got, st.want)
}
}
if _, err := s.DeleteItem(ctx, ids["E"]); err != nil {
t.Fatal(err)
}
if got := titles(t, s, l.ID); got != "C,D,A,B" {
t.Fatalf("delete: %s", got)
}
if _, err := s.UpdateList(ctx, List{ID: l.ID, Title: "x", Category: "movie", MaxItems: 3}); !errors.As(err, &ve) {
t.Fatalf("expected shrink error, got %v", err)
}
if _, err := s.UpdateList(ctx, List{ID: l.ID, Title: "x", Category: "tv", MaxItems: 5}); !errors.As(err, &ve) {
t.Fatalf("expected category change error, got %v", err)
}
}
func TestListLimits(t *testing.T) {
ctx := context.Background()
s := newTestStore(t)
for _, n := range []int{0, MaxListSize + 1} {
if _, err := s.CreateList(ctx, List{Title: "x", Category: "game", MaxItems: n}); err == nil {
t.Errorf("size %d accepted", n)
}
}
if _, err := s.CreateList(ctx, List{Title: "x", Category: "game", MaxItems: MaxListSize}); err != nil {
t.Errorf("max size rejected: %v", err)
}
if _, err := s.CreateList(ctx, List{Title: "x", Category: "podcast", MaxItems: 5}); err == nil {
t.Error("unknown category accepted")
}
// Second list in a category gets a unique slug.
l, err := s.CreateList(ctx, List{Title: "y", Category: "game", MaxItems: 5})
if err != nil {
t.Fatal(err)
}
if l.Slug != "video-games-2" {
t.Errorf("slug = %q", l.Slug)
}
}
func BenchmarkMoveInLargeList(b *testing.B) {
ctx := context.Background()
s, err := Open(b.TempDir())
if err != nil {
b.Fatal(err)
}
defer s.Close()
l, _ := s.CreateList(ctx, List{Title: "big", Category: "song", MaxItems: 5000})
var first int64
for i := 0; i < 5000; i++ {
it, err := s.AddItem(ctx, Item{ListID: l.ID, Title: fmt.Sprint(i)}, 0)
if err != nil {
b.Fatal(err)
}
if i == 0 {
first = it.ID
}
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
s.MoveItem(ctx, first, 5000-(i%2)*4999)
}
}