Python Async v0.1.0

Pornhat API

An asynchronous Python API for Pornhat videos, model and channel profiles, tags, paysite collections, lazy metadata, search and HLS downloads. Powered by eaf_base_api.

GitHub
⚡ Recommended AI Workflow Official MCP Server: https://mcp.echteralsfake.me/mcp

Supercharge your development by connecting your AI coding assistant (Cursor, VS Code / Copilot, Claude Desktop, Windsurf, Zed, Antigravity). No authentication required · No strict rate limits · Covers all 16 APIs · Always up to date · Completely anonymous (0 logs, no profiling). Connecting this MCP server to your AI assistant is absolutely the recommended way to work on and build with these APIs.

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.

See hosts and sorting.

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.

See searching and filtering.

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.

See shared iterator configuration.

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/brand Site objects.
  • 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.