Files
canon/internal/providers/tmdb.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

144 lines
3.4 KiB
Go

package providers
import (
"context"
"fmt"
"net/url"
"strconv"
"strings"
)
// tmdb searches The Movie Database. It accepts either a v3 API key or a v4
// "API Read Access Token".
type tmdb struct {
r *Registry
key string
kind string // "movie" or "tv"
}
func (t *tmdb) Name() string { return "tmdb" }
func (t *tmdb) Label() string { return "TMDB" }
func (t *tmdb) get(ctx context.Context, path string, q url.Values, out any) error {
if q == nil {
q = url.Values{}
}
header := map[string]string{}
if strings.HasPrefix(t.key, "eyJ") {
header["Authorization"] = "Bearer " + t.key
} else {
q.Set("api_key", t.key)
}
err := t.r.getJSON(ctx, "https://api.themoviedb.org/3"+path+"?"+q.Encode(), header, out)
if isStatus(err, 401) {
return fmt.Errorf("TMDB rejected the API key; check it under Settings → Integrations")
}
return err
}
func tmdbImage(path string) string {
if path == "" {
return ""
}
return "https://image.tmdb.org/t/p/w780" + path
}
type tmdbHit struct {
ID int64 `json:"id"`
Title string `json:"title"`
Name string `json:"name"`
ReleaseDate string `json:"release_date"`
FirstAirDate string `json:"first_air_date"`
PosterPath string `json:"poster_path"`
Overview string `json:"overview"`
}
func (t *tmdb) Search(ctx context.Context, query string) ([]Result, error) {
var resp struct {
Results []tmdbHit `json:"results"`
}
q := url.Values{"query": {query}, "include_adult": {"false"}}
if err := t.get(ctx, "/search/"+t.kind, q, &resp); err != nil {
return nil, err
}
out := make([]Result, 0, len(resp.Results))
for _, h := range resp.Results {
title, date := h.Title, h.ReleaseDate
if t.kind == "tv" {
title, date = h.Name, h.FirstAirDate
}
id := strconv.FormatInt(h.ID, 10)
out = append(out, Result{
Provider: "tmdb",
ProviderID: t.kind + ":" + id,
Title: title,
Year: yearOf(date),
ImageURL: tmdbImage(h.PosterPath),
ExternalURL: "https://www.themoviedb.org/" + t.kind + "/" + id,
Overview: truncate(h.Overview, maxDescription),
})
}
return out, nil
}
func (t *tmdb) Details(ctx context.Context, res Result) (Result, error) {
_, id, ok := strings.Cut(res.ProviderID, ":")
if !ok {
return res, nil
}
var d struct {
Genres []struct {
Name string `json:"name"`
} `json:"genres"`
Runtime int `json:"runtime"`
Networks []struct {
Name string `json:"name"`
} `json:"networks"`
CreatedBy []struct {
Name string `json:"name"`
} `json:"created_by"`
Credits struct {
Crew []struct {
Name string `json:"name"`
Job string `json:"job"`
} `json:"crew"`
} `json:"credits"`
}
q := url.Values{}
if t.kind == "movie" {
q.Set("append_to_response", "credits")
}
if err := t.get(ctx, "/"+t.kind+"/"+id, q, &d); err != nil {
return res, err
}
var names []string
if t.kind == "movie" {
for _, c := range d.Credits.Crew {
if c.Job == "Director" {
names = append(names, c.Name)
}
}
} else {
for _, n := range d.Networks {
names = append(names, n.Name)
}
}
if len(names) > 0 {
res.Subtitle = strings.Join(names, ", ")
}
var genres []string
for _, g := range d.Genres {
genres = append(genres, g.Name)
}
if res.Extra == nil {
res.Extra = map[string]string{}
}
if len(genres) > 0 {
res.Extra["genres"] = strings.Join(genres, ", ")
}
if d.Runtime > 0 {
res.Extra["runtime"] = fmt.Sprintf("%dh %02dm", d.Runtime/60, d.Runtime%60)
}
return res, nil
}