Save source descriptions, add "Why it's #N", fix IGDB studio names

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>
This commit is contained in:
cdnutterandClaude Opus 5.5 committed 2026-09-24 18:05:20 -07:00
1 parent 3923444b3b
commit 7320e3f707
16 files changed
+207 -44

No files matched your search

+97 -16
View File
@@ -2,6 +2,7 @@ package providers
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
@@ -9,6 +10,7 @@ import (
"strings"
"sync"
"time"
"unicode"
)
// igdb searches the Internet Game Database, authenticating through Twitch.
@@ -59,7 +61,7 @@ func (g *igdb) token(ctx context.Context, fresh bool) (string, error) {
func (g *igdb) Search(ctx context.Context, query string) ([]Result, error) {
q := strings.NewReplacer(`\`, ``, `"`, `\"`).Replace(query)
body := `search "` + q + `";
fields name,first_release_date,cover.image_id,summary,url,involved_companies.developer,involved_companies.company.name,platforms.abbreviation,platforms.name;
fields name,first_release_date,cover.image_id,summary,url,involved_companies.developer,involved_companies.publisher,involved_companies.company.name,involved_companies.company.parent.name,involved_companies.company.parent.parent.name,involved_companies.company.parent.parent.parent.name,platforms.abbreviation,platforms.name;
limit 25;`
var games []struct {
ID int64 `json:"id"`
@@ -70,13 +72,8 @@ limit 25;`
Cover struct {
ImageID string `json:"image_id"`
} `json:"cover"`
InvolvedCompanies []struct {
Developer bool `json:"developer"`
Company struct {
Name string `json:"name"`
} `json:"company"`
} `json:"involved_companies"`
Platforms []struct {
InvolvedCompanies []igdbInvolved `json:"involved_companies"`
Platforms []struct {
Abbreviation string `json:"abbreviation"`
Name string `json:"name"`
} `json:"platforms"`
@@ -104,12 +101,7 @@ limit 25;`
}
out := make([]Result, 0, len(games))
for _, gm := range games {
var devs, plats []string
for _, ic := range gm.InvolvedCompanies {
if ic.Developer && ic.Company.Name != "" {
devs = append(devs, ic.Company.Name)
}
}
var plats []string
for _, p := range gm.Platforms {
name := p.Abbreviation
if name == "" {
@@ -121,9 +113,9 @@ limit 25;`
Provider: "igdb",
ProviderID: strconv.FormatInt(gm.ID, 10),
Title: gm.Name,
Subtitle: strings.Join(devs, ", "),
Subtitle: strings.Join(igdbStudios(gm.InvolvedCompanies), ", "),
ExternalURL: gm.URL,
Overview: truncate(gm.Summary, 600),
Overview: truncate(gm.Summary, maxDescription),
Extra: map[string]string{},
}
if gm.FirstReleaseDate > 0 {
@@ -139,3 +131,92 @@ limit 25;`
}
return out, nil
}
type igdbInvolved struct {
Developer bool `json:"developer"`
Publisher bool `json:"publisher"`
Company igdbCompany `json:"company"`
}
// igdbStudios names who made a game: its developers, or its publishers when
// IGDB lists no developer, with internal divisions credited to their company.
func igdbStudios(involved []igdbInvolved) []string {
var devs, pubs []string
for _, ic := range involved {
if ic.Company.Name == "" {
continue
}
if ic.Developer {
devs = appendUnique(devs, studioName(&ic.Company))
}
if ic.Publisher {
pubs = appendUnique(pubs, studioName(&ic.Company))
}
}
if len(devs) == 0 {
return pubs
}
return devs
}
// igdbCompany is a company with as much of its parent chain as was
// requested. IGDB sends an unexpanded parent as a bare ID, which is ignored.
type igdbCompany struct {
Name string
Parent *igdbCompany
}
func (c *igdbCompany) UnmarshalJSON(b []byte) error {
if len(b) == 0 || b[0] != '{' {
return nil
}
var raw struct {
Name string `json:"name"`
Parent *igdbCompany `json:"parent"`
}
if err := json.Unmarshal(b, &raw); err != nil {
return err
}
c.Name, c.Parent = raw.Name, raw.Parent
if c.Parent != nil && c.Parent.Name == "" {
c.Parent = nil
}
return nil
}
// studioName credits an internal division to the company it belongs to.
// IGDB lists the exact team, so Super Mario Sunshine's developer is
// "Nintendo EAD" and DK Bananza's is "Nintendo EPD Production Group No. 8".
// When a studio's name starts with the same word as its parent's, it's a
// division of that company and we show the parent ("Nintendo"). Studios
// with their own identity keep their name: Retro Studios (owned by
// Nintendo) or Naughty Dog (owned by Sony).
func studioName(c *igdbCompany) string {
for c.Parent != nil && sameFirstWord(c.Name, c.Parent.Name) {
c = c.Parent
}
return c.Name
}
func sameFirstWord(a, b string) bool {
first := func(s string) string {
f := strings.FieldsFunc(strings.ToLower(s), func(r rune) bool {
return !unicode.IsLetter(r) && !unicode.IsDigit(r)
})
if len(f) == 0 {
return ""
}
return f[0]
}
fa := first(a)
return fa != "" && fa == first(b)
}
func appendUnique(list []string, s string) []string {
for _, x := range list {
if x == s {
return list
}
}
return append(list, s)
}
+55
View File
@@ -0,0 +1,55 @@
package providers
import (
"encoding/json"
"testing"
)
func TestStudioName(t *testing.T) {
cases := []struct{ json, want string }{
// Internal divisions roll up to the company.
{`{"name":"Nintendo EAD","parent":{"name":"Nintendo"}}`, "Nintendo"},
{`{"name":"Nintendo EPD Production Group No. 8","parent":{"name":"Nintendo EPD","parent":{"name":"Nintendo"}}}`, "Nintendo"},
{`{"name":"Ubisoft Montreal","parent":{"name":"Ubisoft Entertainment"}}`, "Ubisoft Entertainment"},
// Studios with their own name stay themselves.
{`{"name":"Retro Studios","parent":{"name":"Nintendo"}}`, "Retro Studios"},
{`{"name":"Naughty Dog","parent":{"name":"Sony Interactive Entertainment"}}`, "Naughty Dog"},
{`{"name":"Landfall Games"}`, "Landfall Games"},
// Stop climbing once the name changes.
{`{"name":"Rockstar North","parent":{"name":"Rockstar Games","parent":{"name":"Take-Two Interactive"}}}`, "Rockstar Games"},
// An unexpanded parent arrives as a bare ID.
{`{"name":"Nintendo EAD Tokyo","parent":1234}`, "Nintendo EAD Tokyo"},
}
for _, c := range cases {
var co igdbCompany
if err := json.Unmarshal([]byte(c.json), &co); err != nil {
t.Fatalf("%s: %v", c.json, err)
}
if got := studioName(&co); got != c.want {
t.Errorf("%s → %q, want %q", c.json, got, c.want)
}
}
}
// A whole search response decodes, and co-developers from the same company
// collapse to one name.
func TestIGDBDevelopers(t *testing.T) {
body := `[{"id":1,"name":"Super Mario Sunshine","involved_companies":[
{"developer":true,"publisher":false,"company":{"name":"Nintendo EAD","parent":{"name":"Nintendo"}}},
{"developer":true,"publisher":false,"company":{"name":"Nintendo EAD Tokyo","parent":{"name":"Nintendo EAD","parent":{"name":"Nintendo"}}}},
{"developer":false,"publisher":true,"company":{"name":"Nintendo"}}]},
{"id":2,"name":"Publisher only","involved_companies":[{"developer":false,"publisher":true,"company":{"name":"Devolver Digital"}}]}]`
var games []struct {
InvolvedCompanies []igdbInvolved `json:"involved_companies"`
}
if err := json.Unmarshal([]byte(body), &games); err != nil {
t.Fatal(err)
}
want := []string{"Nintendo", "Devolver Digital"}
for i, g := range games {
devs := igdbStudios(g.InvolvedCompanies)
if len(devs) != 1 || devs[0] != want[i] {
t.Errorf("game %d developers = %v, want [%s]", i, devs, want[i])
}
}
}
+3 -3
View File
@@ -105,7 +105,7 @@ func (d *deezer) Search(ctx context.Context, query string) ([]Result, error) {
r.Extra["type"] = strings.ToUpper(a.RecordType[:1]) + a.RecordType[1:]
}
if a.NbTracks > 0 {
r.Overview = fmt.Sprintf("%d tracks", a.NbTracks)
r.Hint = fmt.Sprintf("%d tracks", a.NbTracks)
}
out = append(out, r)
}
@@ -126,7 +126,7 @@ func (d *deezer) Search(ctx context.Context, query string) ([]Result, error) {
Subtitle: t.Artist.Name,
ImageURL: cover(t.Album),
ExternalURL: t.Link,
Overview: "From " + t.Album.Title,
Hint: "From " + t.Album.Title,
Extra: map[string]string{"album": t.Album.Title},
})
}
@@ -267,7 +267,7 @@ func (m *musicbrainz) Search(ctx context.Context, query string) ([]Result, error
if i == 0 || rel.ReleaseGroup.PrimaryType == "Album" {
r.ImageURL = "https://coverartarchive.org/release-group/" + rel.ReleaseGroup.ID + "/front-500"
r.Extra["album"] = rel.Title
r.Overview = "From " + rel.Title
r.Hint = "From " + rel.Title
if rel.ReleaseGroup.PrimaryType == "Album" {
break
}
+5 -1
View File
@@ -14,6 +14,9 @@ import (
"time"
)
// maxDescription caps how much of a source's description is kept.
const maxDescription = 2000
const UserAgent = "Canon/0.1 (self-hosted favorites list; https://git.iamchrisama.com/cdnutter/canon)"
// Result is one search hit, already shaped like a list item.
@@ -25,7 +28,8 @@ type Result struct {
Year string `json:"year"`
ImageURL string `json:"imageUrl"`
ExternalURL string `json:"externalUrl"`
Overview string `json:"overview"`
Overview string `json:"overview"` // the source's description, saved with the item
Hint string `json:"hint"` // extra context for search results only, e.g. "12 tracks"
Extra map[string]string `json:"extra,omitempty"`
}
+1 -1
View File
@@ -75,7 +75,7 @@ func (t *tmdb) Search(ctx context.Context, query string) ([]Result, error) {
Year: yearOf(date),
ImageURL: tmdbImage(h.PosterPath),
ExternalURL: "https://www.themoviedb.org/" + t.kind + "/" + id,
Overview: truncate(h.Overview, 600),
Overview: truncate(h.Overview, maxDescription),
})
}
return out, nil
+1 -1
View File
@@ -123,7 +123,7 @@ func (t *tvdb) Search(ctx context.Context, query string) ([]Result, error) {
Year: year,
ImageURL: img,
ExternalURL: ext,
Overview: truncate(overview, 600),
Overview: truncate(overview, maxDescription),
})
}
return out, nil
+1 -1
View File
@@ -99,7 +99,7 @@ func (w *wikipedia) search(ctx context.Context, query string) ([]Result, error)
Year: yearOf(p.Description),
ImageURL: img,
ExternalURL: p.FullURL,
Overview: truncate(p.Extract, 600),
Overview: truncate(p.Extract, maxDescription),
})
}
return out, nil
+4 -1
View File
@@ -125,6 +125,7 @@ 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"`
@@ -178,6 +179,7 @@ func (s *Server) apiAddItem(w http.ResponseWriter, r *http.Request) {
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)
@@ -186,7 +188,7 @@ func (s *Server) apiAddItem(w http.ResponseWriter, r *http.Request) {
it := store.Item{
ListID: listID, Title: res.Title, Subtitle: res.Subtitle, Year: res.Year,
Provider: res.Provider, ProviderID: res.ProviderID, ExternalURL: res.ExternalURL,
Note: in.Note, Extra: res.Extra,
Description: res.Overview, Note: in.Note, Extra: res.Extra,
}
var warning string
if res.ImageURL != "" {
@@ -266,6 +268,7 @@ func (s *Server) apiUpdateItem(w http.ResponseWriter, r *http.Request) {
}
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
}
+2
View File
@@ -18,6 +18,7 @@ type ItemView struct {
Image string `json:"image"`
ExternalURL string `json:"link"`
Source string `json:"source"`
Description string `json:"description"`
Note string `json:"note"`
Badge string `json:"badge"` // e.g. theater production
Meta []MetaKV `json:"meta"`
@@ -59,6 +60,7 @@ func itemView(it store.Item, showNotes bool) ItemView {
Subtitle: it.Subtitle,
Year: it.Year,
ExternalURL: it.ExternalURL,
Description: it.Description,
Source: providerLabels[it.Provider],
Badge: it.Extra["production"],
Meta: []MetaKV{},
+11 -9
View File
@@ -20,17 +20,18 @@ type Item struct {
Provider string `json:"provider"`
ProviderID string `json:"providerId"`
ExternalURL string `json:"externalUrl"`
Note string `json:"note"`
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, provider, provider_id, external_url, note, extra`
const itemColumns = `id, list_id, rank, title, subtitle, year, image, image_source, 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.Provider, &it.ProviderID, &it.ExternalURL, &it.Note, &extra)
&it.Provider, &it.ProviderID, &it.ExternalURL, &it.Description, &it.Note, &extra)
if errors.Is(err, sql.ErrNoRows) {
return it, ErrNotFound
}
@@ -82,11 +83,12 @@ func (it *Item) normalize() error {
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.ExternalURL) > 2000 {
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://") {
@@ -136,9 +138,9 @@ func (s *Store) AddItem(ctx context.Context, it Item, rank int) (Item, error) {
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))
res, err := tx.ExecContext(ctx, `INSERT INTO items (list_id, rank, title, subtitle, year, image, image_source, provider, provider_id, external_url, description, note, extra)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
it.ListID, rank, it.Title, it.Subtitle, it.Year, it.Image, it.ImageSource, it.Provider, it.ProviderID, it.ExternalURL, it.Description, it.Note, extraJSON(it.Extra))
if err != nil {
return it, err
}
@@ -157,8 +159,8 @@ 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)
res, err := s.db.ExecContext(ctx, `UPDATE items SET title=?, subtitle=?, year=?, image=?, image_source=?, external_url=?, description=?, note=?, extra=?, updated_at=unixepoch() WHERE id=?`,
it.Title, it.Subtitle, it.Year, it.Image, it.ImageSource, it.ExternalURL, it.Description, it.Note, extraJSON(it.Extra), it.ID)
if err != nil {
return it, err
}
+2
View File
@@ -111,6 +111,8 @@ 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 {