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