Add refresh from source for entries and whole lists
- Every provider can look an entry up again by its ID (TMDB, TheTVDB,
IGDB, Deezer, MusicBrainz, Wikipedia); search and lookup share parsing
- POST /api/items/{id}/refresh re-applies title, subtitle, year,
description, link, details and artwork. Kept: the "why" note, rank,
theater production/venue/cast and a theater entry's year, the Deezer
preview match. Empty source fields don't erase existing values
- New items.image_custom column (migration 4; existing uploads marked)
records artwork the owner chose; refresh keeps it unless asked
- Refused for custom lists and hand-added entries; entries the source
dropped are reported, and a refresh whose title shares no words with
the current one is rejected as a likely bad ID
- Editor: refresh button per row (asks about the owner's own artwork),
and "Refresh all" with a confirm step, replace-my-art checkbox,
progress with Stop, and a summary of problems
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4ea7cbc984
commit
33053d1c5f
18 files changed
+852
-148
No files matched your search
+216
-117
@@ -92,22 +92,7 @@ func (d *deezer) Search(ctx context.Context, query string) ([]Result, error) {
|
||||
return nil, err
|
||||
}
|
||||
for _, a := range resp.Data {
|
||||
r := Result{
|
||||
Provider: "deezer",
|
||||
ProviderID: "album:" + strconv.FormatInt(a.ID, 10),
|
||||
Title: a.Title,
|
||||
Subtitle: a.Artist.Name,
|
||||
ImageURL: cover(a),
|
||||
ExternalURL: a.Link,
|
||||
Extra: map[string]string{},
|
||||
}
|
||||
if a.RecordType != "" && a.RecordType != "album" {
|
||||
r.Extra["type"] = strings.ToUpper(a.RecordType[:1]) + a.RecordType[1:]
|
||||
}
|
||||
if a.NbTracks > 0 {
|
||||
r.Hint = fmt.Sprintf("%d tracks", a.NbTracks)
|
||||
}
|
||||
out = append(out, r)
|
||||
out = append(out, albumResult(a))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -119,20 +104,74 @@ func (d *deezer) Search(ctx context.Context, query string) ([]Result, error) {
|
||||
return nil, err
|
||||
}
|
||||
for _, t := range resp.Data {
|
||||
out = append(out, Result{
|
||||
Provider: "deezer",
|
||||
ProviderID: "track:" + strconv.FormatInt(t.ID, 10),
|
||||
Title: t.Title,
|
||||
Subtitle: t.Artist.Name,
|
||||
ImageURL: cover(t.Album),
|
||||
ExternalURL: t.Link,
|
||||
Hint: "From " + t.Album.Title,
|
||||
Extra: map[string]string{"album": t.Album.Title},
|
||||
})
|
||||
out = append(out, trackResult(t))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func albumResult(a deezerAlbum) Result {
|
||||
r := Result{
|
||||
Provider: "deezer",
|
||||
ProviderID: "album:" + strconv.FormatInt(a.ID, 10),
|
||||
Title: a.Title,
|
||||
Subtitle: a.Artist.Name,
|
||||
ImageURL: cover(a),
|
||||
ExternalURL: a.Link,
|
||||
Extra: map[string]string{},
|
||||
}
|
||||
if a.RecordType != "" && a.RecordType != "album" {
|
||||
r.Extra["type"] = strings.ToUpper(a.RecordType[:1]) + a.RecordType[1:]
|
||||
}
|
||||
if a.NbTracks > 0 {
|
||||
r.Hint = fmt.Sprintf("%d tracks", a.NbTracks)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func trackResult(t deezerTrack) Result {
|
||||
return Result{
|
||||
Provider: "deezer",
|
||||
ProviderID: "track:" + strconv.FormatInt(t.ID, 10),
|
||||
Title: t.Title,
|
||||
Subtitle: t.Artist.Name,
|
||||
ImageURL: cover(t.Album),
|
||||
ExternalURL: t.Link,
|
||||
Hint: "From " + t.Album.Title,
|
||||
Extra: map[string]string{"album": t.Album.Title},
|
||||
}
|
||||
}
|
||||
|
||||
// Lookup refreshes one album or track by its Deezer ID.
|
||||
func (d *deezer) Lookup(ctx context.Context, providerID string) (Result, error) {
|
||||
kind, id, _ := strings.Cut(providerID, ":")
|
||||
if _, err := strconv.ParseInt(id, 10, 64); err != nil {
|
||||
return Result{}, ErrGone
|
||||
}
|
||||
switch kind {
|
||||
case "album":
|
||||
var a deezerAlbum
|
||||
if err := d.get(ctx, "/album/"+id, &a, &a.deezerErr); err != nil {
|
||||
return Result{}, deezerGone(err)
|
||||
}
|
||||
return applyAlbum(albumResult(a), a), nil
|
||||
case "track":
|
||||
var t deezerTrack
|
||||
if err := d.get(ctx, "/track/"+id, &t, &t.deezerErr); err != nil {
|
||||
return Result{}, deezerGone(err)
|
||||
}
|
||||
return applyTrack(trackResult(t), t), nil
|
||||
}
|
||||
return Result{}, ErrGone
|
||||
}
|
||||
|
||||
// Deezer answers lookups of removed items with an error object.
|
||||
func deezerGone(err error) error {
|
||||
if strings.Contains(err.Error(), "no data") || strings.Contains(err.Error(), "not found") {
|
||||
return ErrGone
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *deezer) Details(ctx context.Context, res Result) (Result, error) {
|
||||
if res.Provider != "deezer" {
|
||||
return res, nil
|
||||
@@ -147,33 +186,51 @@ func (d *deezer) Details(ctx context.Context, res Result) (Result, error) {
|
||||
if err := d.get(ctx, "/album/"+id, &a, &a.deezerErr); err != nil {
|
||||
return res, err
|
||||
}
|
||||
res.Year = yearOf(a.ReleaseDate)
|
||||
if a.Label != "" {
|
||||
res.Extra["label"] = a.Label
|
||||
}
|
||||
var g []string
|
||||
for _, x := range a.Genres.Data {
|
||||
g = append(g, x.Name)
|
||||
}
|
||||
if len(g) > 0 {
|
||||
res.Extra["genres"] = strings.Join(g, ", ")
|
||||
}
|
||||
return applyAlbum(res, a), nil
|
||||
case "track":
|
||||
var t deezerTrack
|
||||
if err := d.get(ctx, "/track/"+id, &t, &t.deezerErr); err != nil {
|
||||
return res, err
|
||||
}
|
||||
res.Year = yearOf(t.ReleaseDate)
|
||||
if res.Year == "" {
|
||||
res.Year = yearOf(t.Album.ReleaseDate)
|
||||
}
|
||||
if t.Duration > 0 {
|
||||
res.Extra["length"] = fmt.Sprintf("%d:%02d", t.Duration/60, t.Duration%60)
|
||||
}
|
||||
return applyTrack(res, t), nil
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// applyAlbum adds the details only a full album lookup has.
|
||||
func applyAlbum(res Result, a deezerAlbum) Result {
|
||||
if res.Extra == nil {
|
||||
res.Extra = map[string]string{}
|
||||
}
|
||||
res.Year = yearOf(a.ReleaseDate)
|
||||
if a.Label != "" {
|
||||
res.Extra["label"] = a.Label
|
||||
}
|
||||
var g []string
|
||||
for _, x := range a.Genres.Data {
|
||||
g = append(g, x.Name)
|
||||
}
|
||||
if len(g) > 0 {
|
||||
res.Extra["genres"] = strings.Join(g, ", ")
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// applyTrack adds the details only a full track lookup has.
|
||||
func applyTrack(res Result, t deezerTrack) Result {
|
||||
if res.Extra == nil {
|
||||
res.Extra = map[string]string{}
|
||||
}
|
||||
res.Year = yearOf(t.ReleaseDate)
|
||||
if res.Year == "" {
|
||||
res.Year = yearOf(t.Album.ReleaseDate)
|
||||
}
|
||||
if t.Duration > 0 {
|
||||
res.Extra["length"] = fmt.Sprintf("%d:%02d", t.Duration/60, t.Duration%60)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// musicbrainz uses the MusicBrainz database with art from the Cover Art
|
||||
// Archive. Fully open data, but limited to one request per second.
|
||||
type musicbrainz struct {
|
||||
@@ -198,95 +255,137 @@ func mbArtists(credits []mbCredit) string {
|
||||
return strings.TrimSpace(b.String())
|
||||
}
|
||||
|
||||
type mbReleaseGroup struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
PrimaryType string `json:"primary-type"`
|
||||
SecondaryTypes []string `json:"secondary-types"`
|
||||
FirstReleaseDate string `json:"first-release-date"`
|
||||
ArtistCredit []mbCredit `json:"artist-credit"`
|
||||
}
|
||||
|
||||
type mbRecording struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Length int `json:"length"`
|
||||
FirstReleaseDate string `json:"first-release-date"`
|
||||
ArtistCredit []mbCredit `json:"artist-credit"`
|
||||
Releases []struct {
|
||||
Title string `json:"title"`
|
||||
ReleaseGroup struct {
|
||||
ID string `json:"id"`
|
||||
PrimaryType string `json:"primary-type"`
|
||||
} `json:"release-group"`
|
||||
} `json:"releases"`
|
||||
}
|
||||
|
||||
func (m *musicbrainz) Search(ctx context.Context, query string) ([]Result, error) {
|
||||
if err := m.r.mb.wait(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []Result
|
||||
q := url.Values{"query": {query}, "fmt": {"json"}, "limit": {"25"}}.Encode()
|
||||
if m.kind == "album" {
|
||||
var resp struct {
|
||||
ReleaseGroups []struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
PrimaryType string `json:"primary-type"`
|
||||
SecondaryTypes []string `json:"secondary-types"`
|
||||
FirstReleaseDate string `json:"first-release-date"`
|
||||
ArtistCredit []mbCredit `json:"artist-credit"`
|
||||
} `json:"release-groups"`
|
||||
ReleaseGroups []mbReleaseGroup `json:"release-groups"`
|
||||
}
|
||||
u := "https://musicbrainz.org/ws/2/release-group?" + url.Values{"query": {query}, "fmt": {"json"}, "limit": {"25"}}.Encode()
|
||||
if err := m.r.getJSON(ctx, u, nil, &resp); err != nil {
|
||||
if err := m.get(ctx, "/release-group?"+q, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, rg := range resp.ReleaseGroups {
|
||||
r := Result{
|
||||
Provider: "musicbrainz",
|
||||
ProviderID: "release-group:" + rg.ID,
|
||||
Title: rg.Title,
|
||||
Subtitle: mbArtists(rg.ArtistCredit),
|
||||
Year: yearOf(rg.FirstReleaseDate),
|
||||
ImageURL: "https://coverartarchive.org/release-group/" + rg.ID + "/front-500",
|
||||
ExternalURL: "https://musicbrainz.org/release-group/" + rg.ID,
|
||||
Extra: map[string]string{},
|
||||
}
|
||||
// e.g. "Soundtrack", "Live", "EP · Soundtrack"
|
||||
types := rg.SecondaryTypes
|
||||
if rg.PrimaryType != "" && rg.PrimaryType != "Album" {
|
||||
types = append([]string{rg.PrimaryType}, types...)
|
||||
}
|
||||
if len(types) > 0 {
|
||||
r.Extra["type"] = strings.Join(types, " · ")
|
||||
}
|
||||
out = append(out, r)
|
||||
out = append(out, rgResult(rg))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
var resp struct {
|
||||
Recordings []struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Length int `json:"length"`
|
||||
FirstReleaseDate string `json:"first-release-date"`
|
||||
ArtistCredit []mbCredit `json:"artist-credit"`
|
||||
Releases []struct {
|
||||
Title string `json:"title"`
|
||||
ReleaseGroup struct {
|
||||
ID string `json:"id"`
|
||||
PrimaryType string `json:"primary-type"`
|
||||
} `json:"release-group"`
|
||||
} `json:"releases"`
|
||||
} `json:"recordings"`
|
||||
Recordings []mbRecording `json:"recordings"`
|
||||
}
|
||||
u := "https://musicbrainz.org/ws/2/recording?" + url.Values{"query": {query}, "fmt": {"json"}, "limit": {"25"}}.Encode()
|
||||
if err := m.r.getJSON(ctx, u, nil, &resp); err != nil {
|
||||
if err := m.get(ctx, "/recording?"+q, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, rec := range resp.Recordings {
|
||||
r := Result{
|
||||
Provider: "musicbrainz",
|
||||
ProviderID: "recording:" + rec.ID,
|
||||
Title: rec.Title,
|
||||
Subtitle: mbArtists(rec.ArtistCredit),
|
||||
Year: yearOf(rec.FirstReleaseDate),
|
||||
ExternalURL: "https://musicbrainz.org/recording/" + rec.ID,
|
||||
Extra: map[string]string{},
|
||||
}
|
||||
// Prefer an album release for the artwork.
|
||||
for i, rel := range rec.Releases {
|
||||
if i == 0 || rel.ReleaseGroup.PrimaryType == "Album" {
|
||||
r.ImageURL = "https://coverartarchive.org/release-group/" + rel.ReleaseGroup.ID + "/front-500"
|
||||
r.Extra["album"] = rel.Title
|
||||
r.Hint = "From " + rel.Title
|
||||
if rel.ReleaseGroup.PrimaryType == "Album" {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if rec.Length > 0 {
|
||||
s := rec.Length / 1000
|
||||
r.Extra["length"] = fmt.Sprintf("%d:%02d", s/60, s%60)
|
||||
}
|
||||
out = append(out, r)
|
||||
out = append(out, recResult(rec))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Lookup refreshes one release group (album) or recording (song).
|
||||
func (m *musicbrainz) Lookup(ctx context.Context, providerID string) (Result, error) {
|
||||
kind, id, _ := strings.Cut(providerID, ":")
|
||||
id = url.PathEscape(id)
|
||||
var err error
|
||||
var res Result
|
||||
switch kind {
|
||||
case "release-group":
|
||||
var rg mbReleaseGroup
|
||||
if err = m.get(ctx, "/release-group/"+id+"?inc=artist-credits&fmt=json", &rg); err == nil {
|
||||
res = rgResult(rg)
|
||||
}
|
||||
case "recording":
|
||||
var rec mbRecording
|
||||
if err = m.get(ctx, "/recording/"+id+"?inc=artist-credits+releases+release-groups&fmt=json", &rec); err == nil {
|
||||
res = recResult(rec)
|
||||
}
|
||||
default:
|
||||
return Result{}, ErrGone
|
||||
}
|
||||
if isStatus(err, 404) || isStatus(err, 400) {
|
||||
return Result{}, ErrGone
|
||||
}
|
||||
return res, err
|
||||
}
|
||||
|
||||
// get waits for MusicBrainz's one-request-per-second limit, then fetches.
|
||||
func (m *musicbrainz) get(ctx context.Context, path string, out any) error {
|
||||
if err := m.r.mb.wait(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
return m.r.getJSON(ctx, "https://musicbrainz.org/ws/2"+path, nil, out)
|
||||
}
|
||||
|
||||
func rgResult(rg mbReleaseGroup) Result {
|
||||
r := Result{
|
||||
Provider: "musicbrainz",
|
||||
ProviderID: "release-group:" + rg.ID,
|
||||
Title: rg.Title,
|
||||
Subtitle: mbArtists(rg.ArtistCredit),
|
||||
Year: yearOf(rg.FirstReleaseDate),
|
||||
ImageURL: "https://coverartarchive.org/release-group/" + rg.ID + "/front-500",
|
||||
ExternalURL: "https://musicbrainz.org/release-group/" + rg.ID,
|
||||
Extra: map[string]string{},
|
||||
}
|
||||
// e.g. "Soundtrack", "Live", "EP · Soundtrack"
|
||||
types := rg.SecondaryTypes
|
||||
if rg.PrimaryType != "" && rg.PrimaryType != "Album" {
|
||||
types = append([]string{rg.PrimaryType}, types...)
|
||||
}
|
||||
if len(types) > 0 {
|
||||
r.Extra["type"] = strings.Join(types, " · ")
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func recResult(rec mbRecording) Result {
|
||||
r := Result{
|
||||
Provider: "musicbrainz",
|
||||
ProviderID: "recording:" + rec.ID,
|
||||
Title: rec.Title,
|
||||
Subtitle: mbArtists(rec.ArtistCredit),
|
||||
Year: yearOf(rec.FirstReleaseDate),
|
||||
ExternalURL: "https://musicbrainz.org/recording/" + rec.ID,
|
||||
Extra: map[string]string{},
|
||||
}
|
||||
// Prefer an album release for the artwork.
|
||||
for i, rel := range rec.Releases {
|
||||
if i == 0 || rel.ReleaseGroup.PrimaryType == "Album" {
|
||||
r.ImageURL = "https://coverartarchive.org/release-group/" + rel.ReleaseGroup.ID + "/front-500"
|
||||
r.Extra["album"] = rel.Title
|
||||
r.Hint = "From " + rel.Title
|
||||
if rel.ReleaseGroup.PrimaryType == "Album" {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if rec.Length > 0 {
|
||||
s := rec.Length / 1000
|
||||
r.Extra["length"] = fmt.Sprintf("%d:%02d", s/60, s%60)
|
||||
}
|
||||
return r
|
||||
}
|
||||
Reference in new issue
Block a user