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, ")") {
+9 -4
View File
@@ -131,6 +131,9 @@ type itemInput struct {
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"`
@@ -196,7 +199,7 @@ func (s *Server) apiAddItem(w http.ResponseWriter, r *http.Request) {
if err != nil {
warning = "Added without artwork: " + err.Error()
} else {
it.Image, it.ImageSource = name, res.ImageURL
it.Image, it.ImageSource, it.CustomArt = name, res.ImageURL, in.CustomArt
}
}
s.matchDeezer(ctx, list.Category, &it)
@@ -273,8 +276,10 @@ func (s *Server) apiUpdateItem(w http.ResponseWriter, r *http.Request) {
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.Image, it.ImageSource, it.CustomArt = "", "", true
}
if url := strings.TrimSpace(in.ImageURL); url != "" && url != cur.ImageSource {
name, err := s.fetchImage(ctx, url)
@@ -282,7 +287,7 @@ func (s *Server) apiUpdateItem(w http.ResponseWriter, r *http.Request) {
s.writeErr(w, store.ValidationError{Msg: "Couldn't use that image: " + err.Error()})
return
}
it.Image, it.ImageSource = name, url
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 {
@@ -335,7 +340,7 @@ func (s *Server) apiUploadItemImage(w http.ResponseWriter, r *http.Request) {
return
}
it := cur
it.Image, it.ImageSource = name, "upload"
it.Image, it.ImageSource, it.CustomArt = name, "upload", true
saved, err := s.store.UpdateItem(ctx, it)
if err != nil {
s.releaseImage(ctx, name)
+162
View File
@@ -0,0 +1,162 @@
package server
import (
"context"
"errors"
"maps"
"net/http"
"time"
"github.com/cdnutter/canon/internal/providers"
"github.com/cdnutter/canon/internal/store"
)
type refreshResponse struct {
Item store.Item `json:"item"`
Changed []string `json:"changed"` // which fields the source updated
Warning string `json:"warning,omitempty"`
}
// apiRefreshItem fetches an entry again from the service it was added from
// and applies what changed. The owner's own content is kept: the "why"
// note, the rank, theater production details, and artwork they chose
// themselves (unless replaceCustomArt). A field the source leaves empty
// keeps its current value.
func (s *Server) apiRefreshItem(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
id, err := pathID(r)
if err != nil {
s.writeErr(w, err)
return
}
var body struct {
ReplaceCustomArt bool `json:"replaceCustomArt"`
}
if r.ContentLength != 0 {
if err := decodeJSON(r, &body); err != nil {
s.writeErr(w, err)
return
}
}
cur, err := s.store.ItemByID(ctx, id)
if err != nil {
s.writeErr(w, err)
return
}
list, err := s.store.ListByID(ctx, cur.ListID)
if err != nil {
s.writeErr(w, err)
return
}
if list.Category == "custom" {
s.writeErr(w, store.ValidationError{Msg: "Custom lists are curated by hand, so there's nothing to refresh."})
return
}
if cur.Provider == "" {
s.writeErr(w, store.ValidationError{Msg: "This entry was added by hand, so there's no source to refresh from."})
return
}
lctx, cancel := context.WithTimeout(ctx, 20*time.Second)
res, err := s.providers.Lookup(lctx, list.Category, cur.Provider, cur.ProviderID)
cancel()
var nc providers.NotConfiguredError
switch {
case errors.As(err, &nc):
s.writeErr(w, err)
return
case errors.Is(err, providers.ErrGone):
writeError(w, http.StatusNotFound, providerLabels[cur.Provider]+" no longer has this entry, so it was left as is.")
return
case err != nil:
s.log.Warn("refresh lookup failed", "item", cur.ID, "provider", cur.Provider, "err", err)
writeError(w, http.StatusBadGateway, providerLabels[cur.Provider]+" didn't respond: "+err.Error())
return
}
if res.Title != "" && !providers.SameThing(cur.Title, res.Title) {
writeError(w, http.StatusConflict, providerLabels[cur.Provider]+" now calls this entry “"+res.Title+"”, which doesn't look like the same thing, so it was left as is. Edit it by hand if the change is right.")
return
}
it := cur
// Details come from the source; keep only the ones the owner enters
// (theater production info) and the Deezer preview link.
it.Extra = map[string]string{}
for _, k := range ownerExtraKeys {
if v := cur.Extra[k]; v != "" {
it.Extra[k] = v
}
}
set := func(dst *string, v string) {
if v != "" {
*dst = v
}
}
set(&it.Title, res.Title)
set(&it.Subtitle, res.Subtitle)
set(&it.Description, res.Overview)
set(&it.ExternalURL, res.ExternalURL)
// A theater entry's year is the production's, which the owner set.
if list.Category != "theater" {
set(&it.Year, res.Year)
}
for k, v := range res.Extra {
if v != "" {
it.Extra[k] = v
}
}
var warning string
keepArt := cur.CustomArt && !body.ReplaceCustomArt
if res.ImageURL != "" && !keepArt && (res.ImageURL != cur.ImageSource || cur.Image == "") {
name, err := s.fetchImage(ctx, res.ImageURL)
if err != nil {
warning = "Kept the current artwork: " + err.Error()
} else {
it.Image, it.ImageSource, it.CustomArt = name, res.ImageURL, false
}
}
if it.Title != cur.Title || it.Subtitle != cur.Subtitle {
s.matchDeezer(ctx, list.Category, &it)
}
changed := changedFields(cur, it)
if len(changed) == 0 {
writeJSON(w, http.StatusOK, refreshResponse{Item: cur, Changed: []string{}, Warning: warning})
return
}
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 saved.Image != cur.Image {
s.releaseImage(ctx, cur.Image)
}
writeJSON(w, http.StatusOK, refreshResponse{Item: saved, Changed: changed, Warning: warning})
}
var ownerExtraKeys = []string{"production", "venue", "cast", "deezer"}
// changedFields names what differs between two versions of an item, in the
// words the editor uses.
func changedFields(a, b store.Item) []string {
var out []string
add := func(changed bool, name string) {
if changed {
out = append(out, name)
}
}
add(a.Title != b.Title, "title")
add(a.Subtitle != b.Subtitle, "subtitle")
add(a.Year != b.Year, "year")
add(a.Description != b.Description, "description")
add(a.ExternalURL != b.ExternalURL, "link")
add(a.Image != b.Image, "artwork")
add(!maps.Equal(a.Extra, b.Extra), "details")
return out
}
+1
View File
@@ -138,6 +138,7 @@ func (s *Server) Handler() http.Handler {
api("PUT /api/items/{id}", s.apiUpdateItem)
api("POST /api/items/{id}/image", s.apiUploadItemImage)
api("POST /api/items/{id}/move", s.apiMoveItem)
api("POST /api/items/{id}/refresh", s.apiRefreshItem)
api("DELETE /api/items/{id}", s.apiDeleteItem)
api("GET /api/search", s.apiSearch)
api("GET /api/settings", s.apiSettings)
+8 -7
View File
@@ -17,6 +17,7 @@ type Item struct {
Year string `json:"year"`
Image string `json:"image"` // cached file name under /images/
ImageSource string `json:"imageSource"` // where the image came from
CustomArt bool `json:"customArt"` // chosen by the owner, not the source
Provider string `json:"provider"`
ProviderID string `json:"providerId"`
ExternalURL string `json:"externalUrl"`
@@ -25,12 +26,12 @@ type Item struct {
Extra map[string]string `json:"extra"`
}
const itemColumns = `id, list_id, rank, title, subtitle, year, image, image_source, provider, provider_id, external_url, description, note, extra`
const itemColumns = `id, list_id, rank, title, subtitle, year, image, image_source, image_custom, 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,
err := row.Scan(&it.ID, &it.ListID, &it.Rank, &it.Title, &it.Subtitle, &it.Year, &it.Image, &it.ImageSource, &it.CustomArt,
&it.Provider, &it.ProviderID, &it.ExternalURL, &it.Description, &it.Note, &extra)
if errors.Is(err, sql.ErrNoRows) {
return it, ErrNotFound
@@ -138,9 +139,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, 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))
res, err := tx.ExecContext(ctx, `INSERT INTO items (list_id, rank, title, subtitle, year, image, image_source, image_custom, provider, provider_id, external_url, description, note, extra)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
it.ListID, rank, it.Title, it.Subtitle, it.Year, it.Image, it.ImageSource, it.CustomArt, it.Provider, it.ProviderID, it.ExternalURL, it.Description, it.Note, extraJSON(it.Extra))
if err != nil {
return it, err
}
@@ -159,8 +160,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=?, 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)
res, err := s.db.ExecContext(ctx, `UPDATE items SET title=?, subtitle=?, year=?, image=?, image_source=?, image_custom=?, external_url=?, description=?, note=?, extra=?, updated_at=unixepoch() WHERE id=?`,
it.Title, it.Subtitle, it.Year, it.Image, it.ImageSource, it.CustomArt, it.ExternalURL, it.Description, it.Note, extraJSON(it.Extra), it.ID)
if err != nil {
return it, err
}
+4
View File
@@ -113,6 +113,10 @@ CREATE INDEX items_list_rank ON items(list_id, rank);
`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 '';`,
// Whether the owner chose the artwork (upload, pasted URL, or removed it)
// rather than taking the source's; refresh leaves it alone unless asked.
`ALTER TABLE items ADD COLUMN image_custom INTEGER NOT NULL DEFAULT 0;
UPDATE items SET image_custom = 1 WHERE image_source = 'upload';`,
}
func (s *Store) migrate(ctx context.Context) error {