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
@@ -12,6 +12,7 @@ Visitors just see your lists. There's no sign-up, no feed and no login button. Y
|
||||
- **Theater by production**: record *which* staging you mean (Broadway, West End, North American Tour, a revival…) plus venue and cast, and use that production's own poster.
|
||||
- **Four styles per list**: **Podium** (top 3 on a medal podium, the rest behind "Show more"), **Spotlight** (#1 as a big feature), **Grid** and **List**. You pick each list's style in List settings, and visitors can switch styles on a list's own page.
|
||||
- **Drag to rank**, or use the arrow buttons or type a new number. Paginated editing keeps huge lists usable.
|
||||
- **Refresh from source**: re-pull an entry, or a whole list, when its metadata changes upstream. Your notes, ranking and theater productions are kept, and artwork you chose yourself is only replaced if you say so. Custom lists and hand-added entries have no source, so they aren't refreshed.
|
||||
- **Art is cached on your server**, so pages never hotlink and keep working if a source changes. You can also paste an image URL or upload your own.
|
||||
- **Preview clips** for songs and albums: a play button plays 30 seconds from Deezer (an album plays its most popular track). Clips stream through your server, so visitors never connect to Deezer. You can turn this off in Settings → Profile.
|
||||
- **Export** any list as a titles-only `.csv` or `.txt`.
|
||||
|
||||
@@ -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, ")") {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -692,6 +692,11 @@ input[type="checkbox"] { accent-color: var(--accent); width: 18px; height: 18px;
|
||||
.art-edit-actions .row-btns { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
@media (max-width: 480px) { .art-edit { grid-template-columns: 80px 1fr; } .art-edit .result-art { width: 80px; } }
|
||||
|
||||
.refresh-art-choice { align-items: flex-start; padding: 12px 14px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg); }
|
||||
.refresh-art-choice input { margin-top: 2px; }
|
||||
.refresh-failures { margin: 0; padding-left: 18px; font-size: 13.5px; color: var(--muted); display: flex; flex-direction: column; gap: 4px; max-height: 240px; overflow-y: auto; }
|
||||
.refresh-failures b { color: var(--text); font-weight: 600; }
|
||||
|
||||
/* settings */
|
||||
.settings { display: grid; grid-template-columns: 200px 1fr; gap: 32px; align-items: start; }
|
||||
.settings-nav { position: sticky; top: 80px; display: flex; flex-direction: column; gap: 2px; }
|
||||
|
||||
+134
-1
@@ -1,5 +1,5 @@
|
||||
// List editor: search providers, add entries, rank and edit them.
|
||||
import { h, icon, api, readBoot, toast, confirmDialog, openDialog, dialogHead, debounce, busy, fmt } from "./common.js";
|
||||
import { h, icon, api, readBoot, toast, confirmDialog, chooseDialog, openDialog, dialogHead, debounce, busy, fmt } from "./common.js";
|
||||
import { sortable } from "./sortable.js";
|
||||
import { openListForm } from "./list-form.js";
|
||||
import { previewButton } from "./player.js";
|
||||
@@ -123,12 +123,144 @@ function row(it) {
|
||||
canPreview(it) && previewButton(`/preview/${it.id}`, `Play a preview of ${it.title}`, "play-btn play-btn--inline hide-sm"),
|
||||
h("button", { type: "button", class: "icon-btn hide-sm", "aria-label": "Move up", title: "Move up", disabled: it.rank === 1, onclick: () => moveTo(it, it.rank - 1) }, icon("chevron-up")),
|
||||
h("button", { type: "button", class: "icon-btn hide-sm", "aria-label": "Move down", title: "Move down", disabled: it.rank === list.itemCount, onclick: () => moveTo(it, it.rank + 1) }, icon("chevron-down")),
|
||||
canRefresh(it) && h("button", { type: "button", class: "icon-btn", "aria-label": `Refresh ${it.title} from ${sourceName(it)}`, title: `Refresh from ${sourceName(it)}`, onclick: (e) => refreshOne(it, e.currentTarget) }, icon("refresh")),
|
||||
h("button", { type: "button", class: "icon-btn", "aria-label": `Edit ${it.title}`, title: "Edit", onclick: () => openItemForm({ item: it }) }, icon("pencil")),
|
||||
h("button", { type: "button", class: "icon-btn icon-btn--danger", "aria-label": `Remove ${it.title}`, title: "Remove", onclick: () => removeItem(it) }, icon("trash")),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const SOURCES = { tmdb: "TMDB", tvdb: "TheTVDB", igdb: "IGDB", deezer: "Deezer", musicbrainz: "MusicBrainz", wikipedia: "Wikipedia" };
|
||||
const sourceName = (it) => SOURCES[it.provider] || "its source";
|
||||
|
||||
// Entries added from a search can be refreshed from that service. Custom
|
||||
// lists and hand-added entries have no source.
|
||||
function canRefresh(it) {
|
||||
return cat.key !== "custom" && !!it.provider;
|
||||
}
|
||||
|
||||
// ---------- refresh from source ----------
|
||||
|
||||
async function refreshOne(it, button) {
|
||||
let replaceCustomArt = false;
|
||||
if (it.customArt) {
|
||||
const choice = await chooseDialog({
|
||||
title: "Replace your artwork?",
|
||||
message: `“${it.title}” uses artwork you chose yourself. Refreshing updates its details from ${sourceName(it)}. Should it also switch to ${sourceName(it)}'s artwork?`,
|
||||
choices: [{ label: "Keep my artwork", value: "keep" }, { label: `Use ${sourceName(it)}'s`, value: "replace", primary: true }],
|
||||
});
|
||||
if (!choice) return;
|
||||
replaceCustomArt = choice === "replace";
|
||||
}
|
||||
busy(button, true);
|
||||
try {
|
||||
const res = await api("POST", `/api/items/${it.id}/refresh`, { replaceCustomArt });
|
||||
toast(res.changed.length ? `Updated ${res.changed.join(", ")} for “${res.item.title}”` : `“${res.item.title}” is already up to date`);
|
||||
if (res.warning) toast(res.warning, { error: true });
|
||||
await load(page, it.id);
|
||||
} catch (err) {
|
||||
toast(err.message, { error: true });
|
||||
busy(button, false);
|
||||
}
|
||||
}
|
||||
|
||||
async function allItems() {
|
||||
const out = [];
|
||||
for (let offset = 0; ; offset += 500) {
|
||||
const batch = await api("GET", `/api/lists/${list.id}/items?offset=${offset}&limit=500`);
|
||||
out.push(...batch);
|
||||
if (batch.length < 500) return out;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshAll() {
|
||||
let all;
|
||||
try {
|
||||
all = await allItems();
|
||||
} catch (err) {
|
||||
toast(err.message, { error: true });
|
||||
return;
|
||||
}
|
||||
const targets = all.filter(canRefresh);
|
||||
if (!targets.length) {
|
||||
toast("Nothing to refresh: every entry here was added by hand.", { error: true });
|
||||
return;
|
||||
}
|
||||
const custom = targets.filter((it) => it.customArt).length;
|
||||
const manual = all.length - targets.length;
|
||||
const sources = new Intl.ListFormat("en", { type: "conjunction" }).format([...new Set(targets.map(sourceName))]);
|
||||
|
||||
// Step 1: confirm, and ask about the owner's own artwork.
|
||||
const replaceBox = h("input", { type: "checkbox" });
|
||||
let start = false;
|
||||
let dlg = openDialog(h("div", {},
|
||||
dialogHead("Refresh all from source", () => dlg),
|
||||
h("div", { class: "dialog-body stack" },
|
||||
h("p", {}, `Re-pull ${fmt.format(targets.length)} ${targets.length === 1 ? "entry" : "entries"} from ${sources}. Titles, years, descriptions, links, details and artwork are updated wherever the source changed.`),
|
||||
h("p", { class: "muted" }, "Your “why” notes, the ranking, and theater productions stay exactly as they are." +
|
||||
(manual ? ` ${fmt.format(manual)} added by hand ${manual === 1 ? "is" : "are"} skipped.` : "")),
|
||||
custom > 0 && h("label", { class: "check refresh-art-choice" }, replaceBox,
|
||||
h("span", {}, h("b", {}, "Also replace artwork you chose yourself"),
|
||||
h("br"), h("span", { class: "muted small" }, `${fmt.format(custom)} ${custom === 1 ? "entry uses" : "entries use"} an image you uploaded, pasted or removed. Leave this off to keep ${custom === 1 ? "it" : "them"}.`))),
|
||||
),
|
||||
h("div", { class: "dialog-foot" },
|
||||
h("button", { type: "button", class: "btn", onclick: () => dlg.close() }, "Cancel"),
|
||||
h("button", { type: "button", class: "btn btn--primary", onclick: () => { start = true; dlg.close(); } }, icon("refresh"), "Refresh all"),
|
||||
),
|
||||
), { onClose: () => start && runRefreshAll(targets, replaceBox.checked) });
|
||||
dlg.style.maxWidth = "520px";
|
||||
}
|
||||
|
||||
async function runRefreshAll(targets, replaceCustomArt) {
|
||||
let stopped = false;
|
||||
let finished = false;
|
||||
const bar = h("span", { style: { width: "0%" } });
|
||||
const status = h("p", { class: "muted small", "aria-live": "polite" }, "Starting…");
|
||||
const body = h("div", { class: "dialog-body stack" }, h("div", { class: "meter", style: { margin: 0 } }, bar), status);
|
||||
const stop = h("button", { type: "button", class: "btn", onclick: () => { stopped = true; stop.disabled = true; stop.textContent = "Stopping…"; } }, "Stop");
|
||||
const foot = h("div", { class: "dialog-foot" }, stop);
|
||||
const dlg = openDialog(h("div", {}, h("div", { class: "dialog-head" }, h("h2", {}, "Refreshing…")), body, foot), {
|
||||
onClose: () => { stopped = true; if (finished) load(page); },
|
||||
});
|
||||
dlg.style.maxWidth = "520px";
|
||||
|
||||
let updated = 0, current = 0, processed = 0;
|
||||
const failed = [];
|
||||
for (let i = 0; i < targets.length && !stopped; i++) {
|
||||
const it = targets[i];
|
||||
status.textContent = `${fmt.format(i + 1)} of ${fmt.format(targets.length)} · ${it.title}`;
|
||||
processed++;
|
||||
try {
|
||||
const res = await api("POST", `/api/items/${it.id}/refresh`, { replaceCustomArt });
|
||||
if (res.changed.length) updated++; else current++;
|
||||
if (res.warning) failed.push({ title: it.title, error: res.warning });
|
||||
} catch (err) {
|
||||
failed.push({ title: it.title, error: err.message });
|
||||
// Missing keys affect every entry from that service: stop early.
|
||||
if (err.status === 424) break;
|
||||
}
|
||||
bar.style.width = `${Math.round((processed / targets.length) * 100)}%`;
|
||||
}
|
||||
|
||||
finished = true;
|
||||
dlg.querySelector(".dialog-head h2").textContent = processed < targets.length ? "Refresh stopped" : "Refresh finished";
|
||||
body.replaceChildren(...[
|
||||
h("p", {}, [
|
||||
`${fmt.format(updated)} updated`,
|
||||
`${fmt.format(current)} already up to date`,
|
||||
failed.length && `${fmt.format(failed.length)} with problems`,
|
||||
processed < targets.length && `${fmt.format(targets.length - processed)} not reached`,
|
||||
].filter(Boolean).join(" · ")),
|
||||
failed.length > 0 && h("ul", { class: "refresh-failures" },
|
||||
failed.slice(0, 12).map((f) => h("li", {}, h("b", {}, f.title), " — ", f.error)),
|
||||
failed.length > 12 && h("li", { class: "muted" }, `…and ${failed.length - 12} more`)),
|
||||
].filter(Boolean));
|
||||
foot.replaceChildren(h("button", { type: "button", class: "btn btn--primary", onclick: () => dlg.close() }, "Done"));
|
||||
await load(page);
|
||||
}
|
||||
|
||||
document.querySelector("[data-action=refresh-all]")?.addEventListener("click", refreshAll);
|
||||
|
||||
// Songs and albums from Deezer, or matched to Deezer when saved, can play.
|
||||
function canPreview(it) {
|
||||
return (cat.key === "song" || cat.key === "album") && (it.provider === "deezer" || !!it.extra?.deezer);
|
||||
@@ -491,6 +623,7 @@ function openItemForm({ item, result }) {
|
||||
} else {
|
||||
Object.assign(body, {
|
||||
provider: result?.provider || "", providerId: result?.providerId || "", details: !!result,
|
||||
customArt: !!result && body.imageUrl !== "" && body.imageUrl !== (result.imageUrl || ""),
|
||||
});
|
||||
const created = await createItem(body);
|
||||
if (created && pendingFile) {
|
||||
|
||||
@@ -137,6 +137,30 @@ export function confirmDialog({ title, message, confirm = "Confirm", danger = fa
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask a question with several answers. Resolves to the chosen value, or
|
||||
* null if dismissed. choices: [{ label, value, primary }]
|
||||
*/
|
||||
export function chooseDialog({ title, message, choices }) {
|
||||
return new Promise((resolve) => {
|
||||
let picked = null;
|
||||
let dlg;
|
||||
const content = h("div", {},
|
||||
dialogHead(title, () => dlg),
|
||||
h("div", { class: "dialog-body" }, h("p", { class: "muted" }, message)),
|
||||
h("div", { class: "dialog-foot" },
|
||||
h("button", { type: "button", class: "btn btn--ghost", onclick: () => dlg.close() }, "Cancel"),
|
||||
choices.map((c) => h("button", {
|
||||
type: "button", class: "btn" + (c.primary ? " btn--primary" : ""),
|
||||
onclick: () => { picked = c.value; dlg.close(); },
|
||||
}, c.label)),
|
||||
),
|
||||
);
|
||||
dlg = openDialog(content, { onClose: () => resolve(picked) });
|
||||
dlg.style.maxWidth = "480px";
|
||||
});
|
||||
}
|
||||
|
||||
export function debounce(fn, ms) {
|
||||
let t;
|
||||
return (...args) => {
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
<div class="admin-head-actions">
|
||||
<a class="btn btn--ghost" data-bind="public" href="/l/{{.Data.List.Slug}}">{{icon "external"}}View</a>
|
||||
{{template "export" .Data.List.Slug}}
|
||||
{{if ne $c.Key "custom"}}<button class="btn btn--ghost" type="button" data-action="refresh-all" title="Pull the latest details and artwork from each entry's source">{{icon "refresh"}}Refresh all</button>{{end}}
|
||||
<button class="btn" type="button" data-action="edit-list">{{icon "settings"}}List settings</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
Reference in new issue
Block a user