Files
canon/internal/store/items.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

272 lines
9.1 KiB
Go

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
CustomArt bool `json:"customArt"` // chosen by the owner, not the source
Provider string `json:"provider"`
ProviderID string `json:"providerId"`
ExternalURL string `json:"externalUrl"`
Description string `json:"description"` // from the source, editable
Note string `json:"note"` // the owner's "why it's here"
Extra map[string]string `json:"extra"`
}
const itemColumns = `id, list_id, rank, title, subtitle, year, image, image_source, image_custom, provider, provider_id, external_url, description, 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.CustomArt,
&it.Provider, &it.ProviderID, &it.ExternalURL, &it.Description, &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.Description = strings.TrimSpace(it.Description)
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.Description) > 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, image_custom, provider, provider_id, external_url, description, note, extra)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
it.ListID, rank, it.Title, it.Subtitle, it.Year, it.Image, it.ImageSource, it.CustomArt, it.Provider, it.ProviderID, it.ExternalURL, it.Description, 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=?, image_custom=?, external_url=?, description=?, note=?, extra=?, updated_at=unixepoch() WHERE id=?`,
it.Title, it.Subtitle, it.Year, it.Image, it.ImageSource, it.CustomArt, it.ExternalURL, it.Description, 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
}