diff --git a/README.md b/README.md index 41032cb..4df96cb 100644 --- a/README.md +++ b/README.md @@ -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`. diff --git a/internal/providers/igdb.go b/internal/providers/igdb.go index f810703..a5eb5dc 100644 --- a/internal/providers/igdb.go +++ b/internal/providers/igdb.go @@ -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"` diff --git a/internal/providers/live_test.go b/internal/providers/live_test.go index 665c162..253396a 100644 --- a/internal/providers/live_test.go +++ b/internal/providers/live_test.go @@ -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) + } +} diff --git a/internal/providers/lookup.go b/internal/providers/lookup.go new file mode 100644 index 0000000..d165214 --- /dev/null +++ b/internal/providers/lookup.go @@ -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 +} diff --git a/internal/providers/music.go b/internal/providers/music.go index 7f3763e..fbf3d53 100644 --- a/internal/providers/music.go +++ b/internal/providers/music.go @@ -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 +} diff --git a/internal/providers/music_both_test.go b/internal/providers/music_both_test.go index 857e7f1..c69048d 100644 --- a/internal/providers/music_both_test.go +++ b/internal/providers/music_both_test.go @@ -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) + } + } +} diff --git a/internal/providers/tmdb.go b/internal/providers/tmdb.go index e68e13e..e23094f 100644 --- a/internal/providers/tmdb.go +++ b/internal/providers/tmdb.go @@ -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) +} diff --git a/internal/providers/tvdb.go b/internal/providers/tvdb.go index ca06378..db7940f 100644 --- a/internal/providers/tvdb.go +++ b/internal/providers/tvdb.go @@ -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 +} diff --git a/internal/providers/wikipedia.go b/internal/providers/wikipedia.go index c53cece..91336fb 100644 --- a/internal/providers/wikipedia.go +++ b/internal/providers/wikipedia.go @@ -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, ")") { diff --git a/internal/server/api.go b/internal/server/api.go index c4ef130..a10fdb7 100644 --- a/internal/server/api.go +++ b/internal/server/api.go @@ -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) diff --git a/internal/server/refresh.go b/internal/server/refresh.go new file mode 100644 index 0000000..87e1db0 --- /dev/null +++ b/internal/server/refresh.go @@ -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 +} diff --git a/internal/server/server.go b/internal/server/server.go index 7fd0deb..318a363 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -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) diff --git a/internal/store/items.go b/internal/store/items.go index 8caec8a..df0b26d 100644 --- a/internal/store/items.go +++ b/internal/store/items.go @@ -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 } diff --git a/internal/store/store.go b/internal/store/store.go index 51e8572..7aff240 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -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 { diff --git a/web/static/css/app.css b/web/static/css/app.css index 2c3e281..42a408e 100644 --- a/web/static/css/app.css +++ b/web/static/css/app.css @@ -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; } diff --git a/web/static/js/admin-list.js b/web/static/js/admin-list.js index 265c21a..e05f4c2 100644 --- a/web/static/js/admin-list.js +++ b/web/static/js/admin-list.js @@ -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) { diff --git a/web/static/js/common.js b/web/static/js/common.js index 034a4fa..102c8db 100644 --- a/web/static/js/common.js +++ b/web/static/js/common.js @@ -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) => { diff --git a/web/templates/pages/admin_list.html b/web/templates/pages/admin_list.html index 785e459..f2edcf8 100644 --- a/web/templates/pages/admin_list.html +++ b/web/templates/pages/admin_list.html @@ -14,6 +14,7 @@