Repository: metatube-community/metatube-sdk-go

Issue:
[Feature] Support provider theporndb.net
### Description

I know this project is mainly build for JAV, but are we looking for an option to add theporndb.net support? willing to help.

https://api.theporndb.net/docs#/Scenes

### Is this feature related to a specific bug?

_No response_

### Do you have a specific solution in mind?

_No response_

Hints from issue comments:
@xjasonlyu: If you're willing to help, that's great!

Solution patch:
diff --git a/engine/register.go b/engine/register.go
index a09287e..3bb5fb9 100644
--- a/engine/register.go
+++ b/engine/register.go
@@ -36,6 +36,7 @@ import (
 	_ "github.com/metatube-community/metatube-sdk-go/provider/pacopacomama"
 	_ "github.com/metatube-community/metatube-sdk-go/provider/pcolle"
 	_ "github.com/metatube-community/metatube-sdk-go/provider/sod"
+	_ "github.com/metatube-community/metatube-sdk-go/provider/theporndb"
 	_ "github.com/metatube-community/metatube-sdk-go/provider/tokyo-hot"
 	_ "github.com/metatube-community/metatube-sdk-go/provider/xxx-av"
 )
diff --git a/provider/theporndb/actor.go b/provider/theporndb/actor.go
new file mode 100644
index 0000000..54f8cbb
--- /dev/null
+++ b/provider/theporndb/actor.go
@@ -0,0 +1,200 @@
+package theporndb
+
+import (
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"net/url"
+	"path"
+	"regexp"
+	"strconv"
+
+	"github.com/gocolly/colly/v2"
+	"golang.org/x/text/language"
+
+	"github.com/metatube-community/metatube-sdk-go/model"
+	"github.com/metatube-community/metatube-sdk-go/provider"
+	"github.com/metatube-community/metatube-sdk-go/provider/internal/scraper"
+)
+
+var (
+	_ provider.ActorProvider = (*ThePornDBActor)(nil)
+	_ provider.ActorSearcher = (*ThePornDBActor)(nil)
+)
+
+const (
+	ActorProviderName = "ThePornDBActor"
+	actorBaseURL      = "https://theporndb.net/performers/"
+	actorPageURL      = "https://theporndb.net/performers/%s"
+	apiGetActorURL    = "https://api.theporndb.net/performers/%s"
+	apiSearchActorURL = "https://api.theporndb.net/performers?q=%s"
+)
+
+type ThePornDBActor struct {
+	*scraper.Scraper
+
+	accessToken string
+}
+
+func NewThePornDBActor() *ThePornDBActor {
+	return &ThePornDBActor{
+		Scraper:     scraper.NewDefaultScraper(ActorProviderName, actorBaseURL, Priority, language.English),
+		accessToken: "",
+	}
+}
+
+func (s *ThePornDBActor) SetConfig(config map[string]string) error {
+	if accessToken, ok := config["ACCESS_TOKEN"]; ok {
+		s.accessToken = accessToken
+	}
+	return nil
+}
+
+// ParseActorIDFromURL impls ActorProvider.ParseActorIDFromURL.
+func (s *ThePornDBActor) ParseActorIDFromURL(rawURL string) (string, error) {
+	homepage, err := url.Parse(rawURL)
+	if err != nil {
+		return "", err
+	}
+	return path.Base(homepage.Path), nil
+}
+
+// GetActorInfoByID impls ActorProvider.GetActorInfoByID.
+func (s *ThePornDBActor) GetActorInfoByID(id string) (info *model.ActorInfo, err error) {
+	if s.accessToken == "" {
+		return nil, nil
+	}
+
+	info = &model.ActorInfo{
+		Provider: s.Name(),
+		Aliases:  []string{},
+		Images:   []string{},
+	}
+
+	c := s.ClonedCollector()
+	c.OnResponse(func(r *colly.Response) {
+		resp := &getActorResponse{}
+		if err = json.Unmarshal(r.Body, resp); err != nil {
+			return
+		}
+
+		info.ID = resp.Data.Slug
+		info.Homepage = fmt.Sprintf(actorPageURL, info.ID)
+		info.Name = resp.Data.Name
+		info.Summary = resp.Data.Bio
+		info.Nationality = resp.Data.Extras.Nationality
+		info.Aliases = resp.Data.Aliases
+
+		if birthday, err := resp.Data.BirthdayDate(); err == nil {
+			info.Birthday = birthday
+		}
+
+		if resp.Data.Image != "" {
+			info.Images = append(info.Images, resp.Data.Image)
+		}
+		if resp.Data.Thumbnail != "" {
+			info.Images = append(info.Images, resp.Data.Thumbnail)
+		}
+		if resp.Data.Face != "" {
+			info.Images = append(info.Images, resp.Data.Face)
+		}
+		for _, poster := range resp.Data.Posters {
+			info.Images = append(info.Images, poster.URL)
+		}
+
+		info.Height = resp.Data.HeightInCM()
+
+		b, cup, err := parseChestSize(resp.Data.Extras.Cupsize)
+		if err == nil {
+			info.CupSize = cup
+
+			w, _ := strconv.Atoi(resp.Data.Extras.Waist)
+			h, _ := strconv.Atoi(resp.Data.Extras.Hips)
+
+			if b != 0 && w != 0 && h != 0 {
+				info.Measurements = fmt.Sprintf("B:%d / W:%d / H:%d", b, w, h)
+			}
+		}
+	})
+
+	headers := http.Header{}
+	headers.Set("Authorization", fmt.Sprintf("Bearer %s", s.accessToken))
+	err = c.Request(http.MethodGet, fmt.Sprintf(apiGetActorURL, id), nil, nil, headers)
+	return
+}
+
+// GetActorInfoByURL impls ActorProvider.GetActorInfoByURL.
+func (s *ThePornDBActor) GetActorInfoByURL(rawURL string) (*model.ActorInfo, error) {
+	id, err := s.ParseActorIDFromURL(rawURL)
+	if err != nil {
+		return nil, err
+	}
+
+	return s.GetActorInfoByID(id)
+}
+
+// SearchActor impls ActorSearcher.SearchActor.
+func (s *ThePornDBActor) SearchActor(keyword string) (results []*model.ActorSearchResult, err error) {
+	if s.accessToken == "" {
+		return nil, nil
+	}
+
+	c := s.ClonedCollector()
+
+	results = make([]*model.ActorSearchResult, 0)
+
+	c.OnResponse(func(r *colly.Response) {
+		resp := &searchActorResponse{}
+		if err = json.Unmarshal(r.Body, resp); err != nil {
+			return
+		}
+		for _, actor := range resp.Data {
+			res := &model.ActorSearchResult{
+				ID:       actor.Slug,
+				Name:     actor.Name,
+				Provider: s.Name(),
+				Homepage: fmt.Sprintf(actorPageURL, actor.Slug),
+			}
+			res.Aliases = actor.Aliases
+
+			if actor.Image != "" {
+				res.Images = append(res.Images, actor.Image)
+			}
+			if actor.Thumbnail != "" {
+				res.Images = append(res.Images, actor.Thumbnail)
+			}
+			if actor.Face != "" {
+				res.Images = append(res.Images, actor.Face)
+			}
+			for _, poster := range actor.Posters {
+				res.Images = append(res.Images, poster.URL)
+			}
+			results = append(results, res)
+		}
+	})
+
+	headers := http.Header{}
+	headers.Set("Authorization", fmt.Sprintf("Bearer %s", s.accessToken))
+	err = c.Request(http.MethodGet, fmt.Sprintf(apiSearchActorURL, url.QueryEscape(keyword)), nil, nil, headers)
+	return
+}
+
+var chestSizeRE = regexp.MustCompile(`^(\d+)([A-Z])$`)
+
+func parseChestSize(s string) (int, string, error) {
+	match := chestSizeRE.FindStringSubmatch(s)
+
+	if len(match) != 3 {
+		return 0, "", fmt.Errorf("invalid format: %s", s)
+	}
+
+	numericPart := match[1]
+	unitPart := match[2]
+
+	value, err := strconv.Atoi(numericPart)
+	if err != nil {
+		return 0, "", fmt.Errorf("failed to parse numeric part '%s': %w", numericPart, err)
+	}
+
+	return value, unitPart, nil
+}
diff --git a/provider/theporndb/structs.go b/provider/theporndb/structs.go
new file mode 100644
index 0000000..452ab9b
--- /dev/null
+++ b/provider/theporndb/structs.go
@@ -0,0 +1,169 @@
+package theporndb
+
+import (
+	"strconv"
+	"strings"
+	"time"
+
+	"gorm.io/datatypes"
+)
+
+type actorInfo struct {
+	ID        string  `json:"id"`
+	NumericID uint64  `json:"_id"`
+	Slug      string  `json:"slug"`
+	Name      string  `json:"name"`
+	Bio       string  `json:"bio"`
+	Rating    float32 `json:"rating"`
+	IsParent  bool    `json:"is_parent"`
+	Extras    struct {
+		Gender            string `json:"gender"`
+		Birthday          string `json:"birthday"`
+		BirthdayTimestamp int    `json:"birthday_timestamp"`
+		Birthplace        string `json:"birthplace"`
+		BirthplaceCode    string `json:"birthplace_code"`
+		Astrology         string `json:"astrology"`
+		Ethnicity         string `json:"ethnicity"`
+		Nationality       string `json:"nationality"`
+		HairColour        string `json:"hair_colour"`
+		EyeColour         string `json:"eye_colour"`
+		Weight            string `json:"weight"`
+		Height            string `json:"height"`
+		Measurements      string `json:"measurements"`
+		Cupsize           string `json:"cupsize"`
+		Tattoos           string `json:"tattoos"`
+		Piercings         string `json:"piercings"`
+		Waist             string `json:"waist"`
+		Hips              string `json:"hips"`
+		FakeBoobs         bool   `json:"fake_boobs"`
+		SameSexOnly       bool   `json:"same_sex_only"`
+		CareerStartYear   int    `json:"career_start_year"`
+		CareerEndYear     int    `json:"career_end_year"`
+	} `json:"extras"`
+	Aliases   []string `json:"aliases"`
+	Image     string   `json:"image"`
+	Thumbnail string   `json:"thumbnail"`
+	Face      string   `json:"face"`
+	Posters   []struct {
+		ID    int    `json:"id"`
+		URL   string `json:"url"`
+		Size  int    `json:"size"`
+		Order int    `json:"order"`
+	} `json:"posters"`
+}
+
+func (a *actorInfo) BirthdayDate() (datatypes.Date, error) {
+	parsedDate, err := time.Parse("2006-01-02", a.Extras.Birthday)
+	return datatypes.Date(parsedDate), err
+}
+
+func (a *actorInfo) HeightInCM() int {
+	if a.Extras.Height == "" {
+		return 0
+	}
+	if !strings.HasSuffix(a.Extras.Height, "cm") {
+		return 0
+	}
+	h, _ := strconv.Atoi(strings.TrimSuffix(a.Extras.Height, "cm"))
+	return h
+}
+
+type getActorResponse struct {
+	Data actorInfo `json:"data"`
+}
+
+type searchActorResponse struct {
+	Data  []actorInfo `json:"data"`
+	Links struct {
+		First string      `json:"first"`
+		Last  string      `json:"last"`
+		Prev  interface{} `json:"prev"`
+		Next  interface{} `json:"next"`
+	} `json:"links"`
+	Meta struct {
+		CurrentPage int `json:"current_page"`
+		From        int `json:"from"`
+		LastPage    int `json:"last_page"`
+		Links       []struct {
+			URL    interface{} `json:"url"`
+			Label  string      `json:"label"`
+			Active bool        `json:"active"`
+		} `json:"links"`
+		Path    string `json:"path"`
+		PerPage int    `json:"per_page"`
+		To      int    `json:"to"`
+		Total   int    `json:"total"`
+	} `json:"meta"`
+}
+
+type videoInfo struct {
+	ID         string `json:"id"`
+	NumericID  uint64 `json:"_id"`
+	ExternalID string `json:"external_id"`
+	// Slug is the meaningful id.
+	Slug string `json:"slug"`
+
+	Title       string  `json:"title"`
+	Type        string  `json:"type"`
+	Description string  `json:"description"`
+	Rating      float32 `json:"rating"`
+	SiteID      int     `json:"site_id"`
+	Date        string  `json:"date"`
+	URL         string  `json:"url"`
+
+	// Cover
+	Image       string `json:"image"`
+	BackImage   string `json:"back_image"`
+	PosterImage string `json:"poster_image"`
+	// Thumbnail
+	Poster   string `json:"poster"`
+	Trailer  string `json:"trailer"`
+	Duration int    `json:"duration"`
+
+	Performers []actorInfo `json:"performers"`
+	Site       struct {
+		Name string `json:"name"`
+	} `json:"site"`
+	Tags []struct {
+		Name string `json:"name"`
+	} `json:"tags"`
+	Directors []struct {
+		Name string `json:"name"`
+	} `json:"directors"`
+}
+
+func (v *videoInfo) ReleaseDate() (datatypes.Date, error) {
+	parsedDate, err := time.Parse("2006-01-02", v.Date)
+	if err != nil {
+		return datatypes.Date{}, err
+	}
+	return datatypes.Date(parsedDate), nil
+}
+
+type getVideoResponse struct {
+	Data videoInfo `json:"data"`
+}
+
+type searchVideosResponse struct {
+	Data  []videoInfo `json:"data"`
+	Links struct {
+		First string      `json:"first"`
+		Last  string      `json:"last"`
+		Prev  interface{} `json:"prev"`
+		Next  interface{} `json:"next"`
+	} `json:"links"`
+	Meta struct {
+		CurrentPage int `json:"current_page"`
+		From        int `json:"from"`
+		LastPage    int `json:"last_page"`
+		Links       []struct {
+			URL    interface{} `json:"url"`
+			Label  string      `json:"label"`
+			Active bool        `json:"active"`
+		} `json:"links"`
+		Path    string `json:"path"`
+		PerPage int    `json:"per_page"`
+		To      int    `json:"to"`
+		Total   int    `json:"total"`
+	} `json:"meta"`
+}
diff --git a/provider/theporndb/theporndb.go b/provider/theporndb/theporndb.go
new file mode 100644
index 0000000..c225dc1
--- /dev/null
+++ b/provider/theporndb/theporndb.go
@@ -0,0 +1,18 @@
+package theporndb
+
+import (
+	"github.com/metatube-community/metatube-sdk-go/provider"
+)
+
+// ThePornDB is disabled by default, to enable:
+// `export MT_ACTOR_PROVIDER_THEPORNDBACTOR__ACCESS_TOKEN=your-token`
+// `export MT_MOVIE_PROVIDER_THEPORNDBMOVIE__ACCESS_TOKEN=your-token`
+// `export MT_MOVIE_PROVIDER_THEPORNDBSCENE__ACCESS_TOKEN=your-token`
+
+const Priority = 1000
+
+func init() {
+	provider.Register(SceneProviderName, NewThePornDBScene)
+	provider.Register(MovieProviderName, NewThePornDBMovie)
+	provider.Register(ActorProviderName, NewThePornDBActor)
+}
diff --git a/provider/theporndb/video.go b/provider/theporndb/video.go
new file mode 100644
index 0000000..7c49e0c
--- /dev/null
+++ b/provider/theporndb/video.go
@@ -0,0 +1,196 @@
+package theporndb
+
+import (
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"net/url"
+	"path"
+	"strings"
+
+	"github.com/gocolly/colly/v2"
+	"golang.org/x/text/language"
+
+	"github.com/metatube-community/metatube-sdk-go/common/number"
+	"github.com/metatube-community/metatube-sdk-go/model"
+	"github.com/metatube-community/metatube-sdk-go/provider"
+	"github.com/metatube-community/metatube-sdk-go/provider/internal/scraper"
+)
+
+var (
+	_ provider.MovieProvider = (*ThePornDBVideo)(nil)
+	_ provider.MovieSearcher = (*ThePornDBVideo)(nil)
+)
+
+const (
+	SceneProviderName = "ThePornDBScene"
+	MovieProviderName = "ThePornDBMovie"
+
+	movieBaseURL = "https://theporndb.net/movies/"
+	sceneBaseURL = "https://theporndb.net/scenes/"
+
+	moviePageURL = "https://theporndb.net/movies/%s"
+	scenePageURL = "https://theporndb.net/scenes/%s"
+
+	apiGetMovieURL  = "https://api.theporndb.net/movies/%s"
+	apiGetScenesURL = "https://api.theporndb.net/scenes/%s"
+
+	apiSearchMovieURL = "https://api.theporndb.net/movies?q=%s"
+	apiSearchSceneURL = "https://api.theporndb.net/scenes?q=%s"
+)
+
+// ThePornDB have different API endpoints for scenes and movies, but response
+// JSON schema is the same. So we need to register 2 video providers.
+
+type ThePornDBVideo struct {
+	*scraper.Scraper
+
+	pageURL      string
+	apiGetURL    string
+	apiSearchURL string
+
+	accessToken string
+}
+
+func new(name, baseURL, pageURL, apiGetURL, apiSearchURL string) *ThePornDBVideo {
+	return &ThePornDBVideo{
+		Scraper:      scraper.NewDefaultScraper(name, baseURL, Priority, language.English),
+		pageURL:      pageURL,
+		apiGetURL:    apiGetURL,
+		apiSearchURL: apiSearchURL,
+		accessToken:  "",
+	}
+}
+
+func NewThePornDBScene() *ThePornDBVideo {
+	return new(SceneProviderName, sceneBaseURL, scenePageURL, apiGetScenesURL, apiSearchSceneURL)
+}
+
+func NewThePornDBMovie() *ThePornDBVideo {
+	return new(MovieProviderName, movieBaseURL, moviePageURL, apiGetMovieURL, apiSearchMovieURL)
+}
+
+func (s *ThePornDBVideo) SetConfig(config map[string]string) error {
+	if accessToken, ok := config["ACCESS_TOKEN"]; ok {
+		fmt.Println(s.Name(), "set token")
+		s.accessToken = accessToken
+	}
+	return nil
+}
+
+// GetMovieInfoByID impls MovieProvider.GetMovieInfoByID.
+func (s *ThePornDBVideo) GetMovieInfoByID(id string) (info *model.MovieInfo, err error) {
+	if s.accessToken == "" {
+		return nil, nil
+	}
+
+	info = &model.MovieInfo{
+		Provider:      s.Name(),
+		Actors:        []string{},
+		PreviewImages: []string{},
+		Genres:        []string{},
+	}
+
+	c := s.ClonedCollector()
+
+	c.OnResponse(func(r *colly.Response) {
+		resp := &getVideoResponse{}
+		if err = json.Unmarshal(r.Body, resp); err != nil {
+			return
+		}
+
+		info.ID = resp.Data.Slug
+		info.Number = resp.Data.Slug
+		info.Homepage = fmt.Sprintf(s.pageURL, info.ID)
+		info.Title = resp.Data.Title
+		info.Summary = resp.Data.Description
+		info.ThumbURL = resp.Data.Poster
+		info.CoverURL = resp.Data.Image
+		info.Score = float64(resp.Data.Rating)
+		info.PreviewVideoURL = resp.Data.Trailer
+		info.Maker = resp.Data.Site.Name
+		info.Runtime = resp.Data.Duration
+
+		if releaseDate, err := resp.Data.ReleaseDate(); err == nil {
+			info.ReleaseDate = releaseDate
+		}
+
+		for _, tag := range resp.Data.Tags {
+			info.Genres = append(info.Genres, tag.Name)
+		}
+
+		for _, actor := range resp.Data.Performers {
+			info.Actors = append(info.Actors, actor.Name)
+		}
+
+		if len(resp.Data.Directors) > 0 {
+			info.Director = resp.Data.Directors[0].Name
+		}
+	})
+
+	headers := http.Header{}
+	headers.Set("Authorization", fmt.Sprintf("Bearer %s", s.accessToken))
+	err = c.Request(http.MethodGet, fmt.Sprintf(s.apiGetURL, id), nil, nil, headers)
+	return
+}
+
+// ParseMovieIDFromURL impls MovieProvider.ParseMovieIDFromURL.
+func (s *ThePornDBVideo) ParseMovieIDFromURL(rawURL string) (string, error) {
+	homepage, err := url.Parse(rawURL)
+	if err != nil {
+		return "", err
+	}
+	return path.Base(homepage.Path), nil
+}
+
+// GetMovieInfoByURL impls MovieProvider.GetMovieInfoByURL.
+func (s *ThePornDBVideo) GetMovieInfoByURL(rawURL string) (info *model.MovieInfo, err error) {
+	id, err := s.ParseMovieIDFromURL(rawURL)
+	if err != nil {
+		return
+	}
+
+	return s.GetMovieInfoByID(id)
+}
+
+// NormalizeMovieKeyword impls MovieSearcher.NormalizeMovieKeyword.
+func (s *ThePornDBVideo) NormalizeMovieKeyword(keyword string) string {
+	if number.IsSpecial(keyword) {
+		return ""
+	}
+	return strings.ToUpper(keyword)
+}
+
+// SearchMovie impls MovieSearcher.SearchMovie.
+func (s *ThePornDBVideo) SearchMovie(keyword string) (results []*model.MovieSearchResult, err error) {
+	if s.accessToken == "" {
+		return nil, nil
+	}
+
+	c := s.ClonedCollector()
+
+	c.OnResponse(func(r *colly.Response) {
+		resp := &searchVideosResponse{}
+		if err = json.Unmarshal(r.Body, resp); err != nil {
+			return
+		}
+		for _, video := range resp.Data {
+			releaseDate, _ := video.ReleaseDate()
+			results = append(results, &model.MovieSearchResult{
+				ID:          video.Slug,
+				Number:      video.Slug,
+				Title:       video.Title,
+				Provider:    s.Name(),
+				Homepage:    fmt.Sprintf(s.apiGetURL, video.Slug),
+				ThumbURL:    video.Poster,
+				CoverURL:    video.Image,
+				ReleaseDate: releaseDate,
+			})
+		}
+	})
+
+	headers := http.Header{}
+	headers.Set("Authorization", fmt.Sprintf("Bearer %s", s.accessToken))
+	err = c.Request(http.MethodGet, fmt.Sprintf(s.apiSearchURL, url.QueryEscape(keyword)), nil, nil, headers)
+	return
+}



Test patch:
diff --git a/provider/theporndb/theporndb_test.go b/provider/theporndb/theporndb_test.go
new file mode 100644
index 0000000..f3b5e08
--- /dev/null
+++ b/provider/theporndb/theporndb_test.go
@@ -0,0 +1,132 @@
+package theporndb
+
+import (
+	"os"
+	"testing"
+
+	"github.com/metatube-community/metatube-sdk-go/provider/internal/testkit"
+)
+
+// Set env MT_THEPORNDB_ACCESS_TOKEN to run tests.
+var accessToken = os.Getenv("MT_THEPORNDB_ACCESS_TOKEN")
+
+func TestThePornDBVideo_GetMovieInfoByID(t *testing.T) {
+	if accessToken == "" {
+		t.Skip("MT_THEPORNDB_ACCESS_TOKEN is not set")
+	}
+	testkit.Test(t, func() *ThePornDBVideo {
+		res := NewThePornDBScene()
+		res.accessToken = accessToken
+		return res
+	}, []string{
+		"i-want-clips-leaking-into-debt",
+		"f32b7a83-9477-4c6f-8d8a-e60dae1aada3",
+		"6474100",
+	})
+
+	testkit.Test(t, func() *ThePornDBVideo {
+		res := NewThePornDBMovie()
+		res.accessToken = accessToken
+		return res
+	}, []string{
+		"digital-sin-sisterly-love-4",
+		"ee8cf0e2-55b3-41fa-a7f6-7db83068b2e4",
+		"6472937",
+	})
+}
+
+func TestThePornDBVideo_GetMovieInfoByURL(t *testing.T) {
+	if accessToken == "" {
+		t.Skip("MT_THEPORNDB_ACCESS_TOKEN is not set")
+	}
+	testkit.Test(t, func() *ThePornDBVideo {
+		res := NewThePornDBScene()
+		res.accessToken = accessToken
+		return res
+	}, []string{
+		sceneBaseURL + "i-want-clips-leaking-into-debt",
+		sceneBaseURL + "f32b7a83-9477-4c6f-8d8a-e60dae1aada3",
+		sceneBaseURL + "6474100",
+	})
+
+	testkit.Test(t, func() *ThePornDBVideo {
+		res := NewThePornDBMovie()
+		res.accessToken = accessToken
+		return res
+	}, []string{
+		movieBaseURL + "digital-sin-sisterly-love-4",
+		movieBaseURL + "ee8cf0e2-55b3-41fa-a7f6-7db83068b2e4",
+		movieBaseURL + "6472937",
+	})
+}
+
+func TestThePornDBVideo_SearchMovie(t *testing.T) {
+	if accessToken == "" {
+		t.Skip("MT_THEPORNDB_ACCESS_TOKEN is not set")
+	}
+	testkit.Test(t, func() *ThePornDBVideo {
+		res := NewThePornDBScene()
+		res.accessToken = accessToken
+		return res
+	}, []string{
+		"The Three Evil Dragon",
+		"6377406",
+		// search on slug and uuid does not work.
+	})
+
+	testkit.Test(t, func() *ThePornDBVideo {
+		res := NewThePornDBMovie()
+		res.accessToken = accessToken
+		return res
+	}, []string{
+		"Sisterly Love 4",
+		"6472937",
+		// search on slug and uuid does not work.
+	})
+}
+
+func TestThePornDBActor_GetActorInfoByID(t *testing.T) {
+	if accessToken == "" {
+		t.Skip("MT_THEPORNDB_ACCESS_TOKEN is not set")
+	}
+	testkit.Test(t, func() *ThePornDBActor {
+		res := NewThePornDBActor()
+		res.accessToken = accessToken
+		return res
+	}, []string{
+		"adf8435e-d5df-42b9-b46b-8440dee5a271",
+		"harley-king",
+		"138309",
+	})
+}
+
+func TestThePornDBActor_GetActorInfoByURL(t *testing.T) {
+	if accessToken == "" {
+		t.Skip("MT_THEPORNDB_ACCESS_TOKEN is not set")
+	}
+	testkit.Test(t, func() *ThePornDBActor {
+		res := NewThePornDBActor()
+		res.accessToken = accessToken
+		return res
+	}, []string{
+		actorBaseURL + "adf8435e-d5df-42b9-b46b-8440dee5a271",
+		actorBaseURL + "harley-king",
+		actorBaseURL + "138309",
+	})
+}
+
+func TestThePornDBActor_SearchActor(t *testing.T) {
+	if accessToken == "" {
+		t.Skip("MT_THEPORNDB_ACCESS_TOKEN is not set")
+	}
+	testkit.Test(t, func() *ThePornDBActor {
+		res := NewThePornDBActor()
+		res.accessToken = accessToken
+		return res
+	}, []string{
+		"Harley",
+		"138309",
+		"harley-king",
+		"adf8435e-d5df-42b9-b46b-8440dee5a271",
+	})
+}