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:
1 parent
4ea7cbc984
commit
33053d1c5f
18 files changed
+852
-148
No files matched your search
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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, ")") {
|
||||
|
||||
Reference in new issue
Block a user