Pornhat API getting started
Version 0.1.0. Distribution: unofficial-api-for-pornhat. Import: pornhat_api.
Requires Python 3.12+ and eaf-base-api>=4.2.6, selectolax, and m3u8.
Install from PyPI:
pip install unofficial-api-for-pornhat
FFmpeg is needed for MP4 remuxing and metadata tags; ffprobe is used by the live tests.
import asyncio
from base_api import BaseCore
from base_api.modules.config import RuntimeConfig
from pornhat_api import Client, DownloadConfigHLS
async def main():
runtime = RuntimeConfig()
runtime.max_workers_download = 4
runtime.videos_concurrency = 2
runtime.pages_concurrency = 2
async with BaseCore(runtime) as core, Client(core) as client:
video = await client.get_video("https://www.pornhat.com/video/armani-black-wants-her-husband-s-friend-deep-inside/")
print(video.title, video.duration, video.views)
print(await video.get_available_qualities())
await video.download(DownloadConfigHLS(quality="best", path="./", remux=True))
if __name__ == "__main__":
asyncio.run(main())
Every runtime request and download uses the shared BaseCore. Client() owns and closes
its own core; Client(core) leaves the supplied core open. Use both context managers as above
when injecting a core. Retain the client/core until you finish using returned media objects.
The API exposes videos, model profiles, grouped channels, paysite/brand collections, tags, search, new/popular/trending video listings, and sortable model/channel directories. Favorites and history are browser-local. There is no account, playlist or related-player API.
html combines matching JSON-LD and scoped page/player data from one HTML request.
media resolves public player redirects and depends on HTML. No separate public metadata
API was found; no api source or load_api argument is exposed.
See Client, Video, source loading, downloads, and shared configuration.
Client — Pornhat API
Client(core: BaseCore | None = None, *, base_url: str = "https://www.pornhat.com")
creates typed objects sharing one configured backend. base_url must be a verified origin,
with no resource path; it controls search, catalog, tag directory and profile-directory requests.
Individual getters retain the host in their supplied URL.
Use async with Client() as client or await client.close(). Only an internally created core
is closed. An injected core remains caller-owned.
get_video
async def get_video(url: str, *, load_html: bool=True, load_media: bool=False) -> Video: ...
Return Video. HTML loads by default. load_html=False creates a deferred object; load_media=True resolves media (loading HTML first even if load_html=False). Requires an absolute /video/<slug>/ URL.
get_model
async def get_model(url: str, *, load_html: bool=True) -> Model: ...
Return Model from an absolute /models/<slug>/ URL. load_html=False defers profile metadata.
get_channel
async def get_channel(url: str, *, load_html: bool=True) -> Channel: ...
Return a grouped Channel from an absolute /sites/<slug>/ URL. This is distinct from the paysite/brand collection.
get_category
async def get_category(url: str, *, load_html: bool=True) -> Category: ...
Return a tag Category from an absolute /tags/<slug>/ URL.
get_site
async def get_site(url: str, *, load_html: bool=True) -> Site: ...
Return a paysite/brand Site from an absolute /paysite/<slug>/ URL.
search
def search(query: str, *, pages: int | Literal['all']=1, start_page: int=1, iterator_config: IteratorConfig | None=None) -> ScrapeStream[Video]: ...
Return a lazy ScrapeStream[Video]. Whitespace in the stripped query becomes hyphens before URL encoding, matching the site form. A blank query raises ValueError. No duration, date, quality or search-sort arguments exist.
get_videos
def get_videos(*, sort: Sort=Sort.NEW, pages: int | Literal['all']=1, start_page: int=1, iterator_config: IteratorConfig | None=None) -> ScrapeStream[Video]: ...
Return new, popular or trending Video results. sort is sorting.Sort.NEW, POPULAR or TRENDING. This method takes no arbitrary listing URL.
get_models
def get_models(*, sort: ProfileSort=ProfileSort.RANK, letter: str | None=None, pages: int | Literal['all']=1, start_page: int=1, iterator_config: IteratorConfig | None=None) -> ScrapeStream[Model]: ...
Return model-directory ScrapeResult[Model] objects. Supports ProfileSort.RANK, VIDEOS, UPDATED and ABC. Alphabet letter accepts A–Z or None for All. The model directory has no other group.
get_channels
def get_channels(*, sort: ProfileSort=ProfileSort.RANK, letter: str | None=None, pages: int | Literal['all']=1, start_page: int=1, iterator_config: IteratorConfig | None=None) -> ScrapeStream[Channel]: ...
Return channel-directory ScrapeResult[Channel] objects. Uses the same sorting enum as models, with the additional alphabet group letter="other".
get_categories
async def get_categories(*, load_html: bool=False) -> list[Category]: ...
Await a list of all tag Category objects. These are deferred by default (load_html=False), with only URL/core identity loaded. load_html=True loads each category sequentially and can issue many requests; prefer loading selected categories.
Listing options
pages is a positive count or "all". start_page is a positive, one-based index.
The first listing page is fetched lazily at iteration time to discover pagination for every
range. The requested range is clamped to the live last page. Beyond-last ranges yield nothing;
invalid page options raise ValueError. pages="all" follows all remaining numbered pages.
Listings return ScrapeResult values: call result.unwrap() to surface failures. Defaults
load html for each result, preserve original page/card order, and yield terminal page/item
failures. An explicitly supplied IteratorConfig is honored. Initial pagination-discovery
failures propagate directly. See iteration.
Video — Pornhat API
Video(url=..., core=...) is a keyword-only BaseMedia dataclass. Prefer
await client.get_video(url). Deferred construction makes no requests until a source or
field is explicitly loaded. Loaders atomically commit field mappings through BaseMedia.
Fields
| Field | Type | Source | Meaning |
|---|---|---|---|
url |
str |
identity | Absolute resource URL; normalized to HTTPS while preserving the verified host. Query and fragment are removed. |
core |
BaseCore |
identity | Shared networking and download backend. |
resolved_url |
str \| None |
identity | Final page response URL recorded by the shared helper, when available. |
video_id |
str \| None |
html | Stable numeric ID as a string; listing objects can already contain it. |
title |
str \| None |
html | Matching VideoObject name; the page h1 can be empty. |
description |
str \| None |
html | Matching VideoObject description. |
thumbnail_url |
str \| None |
html | JSON-LD thumbnail URL. |
poster_url |
str \| None |
html | Public player poster URL. |
preview_video_url |
str \| None |
html | Public preview clip URL from target video setup. |
publish_date |
str \| None |
html | Site ISO upload timestamp, preserving the supplied UTC offset. |
length |
str \| None |
html | Displayed duration, typically MM:SS or HH:MM:SS. |
duration |
int \| None |
html | Exact duration in integer seconds from the target player. |
views |
int \| None |
html | Exact JSON-LD userInteractionCount, normalized to an integer. |
author_name |
str \| None |
html | Producing channel name. |
author_link |
str \| None |
html | Grouped channel URL under /sites/. |
site_url |
str \| None |
html | Separate paysite/brand URL under /paysite/. |
pornstars |
list[str] \| None |
html | Performer names in page order. |
pornstars_urls |
list[str] \| None |
html | Corresponding model profile URLs in page order. |
tags |
list[str] \| None |
html | Tag names in page order. |
tags_urls |
list[str] \| None |
html | Corresponding tag collection URLs. |
embed_url |
str \| None |
html | Public numeric embed URL matching the target player. |
language |
str \| None |
html | Response html language, such as en or de. |
is_hd |
bool \| None |
html | True if the labeled player sources reach at least 720p; not a substitute for master inspection. |
player_sources |
list[dict] \| None |
html | Labeled endpoints containing quality (integer height), url, and type. MP4-looking endpoints may redirect to HLS. |
media_sources |
list[dict] \| None |
media | Resolved sources: format, url, and quality. HLS master entries use quality=None; inspect the actual master for its renditions. |
m3u8_base_url |
str \| None |
media | Resolved real HLS master URL, including its required query string. |
content_url |
str \| None |
media | First resolved direct MP4 if supplied; None for all verified HLS-only samples. |
The id property returns the loaded video_id, or an ID from a loaded embed URL, otherwise
None; it does not fetch. Unknown fields remain unloaded and raise DataNotLoadedError on
synchronous access. Known missing values are None or empty relationship lists. No
likes/dislikes, ratings, comment counts or invented defaults are exposed.
to_dict() includes only loaded fields by default and never triggers requests. It omits the
core unless requested; use it for metadata inspection/export. See source loading.
get_available_qualities
async def get_available_qualities() -> list[int]: ...
Load resolved media, inspect the real HLS master and return sorted unique integer heights. The verified samples expose [360, 480, 720].
get_channel
async def get_channel(*, load_html: bool=True) -> Channel | None: ...
Load author_link if necessary and return the related Channel, or None when absent.
get_models
async def get_models(*, load_html: bool=True) -> list[Model]: ...
Load pornstars_urls if necessary and return related Model objects in page order. There is no separate Pornstar class.
get_site
async def get_site(*, load_html: bool=True) -> Site | None: ...
Load site_url if necessary and return the separate paysite/brand Site, or None.
download
async def download(configuration: DownloadConfigHLS, *, refresh: bool=False): ...
Load title and media, copy the download configuration, and delegate to core.download. Returns bool or shared DownloadReport according to return_report. refresh=True invalidates cached page data and loads fresh sources on a separate object, leaving the original metadata state intact.
Relationship getters are awaited methods and support load_html=False for deferred
related objects. They share the parent core; keep that core open. Example:
async def inspect_relationships(video):
channel = await video.get_channel(load_html=False)
models = await video.get_models()
site = await video.get_site(load_html=False)
return channel, models, site
See downloads for quality selection, remuxing, reports, callbacks, refresh, and the observed transport limitation.
Model — Pornhat API
Model inherits the keyword-only Collection(BaseMedia) dataclass.
Use await client.get_model("https://www.pornhat.com/models/armani-black/");
pass load_html=False to defer all metadata. Absolute /models/<slug>/ URLs are required.
The JSON setup supplies model_id. The model directory uses get_models; there is no separate Pornstar class or user/account profile getter.
Fields
All metadata fields below load from html. URL/core/resolved URL are transport identity.
| Field | Type | Meaning |
|---|---|---|
url |
str |
Normalized absolute collection URL on a verified host. |
core |
BaseCore |
Shared backend; keep it open for iteration and field loading. |
resolved_url |
str \| None |
Final HTML response URL, when recorded. |
name |
str \| None |
Profile/tag title from setup; paysite collections use the h1 text. |
description |
str \| None |
Page meta description; not an invented profile biography. |
thumbnail_url |
str \| None |
Setup screenshot when supplied; None for observed paysite collections. |
object_id |
str \| None |
Model, grouped-channel, or tag numeric ID as a string; None for paysite collections. |
total_videos |
int \| None |
Exact setup count when supplied; can be zero or None. |
total_pages |
int \| None |
Live last page; 1 for single-page or observed empty collections. |
language |
str \| None |
Response html language. |
videos / get_videos
def videos(*, pages: int | Literal['all']=1, start_page: int=1, iterator_config: IteratorConfig | None=None) -> ScrapeStream[Video]: ...
get_videos is an alias of videos with the same arguments and return type.
Results are ScrapeResult[Video] values, sharing this collection's core. Default iteration
loads each video's HTML. Pass an IteratorConfig to select fields/sources, concurrency,
ordering and error handling. pages is a positive count or "all", and start_page is one based.
from base_api import make_iterator_config
async def list_collection(client):
collection = await client.get_model("https://www.pornhat.com/models/armani-black/")
config = make_iterator_config(load_specific_sources=())
async with collection.videos(pages="all", iterator_config=config) as results:
async for result in results:
video = result.unwrap()
print(video.video_id, video.url)
Iteration uses the collection's intended listing region, excluding navigation, ads and other page regions. There are 59 real cards on observed full profile pages; the library follows actual numbered pagination rather than calculating offsets from an assumed capacity. Beyond-last ranges return an empty stream. Known zero-video channels remain valid profiles. The library does not apply global deduplication across rendered pages.
See iteration, Client, and source loading.
Channel — Pornhat API
Channel inherits the keyword-only Collection(BaseMedia) dataclass.
Use await client.get_channel("https://www.pornhat.com/sites/60-plus-milfs/");
pass load_html=False to defer all metadata. Absolute /sites/<slug>/ URLs are required.
This object represents the grouped channel (dvd_group_id). A /paysite/ brand collection is a distinct Site; use video.get_channel() and video.get_site() for the two relationships.
Fields
All metadata fields below load from html. URL/core/resolved URL are transport identity.
| Field | Type | Meaning |
|---|---|---|
url |
str |
Normalized absolute collection URL on a verified host. |
core |
BaseCore |
Shared backend; keep it open for iteration and field loading. |
resolved_url |
str \| None |
Final HTML response URL, when recorded. |
name |
str \| None |
Profile/tag title from setup; paysite collections use the h1 text. |
description |
str \| None |
Page meta description; not an invented profile biography. |
thumbnail_url |
str \| None |
Setup screenshot when supplied; None for observed paysite collections. |
object_id |
str \| None |
Model, grouped-channel, or tag numeric ID as a string; None for paysite collections. |
total_videos |
int \| None |
Exact setup count when supplied; can be zero or None. |
total_pages |
int \| None |
Live last page; 1 for single-page or observed empty collections. |
language |
str \| None |
Response html language. |
videos / get_videos
def videos(*, pages: int | Literal['all']=1, start_page: int=1, iterator_config: IteratorConfig | None=None) -> ScrapeStream[Video]: ...
get_videos is an alias of videos with the same arguments and return type.
Results are ScrapeResult[Video] values, sharing this collection's core. Default iteration
loads each video's HTML. Pass an IteratorConfig to select fields/sources, concurrency,
ordering and error handling. pages is a positive count or "all", and start_page is one based.
from base_api import make_iterator_config
async def list_collection(client):
collection = await client.get_channel("https://www.pornhat.com/sites/60-plus-milfs/")
config = make_iterator_config(load_specific_sources=())
async with collection.videos(pages="all", iterator_config=config) as results:
async for result in results:
video = result.unwrap()
print(video.video_id, video.url)
Iteration uses the collection's intended listing region, excluding navigation, ads and other page regions. There are 59 real cards on observed full profile pages; the library follows actual numbered pagination rather than calculating offsets from an assumed capacity. Beyond-last ranges return an empty stream. Known zero-video channels remain valid profiles. The library does not apply global deduplication across rendered pages.
See iteration, Client, and source loading.
Category — Pornhat API
Category inherits the keyword-only Collection(BaseMedia) dataclass.
Use await client.get_category("https://www.pornhat.com/tags/blowjob/");
pass load_html=False to defer all metadata. Absolute /tags/<slug>/ URLs are required.
Tags are exposed as Category objects using tag_id. await client.get_categories() returns deferred objects by default; load selected categories instead of fetching every tag detail page.
Fields
All metadata fields below load from html. URL/core/resolved URL are transport identity.
| Field | Type | Meaning |
|---|---|---|
url |
str |
Normalized absolute collection URL on a verified host. |
core |
BaseCore |
Shared backend; keep it open for iteration and field loading. |
resolved_url |
str \| None |
Final HTML response URL, when recorded. |
name |
str \| None |
Profile/tag title from setup; paysite collections use the h1 text. |
description |
str \| None |
Page meta description; not an invented profile biography. |
thumbnail_url |
str \| None |
Setup screenshot when supplied; None for observed paysite collections. |
object_id |
str \| None |
Model, grouped-channel, or tag numeric ID as a string; None for paysite collections. |
total_videos |
int \| None |
Exact setup count when supplied; can be zero or None. |
total_pages |
int \| None |
Live last page; 1 for single-page or observed empty collections. |
language |
str \| None |
Response html language. |
videos / get_videos
def videos(*, pages: int | Literal['all']=1, start_page: int=1, iterator_config: IteratorConfig | None=None) -> ScrapeStream[Video]: ...
get_videos is an alias of videos with the same arguments and return type.
Results are ScrapeResult[Video] values, sharing this collection's core. Default iteration
loads each video's HTML. Pass an IteratorConfig to select fields/sources, concurrency,
ordering and error handling. pages is a positive count or "all", and start_page is one based.
from base_api import make_iterator_config
async def list_collection(client):
collection = await client.get_category("https://www.pornhat.com/tags/blowjob/")
config = make_iterator_config(load_specific_sources=())
async with collection.videos(pages="all", iterator_config=config) as results:
async for result in results:
video = result.unwrap()
print(video.video_id, video.url)
Iteration uses the collection's intended listing region, excluding navigation, ads and other page regions. There are 59 real cards on observed full profile pages; the library follows actual numbered pagination rather than calculating offsets from an assumed capacity. Beyond-last ranges return an empty stream. Known zero-video channels remain valid profiles. The library does not apply global deduplication across rendered pages.
See iteration, Client, and source loading.
Site — Pornhat API
Site inherits the keyword-only Collection(BaseMedia) dataclass.
Use await client.get_site("https://www.pornhat.com/paysite/naughty-america/");
pass load_html=False to defer all metadata. Absolute /paysite/<slug>/ URLs are required.
A paysite/brand collection is separate from a grouped Channel. Its page lacks the profile setup payload, so object_id, thumbnail_url and total_videos can be None. name is the complete h1 text, including its site-supplied suffix.
Fields
All metadata fields below load from html. URL/core/resolved URL are transport identity.
| Field | Type | Meaning |
|---|---|---|
url |
str |
Normalized absolute collection URL on a verified host. |
core |
BaseCore |
Shared backend; keep it open for iteration and field loading. |
resolved_url |
str \| None |
Final HTML response URL, when recorded. |
name |
str \| None |
Profile/tag title from setup; paysite collections use the h1 text. |
description |
str \| None |
Page meta description; not an invented profile biography. |
thumbnail_url |
str \| None |
Setup screenshot when supplied; None for observed paysite collections. |
object_id |
str \| None |
Model, grouped-channel, or tag numeric ID as a string; None for paysite collections. |
total_videos |
int \| None |
Exact setup count when supplied; can be zero or None. |
total_pages |
int \| None |
Live last page; 1 for single-page or observed empty collections. |
language |
str \| None |
Response html language. |
videos / get_videos
def videos(*, pages: int | Literal['all']=1, start_page: int=1, iterator_config: IteratorConfig | None=None) -> ScrapeStream[Video]: ...
get_videos is an alias of videos with the same arguments and return type.
Results are ScrapeResult[Video] values, sharing this collection's core. Default iteration
loads each video's HTML. Pass an IteratorConfig to select fields/sources, concurrency,
ordering and error handling. pages is a positive count or "all", and start_page is one based.
from base_api import make_iterator_config
async def list_collection(client):
collection = await client.get_site("https://www.pornhat.com/paysite/naughty-america/")
config = make_iterator_config(load_specific_sources=())
async with collection.videos(pages="all", iterator_config=config) as results:
async for result in results:
video = result.unwrap()
print(video.video_id, video.url)
Iteration uses the collection's intended listing region, excluding navigation, ads and other page regions. There are 59 real cards on observed full profile pages; the library follows actual numbered pagination rather than calculating offsets from an assumed capacity. Beyond-last ranges return an empty stream. Known zero-video channels remain valid profiles. The library does not apply global deduplication across rendered pages.
See iteration, Client, and source loading.
Sorting enums — Pornhat API
Import from pornhat_api.modules.sorting, or use from pornhat_api import sorting.
Sort
| Member | Value | Video catalog |
|---|---|---|
Sort.NEW |
"" |
Root latest-video listing |
Sort.POPULAR |
"popular" |
Popular videos |
Sort.TRENDING |
"trending" |
Trending videos |
Used by Client.get_videos(sort=...). Its default is Sort.NEW.
ProfileSort
| Member | Value | Directory behavior |
|---|---|---|
ProfileSort.RANK |
"" |
Default ranked model/channel directory |
ProfileSort.VIDEOS |
"videos" |
Video-count order |
ProfileSort.UPDATED |
"updated" |
Recently updated |
ProfileSort.ABC |
"abc" |
Alphabet groups |
Used by Client.get_models and Client.get_channels. Their default is ProfileSort.RANK.
For ABC, letter is "A"–"Z" or None for All. Channels also accept "other".
Models have no other group. A letter with another sort raises ValueError.
No duration, quality, upload-date, tag-sort, or search-sort enums are provided.
Source loading — Pornhat API
Pornhat exposes html metadata and media redirect resolution. Embedded JSON-LD and
data-setup JSON belong to the HTML request, not an independent public JSON API.
Collections only declare html. Videos declare both sources.
| Request | Behavior |
|---|---|
await client.get_video(url) |
Load HTML metadata only |
await client.get_video(url, load_html=False) |
Construct without requests |
await client.get_video(url, load_html=False, load_media=True) |
Media loader first ensures HTML, then resolves sources |
await video.load_sources("html", "media") |
Load both, respecting the media dependency |
await video.load_fields("title") |
Load HTML if needed |
await video.load_fields("m3u8_base_url") |
Load HTML plus media if needed |
video.to_dict() |
Serialize loaded fields without requests |
async def inspect_lazily(client, url):
video = await client.get_video(url, load_html=False)
assert not video.loaded_sources
snapshot = video.to_dict()
await video.load_fields("title", "duration")
assert video.loaded_sources == {"html"}
await video.load_sources("media")
assert video.loaded_sources == {"html", "media"}
return snapshot, video.to_dict()
Field access is synchronous and cannot fetch: unresolved fields raise DataNotLoadedError.
Known missing fields are None, while relationships can be empty lists. Inspect
is_field_loaded(name), unloaded_fields(), loaded_sources, source_state(source) and
source_errors before consuming partial objects. to_dict(include_unloaded=True) represents
unloaded fields as None; use the default to preserve loaded-versus-unloaded distinction.
Listings can construct Video objects with URL and stable video_id already present,
without claiming their HTML source is loaded. Default listing configuration then loads HTML;
make_iterator_config(load_specific_sources=()) keeps those objects deferred.
HTML data is committed atomically by the shared model. It is never overwritten by lower
information media resolution; each loader owns separate fields. Failed loaders raise shared
MediaLoadError or MediaLoadErrors with original causes and leave source errors inspectable.
See Video, errors, and shared media models.
Searching and filtering — Pornhat API
Client.search(query, *, pages=1, start_page=1, iterator_config=None) returns a lazy video
stream. Whitespace becomes hyphens before percent encoding, matching the site's form.
Do not manually construct %20 search routes: the observed site rejects those paths.
Only a nonblank query and page/iterator options are accepted; there are no search sort,
upload date, duration, quality, country, gender or language filter arguments.
Select a verified locale origin with Client(base_url="https://de.pornhat.com").
from base_api import make_iterator_config
from pornhat_api.modules.sorting import ProfileSort, Sort
async def browse(client):
lazy = make_iterator_config(load_specific_sources=())
async with client.search("Armani Black", pages=2, iterator_config=lazy) as results:
async for result in results:
print(result.unwrap().video_id)
async with client.get_videos(sort=Sort.TRENDING, iterator_config=lazy) as results:
first = (await anext(results)).unwrap()
print(first.url)
async with client.get_models(sort=ProfileSort.ABC, letter="A", iterator_config=lazy) as results:
first_model = (await anext(results)).unwrap()
print(first_model.url)
Profile directory sorting supports rank, video count, updated and alphabet groups. Models
accept A–Z or All; channels also accept other. The sorting reference
contains exact enum members and values.
async def browse_tag(client):
categories = await client.get_categories()
tag = next(category for category in categories if category.url.endswith("/tags/blowjob/"))
await tag.load_sources("html")
async with tag.videos(pages=1) as results:
async for result in results:
print(result.unwrap().title)
Categories are deferred by default. get_categories(load_html=True) loads every tag detail
sequentially, so load selected categories when possible. Use get_category for a known tag URL.
A Site collection uses /paysite/<slug>/; grouped channels use /sites/<slug>/.
The tested lesbian search repeats four cards across its first/second page boundary.
Streams preserve the site's rendered sequences, including genuine repeated entries.
Profile reconciliation found no such duplicates. See iteration.
Iteration and results — Pornhat API
Video catalogs, search, model/channel directories, and collection .videos() return
ScrapeStream objects. Construction is lazy; iteration fetches the first listing page to
inspect actual pagination, then requests the bounded selected range through the shared helper.
| Option | Meaning |
|---|---|
pages=1 |
One page (default) |
pages=N |
Positive count of pages from start_page, clamped to the last page |
pages="all" |
All remaining pages |
start_page=1 |
Positive one-based first requested page |
iterator_config=None |
HTML-loaded items, original ordering, yielded terminal item/page errors |
Beyond-last ranges return an empty stream. Invalid counts, booleans or nonpositive indices
raise ValueError. Initial navigation-discovery errors propagate directly; later page/item
failures follow IteratorConfig. Call result.unwrap() so failures are not silently ignored.
from base_api import make_iterator_config
async def list_all(client):
model = await client.get_model("https://www.pornhat.com/models/armani-black/")
config = make_iterator_config(
load_specific_sources=(),
order="original",
page_error_mode="raise",
item_error_mode="raise",
max_page_concurrency=2,
max_item_concurrency=2,
)
async with model.videos(pages="all", iterator_config=config) as results:
async for result in results:
video = result.unwrap()
print(video.video_id, video.url)
Without a caller configuration, the provider requests html for each item. To request only
certain fields, use make_iterator_config(load_specific_sources=(), load_specific_fields=("title",)).
Because all video metadata shares one HTML loader, requesting title also loads that page's
other HTML fields. Requesting media fields additionally resolves player redirects.
An explicit IteratorConfig is honored without replacing its source/error/order settings.
order="completion" permits completion order; the default is original page/card order.
Use async with or await stream.aclose() when stopping early so pending work is closed.
Do not close the injected core until all streams and returned object operations finish.
Observed page boundaries
Full profile pages contain 59 real videos; ad cards are excluded. The following complete listings were reconciled independently against live HTML on 2026-11-09:
| Type / route | Expected / actual IDs | Cards per page |
|---|---|---|
Model /models/armani-black/ |
200 / 200 | 59, 59, 59, 23 |
Model /models/venus-valencia/ |
53 / 53 | 53 |
Channel /sites/60-plus-milfs/ |
471 / 471 | 59, 59, 59, 59, 59, 59, 59, 58 |
Channel /sites/deep-throat-sirens/ |
1 / 1 | 1 |
Channel /sites/cannon-productions/ |
0 / 0 | Explicit empty-state block |
These profiles had no duplicates or missing/unexpected IDs. Search pages may repeat cards; no global deduplication is imposed. A changing catalog is not a transactional snapshot. Exact page-capacity multiples were not observed. Empty search results and zero-video channels are recognized through explicit empty-state markers; missing containers otherwise raise errors.
Downloading — Pornhat API
The verified player endpoints look like MP4 URLs but redirect to real HLS masters.
Use DownloadConfigHLS; the provider exposes no direct-MP4 downloader. Preserve signed
query strings and use Video.download rather than guessing CDN routes.
from pornhat_api import DownloadConfigHLS
async def download_video(video):
qualities = await video.get_available_qualities()
config = DownloadConfigHLS(
quality="best",
path="./downloads/",
remux=True,
return_report=True,
metadata={"title": video.title, "artist": video.author_name},
callback=lambda current, total: print(current, total),
)
report = await video.download(config, refresh=True)
assert report.status == "completed" and not report.missing
return qualities, report
Create the output directory first. Without no_title=True, the shared provider helper
joins the configured directory with the sanitized video title and .mp4 extension.
For an exact output filename, set path="./video.mp4", no_title=True.
Configurations are copied before the path/master is assigned. Callbacks, stop events,
metadata and caller options are preserved; the caller's path and master setting are unchanged.
Inspect the returned report for metadata success instead of relying on mutation of the original
configuration's metadata_written field.
Quality selection
await video.get_available_qualities() returns sorted integer heights from actual resolved
sources and HLS master inspection. All representative samples expose [360, 480, 720]:
| Selector | Verified output |
|---|---|
360 / "360p" |
640×360 |
480 / "480p" |
854×480 |
720 / "720p" |
1280×720 |
"best" |
720p |
"half" |
480p (middle available tier) |
"worst" |
360p |
1080 when absent |
720p, using shared nearest-tier fallback |
An explicit unavailable tier uses the shared nearest available resolution, preferring the higher tier on a tie. The master determines available qualities; no resolution is invented from labels, marketing flags, or URL suffixes. Additional quality sets were not observed.
Refresh, reports and cancellation
refresh=True invalidates the page cache and resolves fresh sources on a new Video sharing
the same core and stable ID. It does not overwrite the original object's loaded fields.
This refreshes normally accessible expiring URLs without altering signatures.
Downloads delegate to core.download. return_report=True returns the shared DownloadReport
with status, total, downloaded, missing, and metadata_written; otherwise the backend
returns a boolean or raises. A returned failed/cancelled report must be inspected and is not
a successful transfer. Shared stop_event, segment resume options, progress callbacks and
metadata handling are passed through. remux=True needs FFmpeg; optional metadata and
thumbnail inputs follow shared download configuration contracts.
Verified output and concurrency
On 2026-11-09 the full live suite passed 58 tests, including 20 complete downloads.
Three real videos (IDs 806155, 684951 and 138266) were downloaded at every exposed quality
and ffprobe-validated for dimensions, audio and complete duration. Further transfers covered
selectors, fallback, repeatability, refresh, concurrent different videos, German/.one hosts,
CLI downloading and embedded metadata titles.
Use RuntimeConfig.max_workers_download = 4 for the configuration used by the final suite.
An earlier 20-worker repeat stalled on an HTTP/2 connection despite previous successes;
the bounded final suite completed in 94.99 seconds. The provider preserves injected core
settings. See getting started for the explicit runtime configuration.
For a caller-level deadline, wrap the download in async with asyncio.timeout(seconds):.
See shared downloads and
errors.
Errors and troubleshooting — Pornhat API
Provider exception imports in pornhat_api.modules.errors re-export shared BotDetection,
DownloadFailed, NetworkError, NotFound, ProxyError, UnknownNetworkError and
VideoUnavailable. BaseMedia loading wraps ordinary loader failures in MediaLoadError,
or MediaLoadErrors for multiple sources. Inspect the original cause; catching only the
inner provider error around get_video() will miss the loading wrapper.
from base_api import MediaLoadError, MediaLoadErrors, is_resource_gone
async def fetch_video(client, url):
try:
return await client.get_video(url)
except (MediaLoadError, MediaLoadErrors) as error:
if is_resource_gone(error):
print("Resource no longer available:", url)
raise
| Symptom | Meaning / action |
|---|---|
ValueError on URL construction |
Use an absolute URL on a verified host and the correct resource route; arbitrary mirrors and directory URLs are rejected. |
ValueError for pages/letter/sort |
Positive integer page options or pages="all" are required; letters require ABC; models have no other group. |
DataNotLoadedError reading a field |
Await load_fields or load_sources first; synchronous access does not start requests. |
| Missing video / HTTP 404 | Source loading retains a NotFound cause through shared error wrappers; no empty success object is fabricated. |
| HTTP 403 or access denied | Preserve the error. The library does not bypass protection; check the ordinary route and legitimate access. |
| Missing player, identity mismatch, missing listing block | VideoUnavailable; an unrelated page or incomplete response must not become a valid video/list. |
| Empty search/channel | Valid only when the expected listing block contains its explicit empty-state marker. |
ScrapeResult failure |
Call unwrap, or set IteratorConfig error modes/handlers intentionally. Initial pagination discovery errors propagate directly. |
| Expiring player/CDN URL | Try download(config, refresh=True) through ordinary public access. |
| Download stall with many workers | The final suite used max_workers_download=4. An earlier 20-worker HTTP/2 repeat stalled. Configure a caller deadline as needed. |
| TS output named .mp4 | Set remux=True for an MP4 container and install FFmpeg. |
| Tenacity initial deprecation warning | Emitted by the shared backend; the final live suite passed despite these warnings. |
asyncio cancellation and the shared DownloadCancelled exception propagate; do not swallow
them in broad retry loops. Keep streams and cores scoped with context managers.
Protected, authenticated or unavailable capabilities remain explicit failures.
See source loading, iteration, and shared errors.
CLI — Pornhat API
Run pornhat_api or python -m pornhat_api. The URL is a positional argument;
there are no --download, batch-file, account, or --no-title options.
pornhat_api -h
python -m pornhat_api 'https://www.pornhat.com/video/armani-black-wants-her-husband-s-friend-deep-inside/' --info
pornhat_api 'https://www.pornhat.com/video/armani-black-wants-her-husband-s-friend-deep-inside/' --quality 480 --output ./downloads/
| Argument | Default | Meaning |
|---|---|---|
url |
Required | Absolute public /video/ URL on a verified host |
-q, --quality |
best |
Shared quality selector or numeric tier string |
-o, --output |
./ |
Existing output directory; filename is based on the title |
--info |
False | Print loaded HTML metadata as JSON; do not download |
-h, --help |
— | Show command help |
CLI downloads use DownloadConfigHLS(..., remux=True) and need FFmpeg. --info loads
HTML only and omits unresolved media fields. The CLI creates and closes its client/core.
The Python interface permits explicit core configuration, source refresh and caller deadlines;
the CLI uses backend defaults. See downloading for the observed
high-concurrency HTTP/2 limitation and verified configuration.
Supported platforms and hosts — Pornhat API
Requires Python 3.12 or newer. Live validation was performed on Linux x86_64 with Python 3.15.2, local eaf-base-api 4.2.6, curl-cffi 0.17.0, selectolax 1.0.2, m3u8 6.0.0, FFmpeg and ffprobe. Other Python versions and platforms have not been live-verified here.
Accepted hosts
| Host | Response locale / behavior |
|---|---|
pornhat.com, www.pornhat.com |
English; bare host redirects to www |
pornhat.one, www.pornhat.one |
English alias linked in the site's alternate metadata; bare host redirects to www |
de.pornhat.com |
German |
fr.pornhat.com |
French |
it.pornhat.com |
Italian |
es.pornhat.com |
Spanish |
pt.pornhat.com |
Portuguese |
hi.pornhat.com |
Hindi |
Each host was checked for video ID 806155, duration 991 seconds, the expected response
language, a working three-quality media master, and model ID 22070. Complete downloads
additionally covered .one and German hosts. Arbitrary subdomains and mirror domains
are rejected. No other host family has been verified or accepted.
from pornhat_api import Client
async def localized_video():
async with Client(base_url="https://de.pornhat.com") as client:
video = await client.get_video(
"https://de.pornhat.com/video/armani-black-wants-her-husband-s-friend-deep-inside/"
)
print(video.language)
base_url controls constructed listing URLs. Individual getters use the host supplied in
the resource URL. URL normalization keeps that verified host and resource path, enforces
HTTPS, and drops query/fragment; resolved_url records a final response URL when available.
Signed media URL queries are separate and preserved during resolution/download.
See getting started and downloads.
Changelog — Pornhat API
0.1.0 — initial local implementation
- Async
Client,Video,Model,Channel,Category, and paysite/brandSiteobjects. - Explicit HTML metadata loading and dependent public media resolution through BaseCore.
- Video/relationship metadata, tag collections, search, new/popular/trending feeds and sortable model/channel directories with original-order ScrapeResult streams.
- Numbered pagination, complete pages="all" profile listings, known empty collections and one-based page ranges.
- Verified
.com,.one, and de/fr/it/es/pt/hi host variants. - Shared HLS downloads, source refresh, callbacks, reports, FFmpeg metadata/remuxing and CLI.
- Full live verification on 2026-11-09: 58 passed, 0 failed, 0 skipped in 94.99 seconds, including 20 complete media downloads and complete model/channel reconciliation.
- The final run used four download workers. An earlier 20-worker HTTP/2 repeat stalled; this remains documented rather than hidden by the successful bounded run.
- HTML documentation and searchable MCP reference pages following the existing API corpus.
Exact-multiple profile totals, additional quality sets, other platforms, accounts, playlists and player-only recommendation controls remain unverified or unimplemented.
See getting started and download evidence.