package metadata import ( "context" "encoding/json" "fmt" "net/http" "net/url" "strconv" "strings" "time" ) // ComicVine matches comic series. It requires a free API key, so the provider // is only registered when one is configured. type ComicVine struct { APIKey string BaseURL string Client *http.Client } // NewComicVine builds the provider. An empty key yields a provider that finds // nothing, so a misconfiguration degrades rather than erroring. func NewComicVine(apiKey string) *ComicVine { return &ComicVine{ APIKey: apiKey, BaseURL: "https://comicvine.gamespot.com/api", Client: &http.Client{Timeout: 15 * time.Second}, } } func (p *ComicVine) Name() string { return "comicvine" } func (p *ComicVine) Kinds() []string { return []string{"comics"} } // cvResponse is the subset of the ComicVine search payload we read. type cvResponse struct { StatusCode int `json:"status_code"` Results []struct { ID int `json:"id"` Name string `json:"name"` StartYear string `json:"start_year"` Description string `json:"description"` Publisher struct { Name string `json:"name"` } `json:"publisher"` Image struct { MediumURL string `json:"medium_url"` OriginalURL string `json:"original_url"` SmallURL string `json:"small_url"` } `json:"image"` } `json:"results"` } func (p *ComicVine) Search(ctx context.Context, q Query) ([]Candidate, error) { if p.APIKey == "" || strings.TrimSpace(q.Title) == "" { return nil, nil } params := url.Values{} params.Set("api_key", p.APIKey) params.Set("format", "json") params.Set("resources", "volume") params.Set("query", q.Title) params.Set("limit", "5") endpoint := strings.TrimRight(p.BaseURL, "/") + "/search/?" + params.Encode() req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, err } // ComicVine rejects requests without a User-Agent. req.Header.Set("User-Agent", "Vellum/0.1 (self-hosted media server)") resp, err := p.Client.Do(req) if err != nil { return nil, fmt.Errorf("comicvine: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("comicvine: status %d", resp.StatusCode) } var parsed cvResponse if err := json.NewDecoder(resp.Body).Decode(&parsed); err != nil { return nil, fmt.Errorf("comicvine: decode: %w", err) } // ComicVine returns 200 with status_code 100 for an invalid API key. if parsed.StatusCode != 1 { return nil, fmt.Errorf("comicvine: api status %d (check the API key)", parsed.StatusCode) } out := make([]Candidate, 0, len(parsed.Results)) for _, r := range parsed.Results { if r.Name == "" { continue } cover := r.Image.MediumURL if cover == "" { cover = r.Image.SmallURL } if cover == "" { cover = r.Image.OriginalURL } out = append(out, Candidate{ Provider: p.Name(), ExternalID: strconv.Itoa(r.ID), Title: r.Name, Year: parseYear(r.StartYear), Publisher: r.Publisher.Name, Description: stripHTML(r.Description), CoverURL: cover, }) } return out, nil } // stripHTML removes the tags ComicVine embeds in descriptions. It is // deliberately minimal: descriptions are displayed as plain text, so only the // tags need removing, not full entity decoding. func stripHTML(s string) string { var b strings.Builder depth := 0 for _, r := range s { switch r { case '<': depth++ case '>': if depth > 0 { depth-- } default: if depth == 0 { b.WriteRune(r) } } } return strings.TrimSpace(b.String()) }