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>
This commit is contained in:
cdnutterandClaude Opus 5.5 committed 2026-09-25 12:41:22 -07:00
1 parent 4ea7cbc984
commit 33053d1c5f
18 files changed
+852 -148

No files matched your search

+24 -3
View File
@@ -58,11 +58,32 @@ func (g *igdb) token(ctx context.Context, fresh bool) (string, error) {
return c.token, nil
}
const igdbFields = `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;`
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.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;`
return g.query(ctx, `search "`+q+`";
`+igdbFields+`
limit 25;`)
}
// Lookup refreshes one game by its IGDB ID.
func (g *igdb) Lookup(ctx context.Context, providerID string) (Result, error) {
id, err := strconv.ParseInt(providerID, 10, 64)
if err != nil {
return Result{}, ErrGone
}
res, err := g.query(ctx, igdbFields+"\nwhere id = "+strconv.FormatInt(id, 10)+";")
if err != nil {
return Result{}, err
}
if len(res) == 0 {
return Result{}, ErrGone
}
return res[0], nil
}
func (g *igdb) query(ctx context.Context, body string) ([]Result, error) {
var games []struct {
ID int64 `json:"id"`
Name string `json:"name"`
+30
View File
@@ -62,3 +62,33 @@ func pick(res []Result, i int) Result {
}
return res[i]
}
func TestLiveLookup(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
reg := NewRegistry(func(context.Context) Credentials { return Credentials{} })
for _, c := range []struct{ category, provider, id, want string }{
{"album", "deezer", "album:14590454", "Death Magnetic"},
{"song", "deezer", "track:63480987", "Dreams"},
{"album", "musicbrainz", "release-group:5c8a25bf-4764-3cce-8f37-30af79d3b101", "Death Magnetic"},
{"theater", "wikipedia", "1678965", "Wicked"},
} {
res, err := reg.Lookup(ctx, c.category, c.provider, c.id)
if err != nil {
t.Errorf("%s %s: %v", c.provider, c.id, err)
continue
}
t.Logf("%s %s → %q | %q | %s | img=%v | desc=%d chars | extra=%v", c.provider, c.id, res.Title, res.Subtitle, res.Year, res.ImageURL != "", len(res.Overview), res.Extra)
if c.want != "" && !strings.HasPrefix(res.Title, c.want) {
t.Errorf("want %q, got %q", c.want, res.Title)
}
}
if _, err := reg.Lookup(ctx, "album", "deezer", "album:1"); err == nil {
t.Log("album:1 unexpectedly exists")
} else {
t.Logf("missing album → %v", err)
}
if _, err := reg.Lookup(ctx, "movie", "", ""); err != ErrNoSource {
t.Errorf("manual entry: want ErrNoSource, got %v", err)
}
}
+75
View File
@@ -0,0 +1,75 @@
package providers
import (
"context"
"errors"
"strings"
)
// Looker is implemented by providers that can fetch one entry again by its
// ID, to refresh metadata that changed at the source.
type Looker interface {
Lookup(ctx context.Context, providerID string) (Result, error)
}
// ErrGone means the source no longer has the entry (deleted or merged).
var ErrGone = errors.New("the source no longer has this entry")
// ErrNoSource means the entry wasn't added from a service that can be
// refreshed.
var ErrNoSource = errors.New("no source to refresh from")
// Lookup fetches an entry again from the service it came from. The
// provider is the one saved with the entry, whatever the current setting.
func (r *Registry) Lookup(ctx context.Context, category, provider, providerID string) (Result, error) {
var l Looker
c := r.creds(ctx)
switch provider {
case "tmdb":
if c.TMDBKey == "" {
return Result{}, NotConfiguredError{"TMDB", "Add a TMDB API key under Settings → Integrations."}
}
kind, _, _ := strings.Cut(providerID, ":")
l = &tmdb{r: r, key: c.TMDBKey, kind: kind}
case "tvdb":
if c.TVDBKey == "" {
return Result{}, NotConfiguredError{"TheTVDB", "Add a TheTVDB API key under Settings → Integrations."}
}
l = &tvdb{r: r, key: c.TVDBKey, pin: c.TVDBPin}
case "igdb":
if c.IGDBClientID == "" || c.IGDBClientSecret == "" {
return Result{}, NotConfiguredError{"IGDB", "Add your Twitch client ID and secret under Settings → Integrations."}
}
l = &igdb{r: r, id: c.IGDBClientID, secret: c.IGDBClientSecret}
case "deezer":
l = &deezer{r: r, kind: category}
case "musicbrainz":
l = &musicbrainz{r: r, kind: category}
case "wikipedia":
l = &wikipedia{r: r, stage: category == "theater"}
default:
return Result{}, ErrNoSource
}
return l.Lookup(ctx, providerID)
}
// SameThing reports whether a refreshed title plausibly names the same
// entry: the titles share at least one word, ignoring bracketed qualifiers
// like "(Remastered)". A refresh that comes back with an unrelated title
// usually means a bad or reused ID, and shouldn't overwrite the entry.
func SameThing(a, b string) bool {
wa, wb := strings.Fields(baseTitle(a)), strings.Fields(baseTitle(b))
if len(wa) == 0 || len(wb) == 0 {
return true
}
seen := map[string]bool{}
for _, w := range wa {
seen[w] = true
}
for _, w := range wb {
if seen[w] {
return true
}
}
return false
}
+216 -117
View File
@@ -92,22 +92,7 @@ func (d *deezer) Search(ctx context.Context, query string) ([]Result, error) {
return nil, err
}
for _, a := range resp.Data {
r := Result{
Provider: "deezer",
ProviderID: "album:" + strconv.FormatInt(a.ID, 10),
Title: a.Title,
Subtitle: a.Artist.Name,
ImageURL: cover(a),
ExternalURL: a.Link,
Extra: map[string]string{},
}
if a.RecordType != "" && a.RecordType != "album" {
r.Extra["type"] = strings.ToUpper(a.RecordType[:1]) + a.RecordType[1:]
}
if a.NbTracks > 0 {
r.Hint = fmt.Sprintf("%d tracks", a.NbTracks)
}
out = append(out, r)
out = append(out, albumResult(a))
}
return out, nil
}
@@ -119,20 +104,74 @@ func (d *deezer) Search(ctx context.Context, query string) ([]Result, error) {
return nil, err
}
for _, t := range resp.Data {
out = append(out, Result{
Provider: "deezer",
ProviderID: "track:" + strconv.FormatInt(t.ID, 10),
Title: t.Title,
Subtitle: t.Artist.Name,
ImageURL: cover(t.Album),
ExternalURL: t.Link,
Hint: "From " + t.Album.Title,
Extra: map[string]string{"album": t.Album.Title},
})
out = append(out, trackResult(t))
}
return out, nil
}
func albumResult(a deezerAlbum) Result {
r := Result{
Provider: "deezer",
ProviderID: "album:" + strconv.FormatInt(a.ID, 10),
Title: a.Title,
Subtitle: a.Artist.Name,
ImageURL: cover(a),
ExternalURL: a.Link,
Extra: map[string]string{},
}
if a.RecordType != "" && a.RecordType != "album" {
r.Extra["type"] = strings.ToUpper(a.RecordType[:1]) + a.RecordType[1:]
}
if a.NbTracks > 0 {
r.Hint = fmt.Sprintf("%d tracks", a.NbTracks)
}
return r
}
func trackResult(t deezerTrack) Result {
return Result{
Provider: "deezer",
ProviderID: "track:" + strconv.FormatInt(t.ID, 10),
Title: t.Title,
Subtitle: t.Artist.Name,
ImageURL: cover(t.Album),
ExternalURL: t.Link,
Hint: "From " + t.Album.Title,
Extra: map[string]string{"album": t.Album.Title},
}
}
// Lookup refreshes one album or track by its Deezer ID.
func (d *deezer) Lookup(ctx context.Context, providerID string) (Result, error) {
kind, id, _ := strings.Cut(providerID, ":")
if _, err := strconv.ParseInt(id, 10, 64); err != nil {
return Result{}, ErrGone
}
switch kind {
case "album":
var a deezerAlbum
if err := d.get(ctx, "/album/"+id, &a, &a.deezerErr); err != nil {
return Result{}, deezerGone(err)
}
return applyAlbum(albumResult(a), a), nil
case "track":
var t deezerTrack
if err := d.get(ctx, "/track/"+id, &t, &t.deezerErr); err != nil {
return Result{}, deezerGone(err)
}
return applyTrack(trackResult(t), t), nil
}
return Result{}, ErrGone
}
// Deezer answers lookups of removed items with an error object.
func deezerGone(err error) error {
if strings.Contains(err.Error(), "no data") || strings.Contains(err.Error(), "not found") {
return ErrGone
}
return err
}
func (d *deezer) Details(ctx context.Context, res Result) (Result, error) {
if res.Provider != "deezer" {
return res, nil
@@ -147,33 +186,51 @@ func (d *deezer) Details(ctx context.Context, res Result) (Result, error) {
if err := d.get(ctx, "/album/"+id, &a, &a.deezerErr); err != nil {
return res, err
}
res.Year = yearOf(a.ReleaseDate)
if a.Label != "" {
res.Extra["label"] = a.Label
}
var g []string
for _, x := range a.Genres.Data {
g = append(g, x.Name)
}
if len(g) > 0 {
res.Extra["genres"] = strings.Join(g, ", ")
}
return applyAlbum(res, a), nil
case "track":
var t deezerTrack
if err := d.get(ctx, "/track/"+id, &t, &t.deezerErr); err != nil {
return res, err
}
res.Year = yearOf(t.ReleaseDate)
if res.Year == "" {
res.Year = yearOf(t.Album.ReleaseDate)
}
if t.Duration > 0 {
res.Extra["length"] = fmt.Sprintf("%d:%02d", t.Duration/60, t.Duration%60)
}
return applyTrack(res, t), nil
}
return res, nil
}
// applyAlbum adds the details only a full album lookup has.
func applyAlbum(res Result, a deezerAlbum) Result {
if res.Extra == nil {
res.Extra = map[string]string{}
}
res.Year = yearOf(a.ReleaseDate)
if a.Label != "" {
res.Extra["label"] = a.Label
}
var g []string
for _, x := range a.Genres.Data {
g = append(g, x.Name)
}
if len(g) > 0 {
res.Extra["genres"] = strings.Join(g, ", ")
}
return res
}
// applyTrack adds the details only a full track lookup has.
func applyTrack(res Result, t deezerTrack) Result {
if res.Extra == nil {
res.Extra = map[string]string{}
}
res.Year = yearOf(t.ReleaseDate)
if res.Year == "" {
res.Year = yearOf(t.Album.ReleaseDate)
}
if t.Duration > 0 {
res.Extra["length"] = fmt.Sprintf("%d:%02d", t.Duration/60, t.Duration%60)
}
return res
}
// musicbrainz uses the MusicBrainz database with art from the Cover Art
// Archive. Fully open data, but limited to one request per second.
type musicbrainz struct {
@@ -198,95 +255,137 @@ func mbArtists(credits []mbCredit) string {
return strings.TrimSpace(b.String())
}
type mbReleaseGroup struct {
ID string `json:"id"`
Title string `json:"title"`
PrimaryType string `json:"primary-type"`
SecondaryTypes []string `json:"secondary-types"`
FirstReleaseDate string `json:"first-release-date"`
ArtistCredit []mbCredit `json:"artist-credit"`
}
type mbRecording struct {
ID string `json:"id"`
Title string `json:"title"`
Length int `json:"length"`
FirstReleaseDate string `json:"first-release-date"`
ArtistCredit []mbCredit `json:"artist-credit"`
Releases []struct {
Title string `json:"title"`
ReleaseGroup struct {
ID string `json:"id"`
PrimaryType string `json:"primary-type"`
} `json:"release-group"`
} `json:"releases"`
}
func (m *musicbrainz) Search(ctx context.Context, query string) ([]Result, error) {
if err := m.r.mb.wait(ctx); err != nil {
return nil, err
}
var out []Result
q := url.Values{"query": {query}, "fmt": {"json"}, "limit": {"25"}}.Encode()
if m.kind == "album" {
var resp struct {
ReleaseGroups []struct {
ID string `json:"id"`
Title string `json:"title"`
PrimaryType string `json:"primary-type"`
SecondaryTypes []string `json:"secondary-types"`
FirstReleaseDate string `json:"first-release-date"`
ArtistCredit []mbCredit `json:"artist-credit"`
} `json:"release-groups"`
ReleaseGroups []mbReleaseGroup `json:"release-groups"`
}
u := "https://musicbrainz.org/ws/2/release-group?" + url.Values{"query": {query}, "fmt": {"json"}, "limit": {"25"}}.Encode()
if err := m.r.getJSON(ctx, u, nil, &resp); err != nil {
if err := m.get(ctx, "/release-group?"+q, &resp); err != nil {
return nil, err
}
for _, rg := range resp.ReleaseGroups {
r := Result{
Provider: "musicbrainz",
ProviderID: "release-group:" + rg.ID,
Title: rg.Title,
Subtitle: mbArtists(rg.ArtistCredit),
Year: yearOf(rg.FirstReleaseDate),
ImageURL: "https://coverartarchive.org/release-group/" + rg.ID + "/front-500",
ExternalURL: "https://musicbrainz.org/release-group/" + rg.ID,
Extra: map[string]string{},
}
// e.g. "Soundtrack", "Live", "EP · Soundtrack"
types := rg.SecondaryTypes
if rg.PrimaryType != "" && rg.PrimaryType != "Album" {
types = append([]string{rg.PrimaryType}, types...)
}
if len(types) > 0 {
r.Extra["type"] = strings.Join(types, " · ")
}
out = append(out, r)
out = append(out, rgResult(rg))
}
return out, nil
}
var resp struct {
Recordings []struct {
ID string `json:"id"`
Title string `json:"title"`
Length int `json:"length"`
FirstReleaseDate string `json:"first-release-date"`
ArtistCredit []mbCredit `json:"artist-credit"`
Releases []struct {
Title string `json:"title"`
ReleaseGroup struct {
ID string `json:"id"`
PrimaryType string `json:"primary-type"`
} `json:"release-group"`
} `json:"releases"`
} `json:"recordings"`
Recordings []mbRecording `json:"recordings"`
}
u := "https://musicbrainz.org/ws/2/recording?" + url.Values{"query": {query}, "fmt": {"json"}, "limit": {"25"}}.Encode()
if err := m.r.getJSON(ctx, u, nil, &resp); err != nil {
if err := m.get(ctx, "/recording?"+q, &resp); err != nil {
return nil, err
}
for _, rec := range resp.Recordings {
r := Result{
Provider: "musicbrainz",
ProviderID: "recording:" + rec.ID,
Title: rec.Title,
Subtitle: mbArtists(rec.ArtistCredit),
Year: yearOf(rec.FirstReleaseDate),
ExternalURL: "https://musicbrainz.org/recording/" + rec.ID,
Extra: map[string]string{},
}
// Prefer an album release for the artwork.
for i, rel := range rec.Releases {
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.Hint = "From " + rel.Title
if rel.ReleaseGroup.PrimaryType == "Album" {
break
}
}
}
if rec.Length > 0 {
s := rec.Length / 1000
r.Extra["length"] = fmt.Sprintf("%d:%02d", s/60, s%60)
}
out = append(out, r)
out = append(out, recResult(rec))
}
return out, nil
}
// Lookup refreshes one release group (album) or recording (song).
func (m *musicbrainz) Lookup(ctx context.Context, providerID string) (Result, error) {
kind, id, _ := strings.Cut(providerID, ":")
id = url.PathEscape(id)
var err error
var res Result
switch kind {
case "release-group":
var rg mbReleaseGroup
if err = m.get(ctx, "/release-group/"+id+"?inc=artist-credits&fmt=json", &rg); err == nil {
res = rgResult(rg)
}
case "recording":
var rec mbRecording
if err = m.get(ctx, "/recording/"+id+"?inc=artist-credits+releases+release-groups&fmt=json", &rec); err == nil {
res = recResult(rec)
}
default:
return Result{}, ErrGone
}
if isStatus(err, 404) || isStatus(err, 400) {
return Result{}, ErrGone
}
return res, err
}
// get waits for MusicBrainz's one-request-per-second limit, then fetches.
func (m *musicbrainz) get(ctx context.Context, path string, out any) error {
if err := m.r.mb.wait(ctx); err != nil {
return err
}
return m.r.getJSON(ctx, "https://musicbrainz.org/ws/2"+path, nil, out)
}
func rgResult(rg mbReleaseGroup) Result {
r := Result{
Provider: "musicbrainz",
ProviderID: "release-group:" + rg.ID,
Title: rg.Title,
Subtitle: mbArtists(rg.ArtistCredit),
Year: yearOf(rg.FirstReleaseDate),
ImageURL: "https://coverartarchive.org/release-group/" + rg.ID + "/front-500",
ExternalURL: "https://musicbrainz.org/release-group/" + rg.ID,
Extra: map[string]string{},
}
// e.g. "Soundtrack", "Live", "EP · Soundtrack"
types := rg.SecondaryTypes
if rg.PrimaryType != "" && rg.PrimaryType != "Album" {
types = append([]string{rg.PrimaryType}, types...)
}
if len(types) > 0 {
r.Extra["type"] = strings.Join(types, " · ")
}
return r
}
func recResult(rec mbRecording) Result {
r := Result{
Provider: "musicbrainz",
ProviderID: "recording:" + rec.ID,
Title: rec.Title,
Subtitle: mbArtists(rec.ArtistCredit),
Year: yearOf(rec.FirstReleaseDate),
ExternalURL: "https://musicbrainz.org/recording/" + rec.ID,
Extra: map[string]string{},
}
// Prefer an album release for the artwork.
for i, rel := range rec.Releases {
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.Hint = "From " + rel.Title
if rel.ReleaseGroup.PrimaryType == "Album" {
break
}
}
}
if rec.Length > 0 {
s := rec.Length / 1000
r.Extra["length"] = fmt.Sprintf("%d:%02d", s/60, s%60)
}
return r
}
+18
View File
@@ -58,3 +58,21 @@ func TestMatching(t *testing.T) {
}
}
}
func TestSameThing(t *testing.T) {
for _, c := range []struct {
a, b string
want bool
}{
{"Dreams", "Dreams (2004 Remaster)", true},
{"DM (old)", "Death Magnetic", false},
{"Blonde", "Blond", false},
{"Hamilton", "Getting On", false},
{"The Legend of Zelda: Breath of the Wild", "Zelda: Breath of the Wild", true},
{"", "Anything", true},
} {
if got := SameThing(c.a, c.b); got != c.want {
t.Errorf("SameThing(%q, %q) = %v, want %v", c.a, c.b, got, c.want)
}
}
}
+34
View File
@@ -144,3 +144,37 @@ func (t *tmdb) Details(ctx context.Context, res Result) (Result, error) {
}
return res, nil
}
// Lookup refreshes one movie or show ("movie:123" / "tv:456") by its TMDB ID.
func (t *tmdb) Lookup(ctx context.Context, providerID string) (Result, error) {
kind, id, _ := strings.Cut(providerID, ":")
if kind != "movie" && kind != "tv" {
return Result{}, ErrGone
}
if _, err := strconv.ParseInt(id, 10, 64); err != nil {
return Result{}, ErrGone
}
var h tmdbHit
err := t.get(ctx, "/"+kind+"/"+id, nil, &h)
if isStatus(err, 404) {
return Result{}, ErrGone
}
if err != nil {
return Result{}, err
}
title, date := h.Title, h.ReleaseDate
if kind == "tv" {
title, date = h.Name, h.FirstAirDate
}
res := Result{
Provider: "tmdb",
ProviderID: providerID,
Title: title,
Year: yearOf(date),
ImageURL: tmdbImage(h.PosterPath),
ExternalURL: "https://www.themoviedb.org/" + kind + "/" + id,
Overview: truncate(h.Overview, maxDescription),
}
d := &tmdb{r: t.r, key: t.key, kind: kind}
return d.Details(ctx, res)
}
+81 -13
View File
@@ -7,6 +7,7 @@ import (
"fmt"
"net/http"
"net/url"
"strconv"
"sync"
"time"
)
@@ -79,19 +80,8 @@ func (t *tvdb) Search(ctx context.Context, query string) ([]Result, error) {
} `json:"data"`
}
u := "https://api4.thetvdb.com/v4/search?" + url.Values{"query": {query}, "type": {"series"}, "limit": {"20"}}.Encode()
for attempt := 0; ; attempt++ {
tok, err := t.token(ctx, attempt > 0)
if err != nil {
return nil, err
}
err = t.r.getJSON(ctx, u, map[string]string{"Authorization": "Bearer " + tok}, &resp)
if isStatus(err, 401) && attempt == 0 {
continue
}
if err != nil {
return nil, err
}
break
if err := t.get(ctx, u, &resp); err != nil {
return nil, err
}
out := make([]Result, 0, len(resp.Data))
for _, s := range resp.Data {
@@ -128,3 +118,81 @@ func (t *tvdb) Search(ctx context.Context, query string) ([]Result, error) {
}
return out, nil
}
// get calls the API with a token, logging in again once if it expired.
func (t *tvdb) get(ctx context.Context, u string, out any) error {
for attempt := 0; ; attempt++ {
tok, err := t.token(ctx, attempt > 0)
if err != nil {
return err
}
err = t.r.getJSON(ctx, u, map[string]string{"Authorization": "Bearer " + tok}, out)
if isStatus(err, 401) && attempt == 0 {
continue
}
return err
}
}
// Lookup refreshes one series by its TheTVDB ID, preferring English titles
// and overviews.
func (t *tvdb) Lookup(ctx context.Context, providerID string) (Result, error) {
if _, err := strconv.ParseInt(providerID, 10, 64); err != nil {
return Result{}, ErrGone
}
var s struct {
Data struct {
Name string `json:"name"`
Slug string `json:"slug"`
Image string `json:"image"`
Year string `json:"year"`
FirstAired string `json:"firstAired"`
Overview string `json:"overview"`
OriginalNetwork struct {
Name string `json:"name"`
} `json:"originalNetwork"`
LatestNetwork struct {
Name string `json:"name"`
} `json:"latestNetwork"`
} `json:"data"`
}
err := t.get(ctx, "https://api4.thetvdb.com/v4/series/"+providerID+"/extended?short=true", &s)
if isStatus(err, 404) {
return Result{}, ErrGone
}
if err != nil {
return Result{}, err
}
d := s.Data
res := Result{
Provider: "tvdb",
ProviderID: providerID,
Title: d.Name,
Subtitle: d.OriginalNetwork.Name,
Year: d.Year,
ImageURL: d.Image,
ExternalURL: "https://thetvdb.com/series/" + d.Slug,
Overview: truncate(d.Overview, maxDescription),
}
if res.Subtitle == "" {
res.Subtitle = d.LatestNetwork.Name
}
if res.Year == "" {
res.Year = yearOf(d.FirstAired)
}
var eng struct {
Data struct {
Name string `json:"name"`
Overview string `json:"overview"`
} `json:"data"`
}
if err := t.get(ctx, "https://api4.thetvdb.com/v4/series/"+providerID+"/translations/eng", &eng); err == nil {
if eng.Data.Name != "" {
res.Title = eng.Data.Name
}
if eng.Data.Overview != "" {
res.Overview = truncate(eng.Data.Overview, maxDescription)
}
}
return res, nil
}
+25 -3
View File
@@ -39,13 +39,28 @@ func (w *wikipedia) Search(ctx context.Context, query string) ([]Result, error)
var leadingYear = regexp.MustCompile(`^(1[5-9]\d\d|20\d\d)\s+`)
func (w *wikipedia) search(ctx context.Context, query string) ([]Result, error) {
return w.pages(ctx, url.Values{"generator": {"search"}, "gsrsearch": {query}, "gsrlimit": {"15"}})
}
// Lookup refreshes one page by its page ID.
func (w *wikipedia) Lookup(ctx context.Context, providerID string) (Result, error) {
res, err := w.pages(ctx, url.Values{"pageids": {providerID}})
if err != nil {
return Result{}, err
}
if len(res) == 0 {
return Result{}, ErrGone
}
return res[0], nil
}
// pages runs a query for pages (a search, or specific page IDs) and shapes
// them into results.
func (w *wikipedia) pages(ctx context.Context, which url.Values) ([]Result, error) {
q := url.Values{
"action": {"query"},
"format": {"json"},
"formatversion": {"2"},
"generator": {"search"},
"gsrsearch": {query},
"gsrlimit": {"15"},
"prop": {"pageimages|description|info|extracts"},
"piprop": {"thumbnail"},
"pithumbsize": {"800"},
@@ -56,10 +71,14 @@ func (w *wikipedia) search(ctx context.Context, query string) ([]Result, error)
"exsentences": {"3"},
"exlimit": {"max"},
}
for k, v := range which {
q[k] = v
}
var resp struct {
Query struct {
Pages []struct {
PageID int64 `json:"pageid"`
Missing bool `json:"missing"`
Title string `json:"title"`
Index int `json:"index"`
Description string `json:"description"`
@@ -78,6 +97,9 @@ func (w *wikipedia) search(ctx context.Context, query string) ([]Result, error)
sort.Slice(pages, func(i, j int) bool { return pages[i].Index < pages[j].Index })
out := make([]Result, 0, len(pages))
for _, p := range pages {
if p.Missing {
continue
}
title := p.Title
// "Hamilton (musical)" → "Hamilton"
if i := strings.LastIndex(title, " ("); i > 0 && strings.HasSuffix(title, ")") {