diff --git a/README.md b/README.md index 8342a84..4847bfe 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,7 @@ binary runs with no configuration at all. | `VELLUM_ADMIN_USER` | *(none)* | Creates the first admin, **only on an empty database** | | `VELLUM_ADMIN_PASSWORD` | *(none)* | Password for that first admin (min 8 chars) | | `VELLUM_COOKIE_SECURE` | `false` | Set `true` when serving over TLS | +| `VELLUM_COMICVINE_API_KEY` | *(none)* | Enables comic metadata via ComicVine | | `VELLUM_LOG_LEVEL` | `info` | `debug`, `info`, `warn`, `error` | | `VELLUM_LOG_FORMAT` | `text` | `text` or `json` | | `VELLUM_SESSION_TTL` | `720h` | Login session lifetime | diff --git a/cmd/vellum/main.go b/cmd/vellum/main.go index 2c4dc74..2a352aa 100644 --- a/cmd/vellum/main.go +++ b/cmd/vellum/main.go @@ -90,6 +90,11 @@ func run() error { metadata.Register(metadata.NewOpenLibrary()) metadata.Register(metadata.NewGoogleBooks()) metadata.Register(metadata.NewMusicBrainz()) + if cfg.ComicVineAPIKey != "" { + metadata.Register(metadata.NewComicVine(cfg.ComicVineAPIKey)) + } else { + log.Info("comic metadata disabled: set VELLUM_COMICVINE_API_KEY to enable ComicVine lookups") + } enricher := metadata.NewEnricher(db, log, cfg.ConfigDir) queue.Register(metadata.JobKind, enricher.Handle) metadata.NewHandlers(db, log, authenticator, queue).Register(api.Mux()) diff --git a/internal/core/config.go b/internal/core/config.go index fbb2811..cc8141e 100644 --- a/internal/core/config.go +++ b/internal/core/config.go @@ -32,6 +32,10 @@ type Config struct { AdminUser string AdminPassword string + // ComicVineAPIKey enables comic metadata. ComicVine has no keyless tier, so + // the provider is simply absent when this is empty. + ComicVineAPIKey string + CookieName string CookieSecure bool @@ -54,7 +58,9 @@ func LoadConfig() (Config, error) { LogFormat: env("VELLUM_LOG_FORMAT", "text"), // text|json AdminUser: env("VELLUM_ADMIN_USER", ""), AdminPassword: env("VELLUM_ADMIN_PASSWORD", ""), - CookieName: env("VELLUM_COOKIE_NAME", "vellum_session"), + + ComicVineAPIKey: env("VELLUM_COMICVINE_API_KEY", ""), + CookieName: env("VELLUM_COOKIE_NAME", "vellum_session"), // Secure defaults to false so plain-HTTP development works. Production // behind TLS must set VELLUM_COOKIE_SECURE=true; boot logs a warning // when it is off. diff --git a/internal/metadata/comicvine.go b/internal/metadata/comicvine.go new file mode 100644 index 0000000..25ef847 --- /dev/null +++ b/internal/metadata/comicvine.go @@ -0,0 +1,139 @@ +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()) +} diff --git a/internal/metadata/comicvine_test.go b/internal/metadata/comicvine_test.go new file mode 100644 index 0000000..7671b37 --- /dev/null +++ b/internal/metadata/comicvine_test.go @@ -0,0 +1,116 @@ +package metadata + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestComicVineSearch(t *testing.T) { + var gotPath, gotAPIKey, gotUA string + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotAPIKey = r.URL.Query().Get("api_key") + gotUA = r.Header.Get("User-Agent") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "status_code": 1, + "results": [ + { + "id": 796, + "name": "Saga", + "start_year": "2012", + "description": "
A space opera.
", + "publisher": {"name": "Image Comics"}, + "image": {"medium_url": "https://example.test/saga.jpg", "small_url": ""} + } + ] + }`)) + })) + defer srv.Close() + + p := NewComicVine("secret-key") + p.BaseURL = srv.URL + + cands, err := p.Search(context.Background(), Query{Title: "Saga"}) + if err != nil { + t.Fatalf("Search: %v", err) + } + if len(cands) != 1 { + t.Fatalf("got %d candidates, want 1", len(cands)) + } + + c := cands[0] + if c.Title != "Saga" { + t.Errorf("Title = %q", c.Title) + } + if c.ExternalID != "796" { + t.Errorf("ExternalID = %q", c.ExternalID) + } + if c.Year != 2012 { + t.Errorf("Year = %d, want 2012", c.Year) + } + if c.Publisher != "Image Comics" { + t.Errorf("Publisher = %q", c.Publisher) + } + if c.CoverURL != "https://example.test/saga.jpg" { + t.Errorf("CoverURL = %q", c.CoverURL) + } + if c.Description != "A space opera." { + t.Errorf("Description = %q (HTML should be stripped)", c.Description) + } + + if gotPath != "/search/" { + t.Errorf("request path = %q, want /search/", gotPath) + } + if gotAPIKey != "secret-key" { + t.Errorf("api_key = %q, want secret-key", gotAPIKey) + } + if gotUA == "" { + t.Error("no User-Agent sent; ComicVine rejects those") + } +} + +func TestComicVineRejectsBadAPIKey(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"status_code": 100, "error": "Invalid API Key", "results": []}`)) + })) + defer srv.Close() + + p := NewComicVine("wrong") + p.BaseURL = srv.URL + + if _, err := p.Search(context.Background(), Query{Title: "Saga"}); err == nil { + t.Fatal("expected an error for status_code 100") + } else if !strings.Contains(err.Error(), "API key") { + t.Errorf("error %q should mention the API key", err) + } +} + +func TestComicVineWithoutKeyDoesNothing(t *testing.T) { + p := NewComicVine("") + cands, err := p.Search(context.Background(), Query{Title: "Saga"}) + if err != nil { + t.Fatalf("Search: %v", err) + } + if len(cands) != 0 { + t.Errorf("got %d candidates with no key, want 0", len(cands)) + } +} + +func TestStripHTML(t *testing.T) { + cases := map[string]string{ + "A space opera.
": "A space opera.", + "plain text": "plain text", + "Bold and italic.": "Bold and italic.", + "": "", + } + for in, want := range cases { + if got := stripHTML(in); got != want { + t.Errorf("stripHTML(%q) = %q, want %q", in, got, want) + } + } +}