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:
1 parent
3923444b3b
commit
7320e3f707
16 files changed
+207
-44
No files matched your search
+97
-16
@@ -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)
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user