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
+792 -88

No files matched your search

+1
View File
@@ -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. - **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. - **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. - **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. - **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. - **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`. - **Export** any list as a titles-only `.csv` or `.txt`.
+24 -3
View File
@@ -58,11 +58,32 @@ func (g *igdb) token(ctx context.Context, fresh bool) (string, error) {
return c.token, nil 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) { func (g *igdb) Search(ctx context.Context, query string) ([]Result, error) {
q := strings.NewReplacer(`\`, ``, `"`, `\"`).Replace(query) q := strings.NewReplacer(`\`, ``, `"`, `\"`).Replace(query)
body := `search "` + q + `"; return g.query(ctx, `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; `+igdbFields+`
limit 25;` 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 { var games []struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Name string `json:"name"` Name string `json:"name"`
+30
View File
@@ -62,3 +62,33 @@ func pick(res []Result, i int) Result {
} }
return res[i] 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
}
+157 -58
View File
@@ -92,6 +92,24 @@ func (d *deezer) Search(ctx context.Context, query string) ([]Result, error) {
return nil, err return nil, err
} }
for _, a := range resp.Data { for _, a := range resp.Data {
out = append(out, albumResult(a))
}
return out, nil
}
var resp struct {
deezerErr
Data []deezerTrack `json:"data"`
}
if err := d.get(ctx, "/search/track?"+q, &resp, &resp.deezerErr); err != nil {
return nil, err
}
for _, t := range resp.Data {
out = append(out, trackResult(t))
}
return out, nil
}
func albumResult(a deezerAlbum) Result {
r := Result{ r := Result{
Provider: "deezer", Provider: "deezer",
ProviderID: "album:" + strconv.FormatInt(a.ID, 10), ProviderID: "album:" + strconv.FormatInt(a.ID, 10),
@@ -107,19 +125,11 @@ func (d *deezer) Search(ctx context.Context, query string) ([]Result, error) {
if a.NbTracks > 0 { if a.NbTracks > 0 {
r.Hint = fmt.Sprintf("%d tracks", a.NbTracks) r.Hint = fmt.Sprintf("%d tracks", a.NbTracks)
} }
out = append(out, r) return r
} }
return out, nil
} func trackResult(t deezerTrack) Result {
var resp struct { return Result{
deezerErr
Data []deezerTrack `json:"data"`
}
if err := d.get(ctx, "/search/track?"+q, &resp, &resp.deezerErr); err != nil {
return nil, err
}
for _, t := range resp.Data {
out = append(out, Result{
Provider: "deezer", Provider: "deezer",
ProviderID: "track:" + strconv.FormatInt(t.ID, 10), ProviderID: "track:" + strconv.FormatInt(t.ID, 10),
Title: t.Title, Title: t.Title,
@@ -128,9 +138,38 @@ func (d *deezer) Search(ctx context.Context, query string) ([]Result, error) {
ExternalURL: t.Link, ExternalURL: t.Link,
Hint: "From " + t.Album.Title, Hint: "From " + t.Album.Title,
Extra: map[string]string{"album": t.Album.Title}, Extra: map[string]string{"album": t.Album.Title},
})
} }
return out, nil }
// 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) { func (d *deezer) Details(ctx context.Context, res Result) (Result, error) {
@@ -147,6 +186,22 @@ func (d *deezer) Details(ctx context.Context, res Result) (Result, error) {
if err := d.get(ctx, "/album/"+id, &a, &a.deezerErr); err != nil { if err := d.get(ctx, "/album/"+id, &a, &a.deezerErr); err != nil {
return res, err return res, err
} }
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
}
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) res.Year = yearOf(a.ReleaseDate)
if a.Label != "" { if a.Label != "" {
res.Extra["label"] = a.Label res.Extra["label"] = a.Label
@@ -158,10 +213,13 @@ func (d *deezer) Details(ctx context.Context, res Result) (Result, error) {
if len(g) > 0 { if len(g) > 0 {
res.Extra["genres"] = strings.Join(g, ", ") res.Extra["genres"] = strings.Join(g, ", ")
} }
case "track": return res
var t deezerTrack }
if err := d.get(ctx, "/track/"+id, &t, &t.deezerErr); err != nil {
return res, err // 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) res.Year = yearOf(t.ReleaseDate)
if res.Year == "" { if res.Year == "" {
@@ -170,8 +228,7 @@ func (d *deezer) Details(ctx context.Context, res Result) (Result, error) {
if t.Duration > 0 { if t.Duration > 0 {
res.Extra["length"] = fmt.Sprintf("%d:%02d", t.Duration/60, t.Duration%60) res.Extra["length"] = fmt.Sprintf("%d:%02d", t.Duration/60, t.Duration%60)
} }
} return res
return res, nil
} }
// musicbrainz uses the MusicBrainz database with art from the Cover Art // musicbrainz uses the MusicBrainz database with art from the Cover Art
@@ -198,27 +255,92 @@ func mbArtists(credits []mbCredit) string {
return strings.TrimSpace(b.String()) return strings.TrimSpace(b.String())
} }
func (m *musicbrainz) Search(ctx context.Context, query string) ([]Result, error) { type mbReleaseGroup struct {
if err := m.r.mb.wait(ctx); err != nil {
return nil, err
}
var out []Result
if m.kind == "album" {
var resp struct {
ReleaseGroups []struct {
ID string `json:"id"` ID string `json:"id"`
Title string `json:"title"` Title string `json:"title"`
PrimaryType string `json:"primary-type"` PrimaryType string `json:"primary-type"`
SecondaryTypes []string `json:"secondary-types"` SecondaryTypes []string `json:"secondary-types"`
FirstReleaseDate string `json:"first-release-date"` FirstReleaseDate string `json:"first-release-date"`
ArtistCredit []mbCredit `json:"artist-credit"` ArtistCredit []mbCredit `json:"artist-credit"`
} `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 { 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) {
var out []Result
q := url.Values{"query": {query}, "fmt": {"json"}, "limit": {"25"}}.Encode()
if m.kind == "album" {
var resp struct {
ReleaseGroups []mbReleaseGroup `json:"release-groups"`
}
if err := m.get(ctx, "/release-group?"+q, &resp); err != nil {
return nil, err return nil, err
} }
for _, rg := range resp.ReleaseGroups { for _, rg := range resp.ReleaseGroups {
out = append(out, rgResult(rg))
}
return out, nil
}
var resp struct {
Recordings []mbRecording `json:"recordings"`
}
if err := m.get(ctx, "/recording?"+q, &resp); err != nil {
return nil, err
}
for _, rec := range resp.Recordings {
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{ r := Result{
Provider: "musicbrainz", Provider: "musicbrainz",
ProviderID: "release-group:" + rg.ID, ProviderID: "release-group:" + rg.ID,
@@ -237,31 +359,10 @@ func (m *musicbrainz) Search(ctx context.Context, query string) ([]Result, error
if len(types) > 0 { if len(types) > 0 {
r.Extra["type"] = strings.Join(types, " · ") r.Extra["type"] = strings.Join(types, " · ")
} }
out = append(out, r) return r
} }
return out, nil
} func recResult(rec mbRecording) Result {
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"`
}
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 {
return nil, err
}
for _, rec := range resp.Recordings {
r := Result{ r := Result{
Provider: "musicbrainz", Provider: "musicbrainz",
ProviderID: "recording:" + rec.ID, ProviderID: "recording:" + rec.ID,
@@ -286,7 +387,5 @@ func (m *musicbrainz) Search(ctx context.Context, query string) ([]Result, error
s := rec.Length / 1000 s := rec.Length / 1000
r.Extra["length"] = fmt.Sprintf("%d:%02d", s/60, s%60) r.Extra["length"] = fmt.Sprintf("%d:%02d", s/60, s%60)
} }
out = append(out, r) return r
}
return out, nil
} }
+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 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)
}
+80 -12
View File
@@ -7,6 +7,7 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"net/url" "net/url"
"strconv"
"sync" "sync"
"time" "time"
) )
@@ -79,20 +80,9 @@ func (t *tvdb) Search(ctx context.Context, query string) ([]Result, error) {
} `json:"data"` } `json:"data"`
} }
u := "https://api4.thetvdb.com/v4/search?" + url.Values{"query": {query}, "type": {"series"}, "limit": {"20"}}.Encode() u := "https://api4.thetvdb.com/v4/search?" + url.Values{"query": {query}, "type": {"series"}, "limit": {"20"}}.Encode()
for attempt := 0; ; attempt++ { if err := t.get(ctx, u, &resp); err != nil {
tok, err := t.token(ctx, attempt > 0)
if err != nil {
return nil, err 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
}
out := make([]Result, 0, len(resp.Data)) out := make([]Result, 0, len(resp.Data))
for _, s := range resp.Data { for _, s := range resp.Data {
name := s.Name name := s.Name
@@ -128,3 +118,81 @@ func (t *tvdb) Search(ctx context.Context, query string) ([]Result, error) {
} }
return out, nil 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+`) var leadingYear = regexp.MustCompile(`^(1[5-9]\d\d|20\d\d)\s+`)
func (w *wikipedia) search(ctx context.Context, query string) ([]Result, error) { 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{ q := url.Values{
"action": {"query"}, "action": {"query"},
"format": {"json"}, "format": {"json"},
"formatversion": {"2"}, "formatversion": {"2"},
"generator": {"search"},
"gsrsearch": {query},
"gsrlimit": {"15"},
"prop": {"pageimages|description|info|extracts"}, "prop": {"pageimages|description|info|extracts"},
"piprop": {"thumbnail"}, "piprop": {"thumbnail"},
"pithumbsize": {"800"}, "pithumbsize": {"800"},
@@ -56,10 +71,14 @@ func (w *wikipedia) search(ctx context.Context, query string) ([]Result, error)
"exsentences": {"3"}, "exsentences": {"3"},
"exlimit": {"max"}, "exlimit": {"max"},
} }
for k, v := range which {
q[k] = v
}
var resp struct { var resp struct {
Query struct { Query struct {
Pages []struct { Pages []struct {
PageID int64 `json:"pageid"` PageID int64 `json:"pageid"`
Missing bool `json:"missing"`
Title string `json:"title"` Title string `json:"title"`
Index int `json:"index"` Index int `json:"index"`
Description string `json:"description"` 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 }) sort.Slice(pages, func(i, j int) bool { return pages[i].Index < pages[j].Index })
out := make([]Result, 0, len(pages)) out := make([]Result, 0, len(pages))
for _, p := range pages { for _, p := range pages {
if p.Missing {
continue
}
title := p.Title title := p.Title
// "Hamilton (musical)" → "Hamilton" // "Hamilton (musical)" → "Hamilton"
if i := strings.LastIndex(title, " ("); i > 0 && strings.HasSuffix(title, ")") { 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"` Extra map[string]string `json:"extra"`
ImageURL string `json:"imageUrl"` ImageURL string `json:"imageUrl"`
ClearImage bool `json:"clearImage"` 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. // Only used when adding.
Provider string `json:"provider"` Provider string `json:"provider"`
@@ -196,7 +199,7 @@ func (s *Server) apiAddItem(w http.ResponseWriter, r *http.Request) {
if err != nil { if err != nil {
warning = "Added without artwork: " + err.Error() warning = "Added without artwork: " + err.Error()
} else { } 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) s.matchDeezer(ctx, list.Category, &it)
@@ -273,8 +276,10 @@ func (s *Server) apiUpdateItem(w http.ResponseWriter, r *http.Request) {
if in.Extra != nil { if in.Extra != nil {
it.Extra = in.Extra 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 { if in.ClearImage {
it.Image, it.ImageSource = "", "" it.Image, it.ImageSource, it.CustomArt = "", "", true
} }
if url := strings.TrimSpace(in.ImageURL); url != "" && url != cur.ImageSource { if url := strings.TrimSpace(in.ImageURL); url != "" && url != cur.ImageSource {
name, err := s.fetchImage(ctx, url) 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()}) s.writeErr(w, store.ValidationError{Msg: "Couldn't use that image: " + err.Error()})
return 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 it.Title != cur.Title || it.Subtitle != cur.Subtitle || it.Extra["deezer"] == "" {
if l, err := s.store.ListByID(ctx, it.ListID); err == nil { 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 return
} }
it := cur it := cur
it.Image, it.ImageSource = name, "upload" it.Image, it.ImageSource, it.CustomArt = name, "upload", true
saved, err := s.store.UpdateItem(ctx, it) saved, err := s.store.UpdateItem(ctx, it)
if err != nil { if err != nil {
s.releaseImage(ctx, name) 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("PUT /api/items/{id}", s.apiUpdateItem)
api("POST /api/items/{id}/image", s.apiUploadItemImage) api("POST /api/items/{id}/image", s.apiUploadItemImage)
api("POST /api/items/{id}/move", s.apiMoveItem) api("POST /api/items/{id}/move", s.apiMoveItem)
api("POST /api/items/{id}/refresh", s.apiRefreshItem)
api("DELETE /api/items/{id}", s.apiDeleteItem) api("DELETE /api/items/{id}", s.apiDeleteItem)
api("GET /api/search", s.apiSearch) api("GET /api/search", s.apiSearch)
api("GET /api/settings", s.apiSettings) api("GET /api/settings", s.apiSettings)
+8 -7
View File
@@ -17,6 +17,7 @@ type Item struct {
Year string `json:"year"` Year string `json:"year"`
Image string `json:"image"` // cached file name under /images/ Image string `json:"image"` // cached file name under /images/
ImageSource string `json:"imageSource"` // where the image came from ImageSource string `json:"imageSource"` // where the image came from
CustomArt bool `json:"customArt"` // chosen by the owner, not the source
Provider string `json:"provider"` Provider string `json:"provider"`
ProviderID string `json:"providerId"` ProviderID string `json:"providerId"`
ExternalURL string `json:"externalUrl"` ExternalURL string `json:"externalUrl"`
@@ -25,12 +26,12 @@ type Item struct {
Extra map[string]string `json:"extra"` 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) { func scanItem(row interface{ Scan(...any) error }) (Item, error) {
var it Item var it Item
var extra string 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) &it.Provider, &it.ProviderID, &it.ExternalURL, &it.Description, &it.Note, &extra)
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return it, ErrNotFound 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 { if _, err := tx.ExecContext(ctx, `UPDATE items SET rank = rank + 1 WHERE list_id = ? AND rank >= ?`, it.ListID, rank); err != nil {
return it, err 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) 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, 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)) 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 { if err != nil {
return it, err return it, err
} }
@@ -159,8 +160,8 @@ func (s *Store) UpdateItem(ctx context.Context, it Item) (Item, error) {
if err := it.normalize(); err != nil { if err := it.normalize(); err != nil {
return it, err 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=?`, 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.ExternalURL, it.Description, it.Note, extraJSON(it.Extra), it.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 { if err != nil {
return it, err 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 '';`, `ALTER TABLE lists ADD COLUMN art TEXT NOT NULL DEFAULT '';`,
// The source's description of the item (TMDB overview, IGDB summary…). // The source's description of the item (TMDB overview, IGDB summary…).
`ALTER TABLE items ADD COLUMN description TEXT NOT NULL DEFAULT '';`, `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 { func (s *Store) migrate(ctx context.Context) error {
+5
View File
@@ -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; } .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; } } @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 */
.settings { display: grid; grid-template-columns: 200px 1fr; gap: 32px; align-items: start; } .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; } .settings-nav { position: sticky; top: 80px; display: flex; flex-direction: column; gap: 2px; }
+134 -1
View File
@@ -1,5 +1,5 @@
// List editor: search providers, add entries, rank and edit them. // 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 { sortable } from "./sortable.js";
import { openListForm } from "./list-form.js"; import { openListForm } from "./list-form.js";
import { previewButton } from "./player.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"), 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 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")), 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", "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")), 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. // Songs and albums from Deezer, or matched to Deezer when saved, can play.
function canPreview(it) { function canPreview(it) {
return (cat.key === "song" || cat.key === "album") && (it.provider === "deezer" || !!it.extra?.deezer); return (cat.key === "song" || cat.key === "album") && (it.provider === "deezer" || !!it.extra?.deezer);
@@ -491,6 +623,7 @@ function openItemForm({ item, result }) {
} else { } else {
Object.assign(body, { Object.assign(body, {
provider: result?.provider || "", providerId: result?.providerId || "", details: !!result, provider: result?.provider || "", providerId: result?.providerId || "", details: !!result,
customArt: !!result && body.imageUrl !== "" && body.imageUrl !== (result.imageUrl || ""),
}); });
const created = await createItem(body); const created = await createItem(body);
if (created && pendingFile) { if (created && pendingFile) {
+24
View File
@@ -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) { export function debounce(fn, ms) {
let t; let t;
return (...args) => { return (...args) => {
+1
View File
@@ -14,6 +14,7 @@
<div class="admin-head-actions"> <div class="admin-head-actions">
<a class="btn btn--ghost" data-bind="public" href="/l/{{.Data.List.Slug}}">{{icon "external"}}View</a> <a class="btn btn--ghost" data-bind="public" href="/l/{{.Data.List.Slug}}">{{icon "external"}}View</a>
{{template "export" .Data.List.Slug}} {{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> <button class="btn" type="button" data-action="edit-list">{{icon "settings"}}List settings</button>
</div> </div>
</header> </header>