diff --git a/cmd/vellum/main.go b/cmd/vellum/main.go index b04da0d..a3b4f5d 100644 --- a/cmd/vellum/main.go +++ b/cmd/vellum/main.go @@ -172,6 +172,11 @@ func run(cfg core.Config, log *slog.Logger) error { } else { log.Info("video metadata disabled: set VELLUM_TMDB_API_KEY to enable TMDB movie and TV lookups") } + if cfg.TPDBAPIKey != "" { + providers.Register(providers.NewThePornDB(cfg.TPDBAPIKey)) + } else { + log.Info("adult metadata disabled: set VELLUM_TPDB_API_KEY to enable ThePornDB 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 2999020..7885478 100644 --- a/internal/core/config.go +++ b/internal/core/config.go @@ -41,6 +41,10 @@ type Config struct { // provider is simply absent when this is empty. TMDBAPIKey string + // TPDBAPIKey enables adult scene metadata via ThePornDB. It has no keyless + // tier, so the provider is simply absent when this is empty. + TPDBAPIKey string + // IGDBClientID / IGDBClientSecret enable game metadata via Twitch's // client-credentials flow. Both must be set for the provider to appear. IGDBClientID string @@ -87,6 +91,8 @@ func LoadConfig() (Config, error) { TMDBAPIKey: env("VELLUM_TMDB_API_KEY", ""), + TPDBAPIKey: env("VELLUM_TPDB_API_KEY", ""), + IGDBClientID: env("VELLUM_IGDB_CLIENT_ID", ""), IGDBClientSecret: env("VELLUM_IGDB_CLIENT_SECRET", ""), CookieName: env("VELLUM_COOKIE_NAME", "vellum_session"), diff --git a/internal/providers/theporndb.go b/internal/providers/theporndb.go new file mode 100644 index 0000000..c06e742 --- /dev/null +++ b/internal/providers/theporndb.go @@ -0,0 +1,98 @@ +package providers + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +// ThePornDB matches adult scenes. It requires a free API key, so the provider +// is only registered when one is configured. +type ThePornDB struct { + APIKey string + BaseURL string + Client *http.Client +} + +// NewThePornDB builds the provider. An empty key yields a provider that finds +// nothing, so a misconfiguration degrades rather than erroring. +func NewThePornDB(apiKey string) *ThePornDB { + return &ThePornDB{ + APIKey: apiKey, + BaseURL: "https://api.theporndb.net", + Client: &http.Client{Timeout: 15 * time.Second}, + } +} + +func (p *ThePornDB) Name() string { return "theporndb" } + +func (p *ThePornDB) Kinds() []string { return []string{"adult"} } + +// tpdbResponse is the subset of a scene search payload we read. +type tpdbResponse struct { + Data []struct { + ID string `json:"id"` + Title string `json:"title"` + Date string `json:"date"` + Description string `json:"description"` + Image string `json:"image"` + } `json:"data"` +} + +func (p *ThePornDB) Search(ctx context.Context, q Query) ([]Candidate, error) { + title := strings.TrimSpace(q.Title) + if p.APIKey == "" || title == "" { + return nil, nil + } + + params := url.Values{} + params.Set("parse", title) + params.Set("per_page", "5") + if q.Year > 0 { + params.Set("year", strconv.Itoa(q.Year)) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, + strings.TrimRight(p.BaseURL, "/")+"/scenes?"+params.Encode(), nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+p.APIKey) + req.Header.Set("Accept", "application/json") + + resp, err := p.Client.Do(req) + if err != nil { + return nil, fmt.Errorf("theporndb: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("theporndb: status %d", resp.StatusCode) + } + + var parsed tpdbResponse + if err := json.NewDecoder(resp.Body).Decode(&parsed); err != nil { + return nil, fmt.Errorf("theporndb: decode: %w", err) + } + + out := make([]Candidate, 0, len(parsed.Data)) + for _, s := range parsed.Data { + if s.Title == "" { + continue + } + out = append(out, Candidate{ + Provider: p.Name(), + // Namespaced so a scene id cannot collide with another provider's. + ExternalID: "scene/" + s.ID, + Title: s.Title, + Year: parseYear(s.Date), + Description: stripHTML(s.Description), + CoverURL: s.Image, + }) + } + return out, nil +} diff --git a/internal/providers/theporndb_test.go b/internal/providers/theporndb_test.go new file mode 100644 index 0000000..95ccf6b --- /dev/null +++ b/internal/providers/theporndb_test.go @@ -0,0 +1,167 @@ +package providers + +import ( + "context" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" +) + +// tpdbSrv answers scene searches from one handler, recording the last request. +func tpdbSrv(t *testing.T, fn func(r *http.Request) (string, int)) *httptest.Server { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, status := fn(r) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _, _ = w.Write([]byte(body)) + })) + t.Cleanup(srv.Close) + return srv +} + +func TestThePornDBMapsMatch(t *testing.T) { + var gotPath string + srv := tpdbSrv(t, func(r *http.Request) (string, int) { + gotPath = r.URL.Path + return `{"data":[ + {"id": "abc-123", "title": "A Scene", "date": "2021-05-04", + "description": "

A fine scene.

", "image": "https://cdn.example.com/a.jpg", + "site": {"name": "Some Site"}, "performers": [{"name": "Someone"}]} + ]}`, http.StatusOK + }) + + p := NewThePornDB("secret-key") + p.BaseURL = srv.URL + + cands, err := p.Search(context.Background(), Query{Title: "A Scene", Year: 2021}) + if err != nil { + t.Fatalf("Search: %v", err) + } + if gotPath != "/scenes" { + t.Errorf("path = %q, want /scenes", gotPath) + } + if len(cands) != 1 { + t.Fatalf("got %d candidates, want 1", len(cands)) + } + c := cands[0] + if c.Provider != "theporndb" || c.ExternalID != "scene/abc-123" { + t.Errorf("id = %q (provider %q), want scene/abc-123", c.ExternalID, c.Provider) + } + if c.Title != "A Scene" { + t.Errorf("Title = %q", c.Title) + } + if c.Year != 2021 { + t.Errorf("Year = %d, want 2021 parsed from date", c.Year) + } + if c.Description != "A fine scene." { + t.Errorf("Description = %q (HTML should be stripped)", c.Description) + } + if c.CoverURL != "https://cdn.example.com/a.jpg" { + t.Errorf("CoverURL = %q", c.CoverURL) + } +} + +func TestThePornDBUsesBearerAuth(t *testing.T) { + var gotAuth string + srv := tpdbSrv(t, func(r *http.Request) (string, int) { + gotAuth = r.Header.Get("Authorization") + return `{"data":[]}`, http.StatusOK + }) + + p := NewThePornDB("secret-key") + p.BaseURL = srv.URL + + if _, err := p.Search(context.Background(), Query{Title: "A Scene"}); err != nil { + t.Fatalf("Search: %v", err) + } + if gotAuth != "Bearer secret-key" { + t.Errorf("Authorization = %q, want %q", gotAuth, "Bearer secret-key") + } +} + +func TestThePornDBYearOnlySentWhenSet(t *testing.T) { + var gotYear string + srv := tpdbSrv(t, func(r *http.Request) (string, int) { + gotYear = r.URL.Query().Get("year") + return `{"data":[]}`, http.StatusOK + }) + + p := NewThePornDB("secret-key") + p.BaseURL = srv.URL + + if _, err := p.Search(context.Background(), Query{Title: "A Scene"}); err != nil { + t.Fatalf("Search: %v", err) + } + if gotYear != "" { + t.Errorf("year sent without being set: %q", gotYear) + } + + if _, err := p.Search(context.Background(), Query{Title: "A Scene", Year: 2021}); err != nil { + t.Fatalf("Search: %v", err) + } + if gotYear != "2021" { + t.Errorf("year = %q, want 2021", gotYear) + } +} + +func TestThePornDBEmptyResultsAreNotAnError(t *testing.T) { + srv := tpdbSrv(t, func(_ *http.Request) (string, int) { + return `{"data":[]}`, http.StatusOK + }) + + p := NewThePornDB("secret-key") + p.BaseURL = srv.URL + + cands, err := p.Search(context.Background(), Query{Title: "Nothing At All Like This"}) + if err != nil { + t.Fatalf("Search: %v", err) + } + if len(cands) != 0 { + t.Errorf("got %d candidates, want 0", len(cands)) + } +} + +func TestThePornDBNonOKIsAnError(t *testing.T) { + srv := tpdbSrv(t, func(_ *http.Request) (string, int) { + return `{"message":"nope"}`, http.StatusUnauthorized + }) + + p := NewThePornDB("secret-key") + p.BaseURL = srv.URL + + if _, err := p.Search(context.Background(), Query{Title: "A Scene"}); err == nil { + t.Error("Search returned nil error on a 401") + } +} + +func TestThePornDBWithoutTitleOrKeyDoesNotCallOut(t *testing.T) { + var hits int32 + srv := tpdbSrv(t, func(_ *http.Request) (string, int) { + atomic.AddInt32(&hits, 1) + return `{"data":[{"id":"1","title":"Unwanted"}]}`, http.StatusOK + }) + + keyed := NewThePornDB("secret-key") + keyed.BaseURL = srv.URL + for _, q := range []Query{{}, {Title: " "}} { + cands, err := keyed.Search(context.Background(), q) + if err != nil { + t.Fatalf("Search(%+v): %v", q, err) + } + if len(cands) != 0 { + t.Errorf("Search(%+v) returned %d candidates, want 0", q, len(cands)) + } + } + + keyless := NewThePornDB("") + keyless.BaseURL = srv.URL + if _, err := keyless.Search(context.Background(), Query{Title: "A Scene"}); err != nil { + t.Fatalf("Search without key: %v", err) + } + + if n := atomic.LoadInt32(&hits); n != 0 { + t.Errorf("server hit %d times with no title or no key, want 0", n) + } +}