Add a ThePornDB provider for the adult kind
The adult kind scanned and browsed but could never be enriched, because no provider claimed it. ThePornDB does: it is the catalogue the Stash ecosystem uses for scenes and movies, and it is keyed the same way IGDB and TMDB are, so it registers only when VELLUM_TPDB_API_KEY is set and otherwise logs the env var at boot. The API shape came from the live OpenAPI spec rather than the docs, which is where the search parameter lives: it is `parse`, not `q` or `title`, and the endpoint is /scenes with a `data` envelope. Not verified against the live API: it needs a token I do not have, so the tests run against httptest only. The field names are taken from the published schema, which is the best available substitute. The provider also does not use ThePornDB's hash lookup, which is the more accurate way to match: it needs a file hash computed at scan time, and matching by parsed title and date works today without that.
This commit is contained in:
parent
fc28ec8efd
commit
82ded6a9c7
4 changed files with 276 additions and 0 deletions
|
|
@ -172,6 +172,11 @@ func run(cfg core.Config, log *slog.Logger) error {
|
||||||
} else {
|
} else {
|
||||||
log.Info("video metadata disabled: set VELLUM_TMDB_API_KEY to enable TMDB movie and TV lookups")
|
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)
|
enricher := metadata.NewEnricher(db, log, cfg.ConfigDir)
|
||||||
queue.Register(metadata.JobKind, enricher.Handle)
|
queue.Register(metadata.JobKind, enricher.Handle)
|
||||||
metadata.NewHandlers(db, log, authenticator, queue).Register(api.Mux())
|
metadata.NewHandlers(db, log, authenticator, queue).Register(api.Mux())
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,10 @@ type Config struct {
|
||||||
// provider is simply absent when this is empty.
|
// provider is simply absent when this is empty.
|
||||||
TMDBAPIKey string
|
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
|
// IGDBClientID / IGDBClientSecret enable game metadata via Twitch's
|
||||||
// client-credentials flow. Both must be set for the provider to appear.
|
// client-credentials flow. Both must be set for the provider to appear.
|
||||||
IGDBClientID string
|
IGDBClientID string
|
||||||
|
|
@ -87,6 +91,8 @@ func LoadConfig() (Config, error) {
|
||||||
|
|
||||||
TMDBAPIKey: env("VELLUM_TMDB_API_KEY", ""),
|
TMDBAPIKey: env("VELLUM_TMDB_API_KEY", ""),
|
||||||
|
|
||||||
|
TPDBAPIKey: env("VELLUM_TPDB_API_KEY", ""),
|
||||||
|
|
||||||
IGDBClientID: env("VELLUM_IGDB_CLIENT_ID", ""),
|
IGDBClientID: env("VELLUM_IGDB_CLIENT_ID", ""),
|
||||||
IGDBClientSecret: env("VELLUM_IGDB_CLIENT_SECRET", ""),
|
IGDBClientSecret: env("VELLUM_IGDB_CLIENT_SECRET", ""),
|
||||||
CookieName: env("VELLUM_COOKIE_NAME", "vellum_session"),
|
CookieName: env("VELLUM_COOKIE_NAME", "vellum_session"),
|
||||||
|
|
|
||||||
98
internal/providers/theporndb.go
Normal file
98
internal/providers/theporndb.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
167
internal/providers/theporndb_test.go
Normal file
167
internal/providers/theporndb_test.go
Normal file
|
|
@ -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": "<p>A <b>fine</b> scene.</p>", "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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue