Files
cdnutterandClaude Opus 5.5 88373176f2 Allow empty places in rankings; rebuild the list editor
Entries now sit at places 1…max with gaps allowed (#28, nothing at #29,
#30). Moving onto an empty place just puts the entry there; onto a taken
place, the entries in between shift one step, stopping at the first
empty place, so lists without gaps reorder exactly as before. Inserts
push down only to the next gap; deleting can close up or leave the place
empty. New endpoint POST /api/lists/{id}/places inserts or removes empty
places or closes every gap; move and delete reply with every entry's
place. The podium shows an empty step for an empty #2 or #3, and a list
can't shrink below its last used place.

The editor loads every entry (no paging) and applies edits instantly
with the same rules (places.js mirrors places.go), queueing saves in the
background and reconciling with the server's reply. Type a place and
press Enter (↑/↓ move between boxes, Alt+↑/↓ moves the entry), drag, or
use the row menu. Empty runs show as rows with Add here and Remove, and
a toolbar shows the gap count, Close gaps and a Go to # box. Clicking an
entry opens it for editing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:24:00 -07:00

769 lines
20 KiB
Go

package server
import (
"context"
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"unicode/utf8"
"github.com/cdnutter/canon/internal/auth"
"github.com/cdnutter/canon/internal/imgcache"
"github.com/cdnutter/canon/internal/providers"
"github.com/cdnutter/canon/internal/store"
)
// ---- lists ----
func (s *Server) apiLists(w http.ResponseWriter, r *http.Request) {
lists, err := s.store.Lists(r.Context(), true)
if err != nil {
s.writeErr(w, err)
return
}
if lists == nil {
lists = []store.List{}
}
writeJSON(w, http.StatusOK, lists)
}
func (s *Server) apiCreateList(w http.ResponseWriter, r *http.Request) {
var l store.List
if err := decodeJSON(r, &l); err != nil {
s.writeErr(w, err)
return
}
l, err := s.store.CreateList(r.Context(), l)
if err != nil {
s.writeErr(w, err)
return
}
writeJSON(w, http.StatusCreated, l)
}
func (s *Server) apiUpdateList(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r)
if err != nil {
s.writeErr(w, err)
return
}
var l store.List
if err := decodeJSON(r, &l); err != nil {
s.writeErr(w, err)
return
}
l.ID = id
l, err = s.store.UpdateList(r.Context(), l)
if err != nil {
s.writeErr(w, err)
return
}
writeJSON(w, http.StatusOK, l)
}
func (s *Server) apiDeleteList(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r)
if err != nil {
s.writeErr(w, err)
return
}
images, err := s.store.ListImages(r.Context(), id)
if err != nil {
s.writeErr(w, err)
return
}
if err := s.store.DeleteList(r.Context(), id); err != nil {
s.writeErr(w, err)
return
}
for _, img := range images {
s.releaseImage(r.Context(), img)
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) apiReorderLists(w http.ResponseWriter, r *http.Request) {
var body struct {
IDs []int64 `json:"ids"`
}
if err := decodeJSON(r, &body); err != nil {
s.writeErr(w, err)
return
}
if err := s.store.ReorderLists(r.Context(), body.IDs); err != nil {
s.writeErr(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// ---- items ----
func (s *Server) apiItems(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r)
if err != nil {
s.writeErr(w, err)
return
}
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
if limit <= 0 || limit > 500 {
limit = 100
}
items, err := s.store.Items(r.Context(), id, max(0, offset), limit)
if err != nil {
s.writeErr(w, err)
return
}
writeJSON(w, http.StatusOK, items)
}
type itemInput struct {
Title string `json:"title"`
Subtitle string `json:"subtitle"`
Year string `json:"year"`
Description string `json:"description"`
Note string `json:"note"`
ExternalURL string `json:"externalUrl"`
Extra map[string]string `json:"extra"`
ImageURL string `json:"imageUrl"`
ClearImage bool `json:"clearImage"`
// CustomArt marks an added entry's image as the owner's own choice
// rather than the search result's.
CustomArt bool `json:"customArt"`
// Only used when adding.
Provider string `json:"provider"`
ProviderID string `json:"providerId"`
Rank int `json:"rank"`
Details bool `json:"details"`
Force bool `json:"force"`
}
type itemResponse struct {
Item store.Item `json:"item"`
Warning string `json:"warning,omitempty"`
}
func (s *Server) apiAddItem(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
listID, err := pathID(r)
if err != nil {
s.writeErr(w, err)
return
}
var in itemInput
if err := decodeJSON(r, &in); err != nil {
s.writeErr(w, err)
return
}
list, err := s.store.ListByID(ctx, listID)
if err != nil {
s.writeErr(w, err)
return
}
if list.ItemCount >= list.MaxItems {
s.writeErr(w, store.ValidationError{Msg: fmt.Sprintf("This list is full (%d of %d). Increase its size or remove an item first.", list.ItemCount, list.MaxItems)})
return
}
if !in.Force {
if dup, err := s.store.FindItemByProvider(ctx, listID, in.Provider, in.ProviderID); err == nil {
writeJSON(w, http.StatusConflict, map[string]any{
"error": fmt.Sprintf("“%s” is already on this list at #%d.", dup.Title, dup.Rank),
"rank": dup.Rank,
})
return
}
}
res := providers.Result{
Provider: in.Provider, ProviderID: in.ProviderID, Title: in.Title, Subtitle: in.Subtitle,
Year: in.Year, ImageURL: in.ImageURL, ExternalURL: in.ExternalURL, Extra: in.Extra,
Overview: in.Description,
}
if in.Details && in.Provider != "" {
res = s.fillDetails(ctx, list.Category, res)
}
it := store.Item{
ListID: listID, Title: res.Title, Subtitle: res.Subtitle, Year: res.Year,
Provider: res.Provider, ProviderID: res.ProviderID, ExternalURL: res.ExternalURL,
Description: res.Overview, Note: in.Note, Extra: res.Extra,
}
var warning string
if res.ImageURL != "" {
name, err := s.fetchImage(ctx, res.ImageURL)
if err != nil {
warning = "Added without artwork: " + err.Error()
} else {
it.Image, it.ImageSource, it.CustomArt = name, res.ImageURL, in.CustomArt
}
}
s.matchDeezer(ctx, list.Category, &it)
saved, err := s.store.AddItem(ctx, it, in.Rank)
if err != nil {
s.releaseImage(ctx, it.Image)
s.writeErr(w, err)
return
}
writeJSON(w, http.StatusCreated, itemResponse{Item: saved, Warning: warning})
}
// fillDetails asks the provider for richer metadata. Failures are logged
// and the search result is used as-is.
func (s *Server) fillDetails(ctx context.Context, category string, res providers.Result) providers.Result {
p, err := s.providers.For(ctx, category)
if err != nil {
return res
}
d, ok := p.(providers.Detailer)
if !ok {
return res
}
dctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
full, err := d.Details(dctx, res)
if err != nil {
s.log.Warn("fetching details", "provider", res.Provider, "id", res.ProviderID, "err", err)
return res
}
return full
}
func (s *Server) fetchImage(ctx context.Context, url string) (string, error) {
fctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
return s.images.Fetch(fctx, url)
}
// releaseImage deletes a cached image once nothing references it.
func (s *Server) releaseImage(ctx context.Context, name string) {
if name == "" {
return
}
inUse, err := s.store.ImageInUse(ctx, name)
if err != nil || inUse {
return
}
if err := s.images.Remove(name); err != nil {
s.log.Warn("removing image", "name", name, "err", err)
}
}
func (s *Server) apiUpdateItem(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
id, err := pathID(r)
if err != nil {
s.writeErr(w, err)
return
}
var in itemInput
if err := decodeJSON(r, &in); err != nil {
s.writeErr(w, err)
return
}
cur, err := s.store.ItemByID(ctx, id)
if err != nil {
s.writeErr(w, err)
return
}
it := cur
it.Title, it.Subtitle, it.Year, it.Note, it.ExternalURL = in.Title, in.Subtitle, in.Year, in.Note, in.ExternalURL
it.Description = in.Description
if in.Extra != nil {
it.Extra = in.Extra
}
// Removing art or swapping it in by hand makes it the owner's choice,
// which a refresh leaves alone unless asked.
if in.ClearImage {
it.Image, it.ImageSource, it.CustomArt = "", "", true
}
if url := strings.TrimSpace(in.ImageURL); url != "" && url != cur.ImageSource {
name, err := s.fetchImage(ctx, url)
if err != nil {
s.writeErr(w, store.ValidationError{Msg: "Couldn't use that image: " + err.Error()})
return
}
it.Image, it.ImageSource, it.CustomArt = name, url, true
}
if it.Title != cur.Title || it.Subtitle != cur.Subtitle || it.Extra["deezer"] == "" {
if l, err := s.store.ListByID(ctx, it.ListID); err == nil {
s.matchDeezer(ctx, l.Category, &it)
}
}
saved, err := s.store.UpdateItem(ctx, it)
if err != nil {
if it.Image != cur.Image {
s.releaseImage(ctx, it.Image)
}
s.writeErr(w, err)
return
}
if cur.Image != saved.Image {
s.releaseImage(ctx, cur.Image)
}
writeJSON(w, http.StatusOK, itemResponse{Item: saved})
}
func (s *Server) saveUpload(r *http.Request) (string, error) {
r.Body = http.MaxBytesReader(nil, r.Body, imgcache.MaxBytes+1<<20)
f, _, err := r.FormFile("file")
if err != nil {
return "", store.ValidationError{Msg: "Choose an image file (up to 15 MB)."}
}
defer f.Close()
name, err := s.images.Save(f)
if err != nil && !errors.Is(err, imgcache.ErrNotImage) {
return "", store.ValidationError{Msg: err.Error()}
}
return name, err
}
func (s *Server) apiUploadItemImage(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
id, err := pathID(r)
if err != nil {
s.writeErr(w, err)
return
}
cur, err := s.store.ItemByID(ctx, id)
if err != nil {
s.writeErr(w, err)
return
}
name, err := s.saveUpload(r)
if err != nil {
s.writeErr(w, err)
return
}
it := cur
it.Image, it.ImageSource, it.CustomArt = name, "upload", true
saved, err := s.store.UpdateItem(ctx, it)
if err != nil {
s.releaseImage(ctx, name)
s.writeErr(w, err)
return
}
if cur.Image != name {
s.releaseImage(ctx, cur.Image)
}
writeJSON(w, http.StatusOK, itemResponse{Item: saved})
}
func (s *Server) apiMoveItem(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r)
if err != nil {
s.writeErr(w, err)
return
}
var body struct {
Rank int `json:"rank"`
}
if err := decodeJSON(r, &body); err != nil {
s.writeErr(w, err)
return
}
places, err := s.store.MoveItem(r.Context(), id, body.Rank)
if err != nil {
s.writeErr(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"places": places})
}
// apiPlaces changes a list's empty places: insert one, remove one, or close
// every gap. It replies with where each entry now sits.
func (s *Server) apiPlaces(w http.ResponseWriter, r *http.Request) {
listID, err := pathID(r)
if err != nil {
s.writeErr(w, err)
return
}
var body struct {
Action string `json:"action"`
At int `json:"at"`
Count int `json:"count"` // remove: how many empty places
}
if err := decodeJSON(r, &body); err != nil {
s.writeErr(w, err)
return
}
var places []store.Place
switch body.Action {
case "insert":
places, err = s.store.InsertPlace(r.Context(), listID, body.At)
case "remove":
places, err = s.store.RemovePlace(r.Context(), listID, body.At, body.Count)
case "compact":
places, err = s.store.CloseGaps(r.Context(), listID)
default:
err = store.ValidationError{Msg: `action must be "insert", "remove" or "compact"`}
}
if err != nil {
s.writeErr(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"places": places})
}
func (s *Server) apiDeleteItem(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r)
if err != nil {
s.writeErr(w, err)
return
}
// ?keep=1 leaves the place empty instead of moving the entries below up.
it, err := s.store.DeleteItem(r.Context(), id, r.URL.Query().Get("keep") != "1")
if err != nil {
s.writeErr(w, err)
return
}
s.releaseImage(r.Context(), it.Image)
places, err := s.store.Places(r.Context(), it.ListID)
if err != nil {
s.writeErr(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"places": places})
}
// ---- search ----
type searchResult struct {
providers.Result
ExistingRank int `json:"existingRank,omitempty"`
PreviewRef string `json:"previewRef,omitempty"` // Deezer id to audition before adding
}
func (s *Server) apiSearch(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
q := strings.TrimSpace(r.URL.Query().Get("q"))
category := r.URL.Query().Get("category")
if q == "" || utf8.RuneCountInString(q) > 200 {
writeError(w, http.StatusBadRequest, "Type something to search for.")
return
}
p, err := s.providers.For(ctx, category)
if err != nil {
s.writeErr(w, err)
return
}
sctx, cancel := context.WithTimeout(ctx, 20*time.Second)
defer cancel()
results, err := p.Search(sctx, q)
if err != nil {
s.log.Warn("search failed", "provider", p.Name(), "err", err)
writeError(w, http.StatusBadGateway, fmt.Sprintf("%s search failed: %v", p.Label(), err))
return
}
listID, _ := strconv.ParseInt(r.URL.Query().Get("list"), 10, 64)
out := make([]searchResult, 0, len(results))
for _, res := range results {
sr := searchResult{Result: res}
if res.Provider == "deezer" && hasPreviews(category) {
sr.PreviewRef = res.ProviderID
}
if listID > 0 {
if it, err := s.store.FindItemByProvider(ctx, listID, res.Provider, res.ProviderID); err == nil {
sr.ExistingRank = it.Rank
}
}
out = append(out, sr)
}
writeJSON(w, http.StatusOK, map[string]any{"provider": p.Name(), "label": p.Label(), "results": out})
}
// ---- settings ----
type secretStatus struct {
Set bool `json:"set"`
Hint string `json:"hint"`
Locked bool `json:"locked"` // provided by an environment variable
}
var secretKeys = []string{store.SettingTMDBKey, store.SettingTVDBKey, store.SettingTVDBPin, store.SettingIGDBClientID, store.SettingIGDBClientSecret}
func (s *Server) envSecret(key string) string {
switch key {
case store.SettingTMDBKey:
return s.cfg.TMDBKey
case store.SettingTVDBKey:
return s.cfg.TVDBKey
case store.SettingTVDBPin:
return s.cfg.TVDBPin
case store.SettingIGDBClientID:
return s.cfg.IGDBClientID
case store.SettingIGDBClientSecret:
return s.cfg.IGDBClientSecret
}
return ""
}
func hint(v string) string {
if len(v) <= 4 {
return strings.Repeat("•", len(v))
}
return "••••" + v[len(v)-4:]
}
func (s *Server) apiSettings(w http.ResponseWriter, r *http.Request) {
set, err := s.store.Settings(r.Context())
if err != nil {
s.writeErr(w, err)
return
}
site := s.site(r.Context())
secrets := map[string]secretStatus{}
for _, k := range secretKeys {
v, locked := set[k], false
if env := s.envSecret(k); env != "" {
v, locked = env, true
}
secrets[k] = secretStatus{Set: v != "", Hint: hint(v), Locked: locked}
}
u, _ := s.currentUser(r)
writeJSON(w, http.StatusOK, map[string]any{
"siteTitle": set[store.SettingSiteTitle],
"siteTitleShown": site.Title,
"ownerName": site.OwnerName,
"tagline": site.Tagline,
"avatar": site.AvatarURL,
"theme": site.Theme,
"customCss": set[store.SettingCustomCSS],
"previewCount": site.PreviewCount,
"showNotes": site.ShowNotes,
"showPreviews": site.ShowPreviews,
"musicProvider": orDefault(set[store.SettingMusicProvider], "both"),
"tvProvider": orDefault(set[store.SettingTVProvider], "tvdb"),
"secrets": secrets,
"themes": Themes,
"username": u.Username,
"sitePassword": set[store.SettingSitePassword] != "",
})
}
func orDefault(v, def string) string {
if v == "" {
return def
}
return v
}
func (s *Server) apiUpdateSettings(w http.ResponseWriter, r *http.Request) {
var in map[string]any
if err := decodeJSON(r, &in); err != nil {
s.writeErr(w, err)
return
}
out := map[string]string{}
str := func(field, key string, maxLen int) error {
v, ok := in[field]
if !ok {
return nil
}
sv, ok := v.(string)
if !ok {
return store.ValidationError{Msg: field + " must be text"}
}
sv = strings.TrimSpace(sv)
if len(sv) > maxLen {
return store.ValidationError{Msg: fmt.Sprintf("%s is too long (max %d characters)", field, maxLen)}
}
out[key] = sv
return nil
}
checks := []error{
str("siteTitle", store.SettingSiteTitle, 100),
str("ownerName", store.SettingOwnerName, 100),
str("tagline", store.SettingTagline, 300),
str("theme", store.SettingTheme, 50),
str("customCss", store.SettingCustomCSS, 200_000),
str("musicProvider", store.SettingMusicProvider, 20),
str("tvProvider", store.SettingTVProvider, 20),
}
for _, k := range secretKeys {
checks = append(checks, str(k, k, 500))
}
for _, err := range checks {
if err != nil {
s.writeErr(w, err)
return
}
}
if v, ok := out[store.SettingTheme]; ok && !validTheme(v) {
s.writeErr(w, store.ValidationError{Msg: "Unknown theme"})
return
}
if v, ok := out[store.SettingMusicProvider]; ok && v != "both" && v != "deezer" && v != "musicbrainz" {
s.writeErr(w, store.ValidationError{Msg: "Unknown music provider"})
return
}
if v, ok := out[store.SettingTVProvider]; ok && v != "tvdb" && v != "tmdb" {
s.writeErr(w, store.ValidationError{Msg: "Unknown TV provider"})
return
}
if v, ok := in["previewCount"]; ok {
n, ok := v.(float64)
if !ok || n < 0 || n > 50 || n != float64(int(n)) {
s.writeErr(w, store.ValidationError{Msg: "Home page preview must be between 0 and 50 items"})
return
}
out[store.SettingPreviewCount] = strconv.Itoa(int(n))
}
if v, ok := in["showPreviews"]; ok {
b, ok := v.(bool)
if !ok {
s.writeErr(w, store.ValidationError{Msg: "showPreviews must be true or false"})
return
}
out[store.SettingShowPreviews] = strconv.FormatBool(b)
}
if v, ok := in["showNotes"]; ok {
b, ok := v.(bool)
if !ok {
s.writeErr(w, store.ValidationError{Msg: "showNotes must be true or false"})
return
}
out[store.SettingShowNotes] = strconv.FormatBool(b)
}
if err := s.store.SetSettings(r.Context(), out); err != nil {
s.writeErr(w, err)
return
}
s.apiSettings(w, r)
}
func (s *Server) apiUploadAvatar(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
name, err := s.saveUpload(r)
if err != nil {
s.writeErr(w, err)
return
}
set, err := s.store.Settings(ctx)
if err != nil {
s.writeErr(w, err)
return
}
old := set[store.SettingAvatar]
if err := s.store.SetSettings(ctx, map[string]string{store.SettingAvatar: name}); err != nil {
s.writeErr(w, err)
return
}
if old != name {
s.releaseImage(ctx, old)
}
writeJSON(w, http.StatusOK, map[string]string{"avatar": "/images/" + name})
}
func (s *Server) apiDeleteAvatar(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
set, err := s.store.Settings(ctx)
if err != nil {
s.writeErr(w, err)
return
}
if err := s.store.SetSettings(ctx, map[string]string{store.SettingAvatar: ""}); err != nil {
s.writeErr(w, err)
return
}
s.releaseImage(ctx, set[store.SettingAvatar])
w.WriteHeader(http.StatusNoContent)
}
var testQueries = map[string]string{
"movie": "Inception",
"tv": "Breaking Bad",
"album": "Abbey Road",
"song": "Bohemian Rhapsody",
"game": "Zelda",
"theater": "Hamilton",
"custom": "Chicago Bulls",
}
func (s *Server) apiTestIntegration(w http.ResponseWriter, r *http.Request) {
var body struct {
Category string `json:"category"`
}
if err := decodeJSON(r, &body); err != nil {
s.writeErr(w, err)
return
}
q, ok := testQueries[body.Category]
if !ok {
s.writeErr(w, store.ValidationError{Msg: "Unknown category"})
return
}
p, err := s.providers.For(r.Context(), body.Category)
if err != nil {
writeJSON(w, http.StatusOK, map[string]any{"ok": false, "message": err.Error()})
return
}
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
defer cancel()
results, err := p.Search(ctx, q)
if err != nil {
writeJSON(w, http.StatusOK, map[string]any{"ok": false, "message": fmt.Sprintf("%s: %v", p.Label(), err)})
return
}
msg := fmt.Sprintf("%s is working. Searching “%s” returned %d results.", p.Label(), q, len(results))
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "message": msg})
}
// ---- account ----
func (s *Server) apiUpdateAccount(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var body struct {
CurrentPassword string `json:"currentPassword"`
Username string `json:"username"`
NewPassword string `json:"newPassword"`
}
if err := decodeJSON(r, &body); err != nil {
s.writeErr(w, err)
return
}
su, _ := s.currentUser(r)
u, err := s.store.UserByID(ctx, su.ID)
if err != nil {
s.writeErr(w, err)
return
}
if !auth.CheckPassword(u.PasswordHash, body.CurrentPassword) {
writeError(w, http.StatusBadRequest, "Your current password isn't right.")
return
}
username := strings.TrimSpace(body.Username)
if username == "" || len(username) > 64 {
writeError(w, http.StatusBadRequest, "Pick a username (up to 64 characters).")
return
}
hash := u.PasswordHash
if body.NewPassword != "" {
if len(body.NewPassword) < auth.MinPasswordLength {
writeError(w, http.StatusBadRequest, "Use a password of at least 8 characters.")
return
}
if hash, err = auth.HashPassword(body.NewPassword); err != nil {
s.writeErr(w, err)
return
}
}
if err := s.store.UpdateUser(ctx, u.ID, username, hash); err != nil {
if strings.Contains(err.Error(), "UNIQUE") {
writeError(w, http.StatusBadRequest, "That username is taken.")
return
}
s.writeErr(w, err)
return
}
if body.NewPassword != "" {
// Sign out every other browser.
s.store.DeleteUserSessions(ctx, u.ID, su.TokenHash)
}
writeJSON(w, http.StatusOK, map[string]string{"username": username})
}