Files
canon/internal/providers/music.go
T
cdnutterandClaude Opus 5.5 7320e3f707 Save source descriptions, add "Why it's #N", fix IGDB studio names
Descriptions
- Items keep the source's description (TMDB/TheTVDB overview, IGDB
  summary, Wikipedia intro) in a new items.description column
  (migration 3); editable in the item form
- Deezer/MusicBrainz track counts and album names are now display-only
  hints, not saved as descriptions

Why it's #N
- The owner's note is presented as "Why it's #N" in the editor and the
  public detail view, above an "About" section with the description

IGDB developers
- IGDB credits internal divisions ("Nintendo EAD", "Nintendo EPD
  Production Group No. 8"). A developer whose name shares its first word
  with its parent company is credited to the parent ("Nintendo");
  independently named studios keep their name (Retro Studios, Naughty
  Dog, Landfall Games)
- Fall back to the publisher when IGDB lists no developer

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 18:05:20 -07:00

284 lines
7.7 KiB
Go

package providers
import (
"context"
"fmt"
"net/url"
"strconv"
"strings"
)
// deezer uses Deezer's public catalogue API: no key, generous limits and
// 1000px cover art.
type deezer struct {
r *Registry
kind string // "album" or "song"
}
func (d *deezer) Name() string { return "deezer" }
func (d *deezer) Label() string { return "Deezer" }
type deezerErr struct {
Error *struct {
Message string `json:"message"`
} `json:"error"`
}
// Deezer reports errors with a 200 status and an "error" object.
func (e *deezerErr) check() error {
if e.Error != nil {
return fmt.Errorf("Deezer: %s", e.Error.Message)
}
return nil
}
func (d *deezer) get(ctx context.Context, path string, out any, e *deezerErr) error {
if err := d.r.getJSON(ctx, "https://api.deezer.com"+path, nil, out); err != nil {
return err
}
return e.check()
}
type deezerArtist struct {
Name string `json:"name"`
}
type deezerAlbum struct {
deezerErr
ID int64 `json:"id"`
Title string `json:"title"`
Link string `json:"link"`
CoverXL string `json:"cover_xl"`
CoverBig string `json:"cover_big"`
RecordType string `json:"record_type"`
NbTracks int `json:"nb_tracks"`
ReleaseDate string `json:"release_date"`
Label string `json:"label"`
Artist deezerArtist `json:"artist"`
Genres struct {
Data []struct {
Name string `json:"name"`
} `json:"data"`
} `json:"genres"`
}
type deezerTrack struct {
deezerErr
ID int64 `json:"id"`
Title string `json:"title"`
Link string `json:"link"`
Duration int `json:"duration"`
ReleaseDate string `json:"release_date"`
Artist deezerArtist `json:"artist"`
Album deezerAlbum `json:"album"`
}
func cover(a deezerAlbum) string {
if a.CoverXL != "" {
return a.CoverXL
}
return a.CoverBig
}
func (d *deezer) Search(ctx context.Context, query string) ([]Result, error) {
q := url.Values{"q": {query}, "limit": {"25"}}.Encode()
var out []Result
if d.kind == "album" {
var resp struct {
deezerErr
Data []deezerAlbum `json:"data"`
}
if err := d.get(ctx, "/search/album?"+q, &resp, &resp.deezerErr); err != nil {
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)
}
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, 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},
})
}
return out, nil
}
func (d *deezer) Details(ctx context.Context, res Result) (Result, error) {
kind, id, _ := strings.Cut(res.ProviderID, ":")
if res.Extra == nil {
res.Extra = map[string]string{}
}
switch kind {
case "album":
var a deezerAlbum
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, ", ")
}
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 res, nil
}
// 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 {
r *Registry
kind string
}
func (m *musicbrainz) Name() string { return "musicbrainz" }
func (m *musicbrainz) Label() string { return "MusicBrainz" }
type mbCredit struct {
Name string `json:"name"`
JoinPhrase string `json:"joinphrase"`
}
func mbArtists(credits []mbCredit) string {
var b strings.Builder
for _, c := range credits {
b.WriteString(c.Name)
b.WriteString(c.JoinPhrase)
}
return strings.TrimSpace(b.String())
}
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
if m.kind == "album" {
var resp struct {
ReleaseGroups []struct {
ID string `json:"id"`
Title string `json:"title"`
PrimaryType string `json:"primary-type"`
FirstReleaseDate string `json:"first-release-date"`
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 {
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{},
}
if rg.PrimaryType != "" && rg.PrimaryType != "Album" {
r.Extra["type"] = rg.PrimaryType
}
out = append(out, r)
}
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"`
}
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{
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)
}
return out, nil
}