Bellesa API getting started
Version 1.0.0. Distribution: unofficial-api-for-bellesa; import: bellesa_api.
Requires Python 3.12+, eaf-base-api[hls]>=4.2.6, and selectolax.
pip install unofficial-api-for-bellesa
FFmpeg is needed to remux HLS output to MP4. Direct MP4 transfers use the shared RAW downloader. No account credentials are required for the public catalog.
import asyncio
from bellesa_api import Client
async def main():
async with Client() as client:
video = await client.get_video("https://www.bellesa.co/videos/7054")
print(video.title, video.duration, video.pornstars)
await video.load_sources("media")
print(video.available_qualities)
if __name__ == "__main__":
asyncio.run(main())
Client() owns its core and closes it on context exit or await client.close().
Client(core) leaves the injected core caller-owned. Keep the core open until all
objects, iterators and downloads using it have finished. Requests, caching, retries,
source loading, iteration and downloads use eaf-base-api; session headers and the
caller's download configuration are not mutated by this provider.
from base_api import BaseCore
from base_api.modules.config import RuntimeConfig
from bellesa_api import Client
async def inspect_video():
runtime = RuntimeConfig()
runtime.request_delay = 1
runtime.pages_concurrency = 1
runtime.videos_concurrency = 2
async with BaseCore(runtime) as core, Client(core) as client:
video = await client.get_video("https://bellesa.co/videos/1094")
print(video.title)
See Client, source loading, downloads, and shared runtime configuration.
Client — Bellesa API
Client(core: BaseCore | None = None) constructs objects sharing a configurable backend.
An internally created core is closed by close() or the async context manager. An
injected core remains caller-owned. The client has no login or account state.
Object getters
async def get_video(url: str, load_html: bool = False, load_api: bool = True) -> Video: ...
async def get_performer(url: str, load_html: bool = False, load_api: bool = True) -> Performer: ...
async def get_channel(url: str, load_api: bool = True) -> Channel: ...
async def get_category(url: str, load_api: bool = True) -> Category: ...
async def get_categories() -> list[Category]: ...
Getters require complete supported URLs, not bare IDs/handles. Video paths are
/videos/{numeric-id} or /videos/{numeric-id}/{title-slug}; performer, channel and
category routes are /performers/{handle}, /channels/{handle} and
/videos/categories/{handle}. Accepted hosts are bellesa.co and www.bellesa.co.
Only individual resources are accepted by object getters.
Setting all available source flags false constructs a deferred object. Video and
Performer have independent API/HTML loaders. Channel and Category have API-only
metadata. get_categories() is an awaited list of loaded categories, not a stream.
get_videos
def get_videos(*, sort=Sort.RECENT, categories=(), channels=(), performers=(), tags=(),
min_duration: int | None = None, max_duration: int | None = None,
pages=1, start_page=1, iterator_config=None) -> ScrapeStream[Video]: ...
Filters are sequences of handles/tags, for example channels=["zero-to-hero"], not
individual strings or profile URLs. Duration bounds are nonnegative integer seconds;
when both are given, min_duration <= max_duration is required. Provider/performer
multiselects use the site's OR filter. Category/tag combinations retain site semantics.
Use recent, popular or longest ordering. See filters.
get_performers
def get_performers(*, gender="female", pages=1, start_page=1,
iterator_config=None) -> ScrapeStream[Performer]: ...
gender is "female", "male" or "all". The directory orders by the site's video
count descending and has 12 items per page. Profile metadata is loaded through API by
default. It is distinct from Performer.videos(), which lists videos at 9 per page.
search
def search(query: str, *, kind="video", sort=Sort.RELEVANCE,
pages=1, start_page=1, iterator_config=None) -> ScrapeStream[Video | Performer | Channel]: ...
The trimmed query must have at least three characters. kind selects "video",
"performer", or "channel"; results are the corresponding typed objects. Searches use
the site's search endpoint, not local substring matching. Supports relevance, recent,
longest and popular ordering; exact ranking and matching belong to the site. Search
pages contain up to 24 items. There is no separate search_channels() method.
Stream defaults
All listing methods return ScrapeStream and accept a positive page count or
pages="all", positive one-based start_page, and optional IteratorConfig. Defaults
load the api source, preserve original ordering, and raise terminal page/item errors.
An explicitly provided configuration replaces those defaults. The initial pagination
request occurs outside the Helper scheduler, so its failure propagates directly even
when a custom item/page error policy is configured.
See iteration and results, Video, Performer, Channel, and Category.
Video — Bellesa API
Video(url=..., core=..., video_id=None) is a keyword-only BaseMedia dataclass.
Prefer await client.get_video(url). Construction validates the URL, preserves its
accepted host, strips query/fragment, and derives a string numeric video_id. An
explicit video_id must agree with the URL. No network request occurs in construction.
Metadata fields
Fields may be known absent (None or an empty list) or unresolved. Reading an unresolved
field raises DataNotLoadedError; inspect is_field_loaded() or load it explicitly.
Common fields prefer API over HTML regardless of load order.
| Fields | Type / units | Source |
|---|---|---|
url, video_id, core |
str, str, BaseCore |
Identity |
resolved_url |
str or None, response transport metadata |
Shared backend |
title, title_tag |
str or None |
api, html |
description, description_html |
Plain text and original HTML; str or None |
api, html |
thumbnail_url, preview_video_url |
str or None |
api, html |
duration, length |
Integer seconds; minutes:seconds display string |
api, html |
posted_on, publish_date |
Unix seconds; UTC ISO 8601 string | api, html |
views, likes |
int or None; mutable site statistics |
api, html |
tags, categories |
Lists of names | api, html |
primary_category |
Category name or None |
api, html |
category_urls |
Category URL list | api, html |
pornstars, pornstars_urls |
Performer names and URLs | api, html |
channel_urls |
Provider/channel URL list | api, html |
author, author_link |
First provider's name and channel URL | api, html |
advertised_qualities |
Integer labels supplied in metadata; possibly empty | api, html |
source_id, preview_source_id, trailer_source_id |
Player identifiers or None |
api, html |
m3u8_base_url |
Public HLS master URL or None |
api, html |
access, highlights |
Site-supplied access mapping and editorial list | api, html |
canonical_url, og_title |
Open Graph values or None |
html only |
available_qualities |
Sorted actual shorter-dimension values, list[int] |
media |
direct_sources |
Actual quality → direct MP4 URL, dict[int, str] |
media |
renditions |
List of rendition mappings described below | media |
content_url |
Highest actual rendition's direct URL | media |
The media loader first resolves m3u8_base_url if needed, then reads the live HLS
master. It does not download the video file. A deferred object's prerequisite fields
normally select API; loading HTML first allows the media loader to reuse that data.
This dependency does not load HTML automatically.
Each rendition maps quality, source_quality, width, height, bandwidth, fps,
codecs, hls_url, and direct_url. quality is the actual shorter dimension;
source_quality retains the site's file label. Label 480 commonly measures 800×450.
Video 1094 has labels 360/480/720 but actual qualities 480/600/960. See
downloads before selecting numeric quality.
Relationships
async def get_author(load_api: bool = True) -> Channel | None: ...
async def get_performers(load_api: bool = True) -> list[Performer]: ...
def related_videos(*, pages=1, start_page=1, iterator_config=None) -> ScrapeStream[Video]: ...
Relationship methods load the required video fields and return usable profile objects.
With load_api=False, returned profiles are deferred. Author is the first channel,
not an individual performer. Related videos use the same channel, exclude the current
video, and use pages of 6. This stream is separate from the site's randomized autoplay
rail. If no author exists, it is empty.
Download and refresh
async def download(configuration: DownloadConfigHLS | DownloadConfigRAW): ...
async def refresh_media() -> Video: ...
download() requires access.public == 1, loads media, copies configuration and applies
the shared filename rule. HLS returns the shared report/boolean; RAW returns a boolean.
It does not invent media authorization. Availability and cancellation errors retain
the shared behavior described in errors.
refresh_media() invalidates this video's API text cache entry and returns a new
Video with fresh API and media sources. It leaves the original object's metadata and
the core's session headers unchanged. HTML-only fields remain deferred on the new object.
Shared to_dict() omits unresolved fields and the core by default, without fetching.
Performer — Bellesa API
Performer(url=..., core=...) is a keyword-only BaseMedia dataclass for
https://www.bellesa.co/performers/{handle}. Use await client.get_performer(url).
Both api and html sources are available, with API precedence; API loads by default.
Setting both source flags false creates a deferred profile.
| Field | Type | Meaning |
|---|---|---|
url, core |
str, BaseCore |
Identity/backend |
profile_id |
str or None |
Stable numeric performer ID as text |
name, description |
str or None |
Display name and plain biography |
thumbnail_url, hero_url |
str or None |
Images when supplied |
logo_url |
str or None |
Usually absent on performers |
gender |
str or None |
Site-supplied gender |
video_count |
int or None |
Profile count; may span more than the free listing |
videos / get_videos
def videos(*, sort=Sort.RECENT, pages=1, start_page=1,
iterator_config=None) -> ScrapeStream[Video]: ...
get_videos is an alias of videos. The free catalog listing uses 9 items per page;
recent, longest and popular ordering are supported. The profile can be deferred because
the listing filter uses its handle. Use pages="all" to follow the listing's actual
pagination total, not video_count.
Lily Starfire's profile reported 7 videos while the free listing exposed 3; Kenna James's free listing exposed 57 over seven pages. The library preserves these distinct count semantics instead of including recommendations or premium-only entries.
from bellesa_api import Client
async def list_performer():
async with Client() as client:
performer = await client.get_performer(
"https://www.bellesa.co/performers/kenna-james"
)
async with performer.videos(pages="all") as stream:
async for result in stream:
video = result.unwrap()
print(video.video_id, video.title)
The directory is Client.get_performers(...); performer search is
Client.search(query, kind="performer"). There is no separate Model/Pornstar subtype.
See Client and iteration.
Channel — Bellesa API
Channel(url=..., core=...) is a keyword-only BaseMedia dataclass for
https://www.bellesa.co/channels/{handle}. Use await client.get_channel(url, load_api=True).
Only the api source exists for channel metadata; load_api=False defers it.
| Field | Type | Meaning |
|---|---|---|
url, core |
str, BaseCore |
Channel identity/backend |
profile_id |
str or None |
Provider ID as text |
name, description |
str or None |
Channel name and plain description |
thumbnail_url, logo_url |
str or None |
Channel image/logo |
website_url |
str or None |
Optional external website |
videos / get_videos
def videos(*, sort=Sort.RECENT, pages=1, start_page=1,
iterator_config=None) -> ScrapeStream[Video]: ...
get_videos aliases videos. Streams use the actual provider-filtered public video
endpoint with 24 items per page. pages="all" uses listing totals and handles partial
and exact full-page boundaries. Recent, popular and longest sorts are supported.
The website redirects /channels/{handle} to /videos?providers={handle} and can drop
a channel URL's page query. The API paginates the actual listing directly, preserving
the provider filter. Channel profiles have no invented video count or HTML loader.
from bellesa_api import Client
async def list_channel():
async with Client() as client:
channel = await client.get_channel("https://www.bellesa.co/channels/zero-to-hero")
print(channel.name)
async with channel.videos(pages="all") as stream:
async for result in stream:
print(result.unwrap().video_id)
Use Video.get_author() to follow the first provider relationship,
Client.search(query, kind="channel") for channel search, or
Client.get_videos(channels=["zero-to-hero"]) for filters.
See iteration.
Category — Bellesa API
Category(url=..., core=...) is a keyword-only BaseMedia dataclass for
https://www.bellesa.co/videos/categories/{handle}. Use await client.get_category(url).
Metadata uses api only; load_api=False creates a deferred category.
| Field | Type | Meaning |
|---|---|---|
url, core |
str, BaseCore |
Identity/backend |
category_id |
str or None |
Category ID as text |
name, description |
str or None |
Name and plain description |
The loader finds the exact handle in the public category directory. Unknown handles
raise NotFound through the shared media loader error wrapper. The observed directory
contained 19 categories; catalog contents may change.
videos / get_videos
def videos(*, sort=Sort.RECENT, pages=1, start_page=1,
iterator_config=None) -> ScrapeStream[Video]: ...
get_videos aliases videos; it delegates to Client.get_videos(categories=[handle]).
Pages contain up to 24 items and support recent, popular and longest ordering.
from bellesa_api import Client
async def list_categories():
async with Client() as client:
categories = await client.get_categories() # list[Category], not ScrapeResult
for category in categories:
print(category.category_id, category.name)
sensual = await client.get_category(
"https://www.bellesa.co/videos/categories/sensual"
)
async with sensual.videos() as stream:
async for result in stream:
print(result.unwrap().title)
See Client and searching and filtering.
Sort — Bellesa API
Import Sort from bellesa_api or bellesa_api.modules.sorting.
It is a StrEnum; string values are accepted by the same public methods.
| Member | Value | Site order field |
|---|---|---|
Sort.RECENT |
recent |
posted_on, descending |
Sort.POPULAR |
popular |
views, descending |
Sort.LONGEST |
longest |
duration, descending |
Sort.RELEVANCE |
relevance |
relevance, descending |
Video/profile/category listings default to RECENT. Search defaults to RELEVANCE.
Use RELEVANCE for search; relevance ordering without a query is not a verified listing
capability. Unknown values raise ValueError when converted to Sort. These values
are ordering choices, not download quality labels or upload-date filters.
See search and filters.
CLI — Bellesa API
Run bellesa_api -h or python -m bellesa_api -h.
bellesa_api URL [--quality QUALITY] [--output OUTPUT] [--format {hls,mp4}]
| Argument | Default | Meaning |
|---|---|---|
URL |
Required | Supported public Bellesa video URL |
--quality |
best |
best, half, worst, or an actual numeric resolution such as 720 |
--output |
. |
Output directory; the sanitized title supplies the filename |
--format |
hls |
hls uses shared HLS download with MP4 remux; mp4 uses direct RAW |
python -m bellesa_api https://www.bellesa.co/videos/7054 --quality worst --format hls --output ./
bellesa_api https://www.bellesa.co/videos/7054 --quality 720 --format mp4 --output ./
The CLI owns and closes its client. It has no search, credential, browser-cookie,
proxy or JSON-export arguments. Configure a custom BaseCore through the Python API
when runtime customization is needed. See downloading.
Source loading — Bellesa API
Video and Performer each declare independent api and html sources. Their getters
load API by default and leave HTML deferred. API is an anonymous JSON request; HTML
reads the requested object from server-rendered window.__INITIAL_DATA__. Embedded
JSON is part of the HTML request, not a separate network API.
from bellesa_api import Client
async def inspect_sources():
url = "https://www.bellesa.co/videos/1094"
async with Client() as client:
api = await client.get_video(url, load_api=True, load_html=False)
html = await client.get_video(url, load_api=False, load_html=True)
both = await client.get_video(url, load_api=True, load_html=True)
deferred = await client.get_video(url, load_api=False, load_html=False)
print(deferred.to_dict())
await deferred.load_fields("title")
await deferred.load_sources("html")
print(deferred.title, deferred.canonical_url)
await deferred.load_sources("media")
print(deferred.renditions)
A common field uses API precedence regardless of completion or loading order.
HTML-only canonical_url and og_title remain available after API loads. Description
is taken from the target video; a generic website description does not replace a known
absent one. The shared loader applies mappings atomically and records source failures.
Access is synchronous: reading an unresolved field raises DataNotLoadedError and
does not initiate network I/O. None means a loaded source knows a value is absent.
Use loaded_sources, source_state(source), source_errors, is_field_loaded(name),
unloaded_fields(), load_fields(...), and load_sources(...) for inspection/loading.
to_dict() omits unresolved fields and core by default without fetching. The shared
include_unloaded and include_core options control serialization explicitly.
Video also has a lazy media source. It obtains prerequisite player metadata if
needed, then reads the HLS master to populate measured renditions and direct-source
mappings. To keep metadata HTML-only, load HTML before requesting media. It reuses
already-loaded fields instead of fetching API when that is sufficient.
Channel and Category have api metadata only; passing an unsupported source name raises
a shared loader configuration error. refresh_media() returns a new API/media-loaded
Video and does not automatically load HTML-only fields.
See Video fields, errors, and shared BaseMedia.
Searching and filtering — Bellesa API
Client.search(query, kind="video", sort=Sort.RELEVANCE, ...) uses the site's search
endpoint. A trimmed query must have at least three characters; an empty query is not
a general catalog listing. Select kind="performer" or kind="channel" for typed profile
results. Search supports relevance, recent, longest and popular ordering. Matching and
ranking are site-defined; a query containing one meaningful term can return results
even when its other tokens appear unusual.
from bellesa_api import Client, Sort
async def search_catalog():
async with Client() as client:
async with client.search("romance", sort=Sort.POPULAR) as stream:
async for result in stream:
print(result.unwrap().title)
async with client.search("lacy lennon", kind="performer") as stream:
async for result in stream:
print(result.unwrap().name)
async with client.search("zero", kind="channel") as stream:
async for result in stream:
print(result.unwrap().name)
Use get_videos() for a general or filtered listing:
from bellesa_api import Client
async def filter_catalog():
async with Client() as client:
async with client.get_videos(
categories=["sensual"], channels=["zero-to-hero", "belle-says"],
performers=[], tags=[], min_duration=60, max_duration=1200,
sort="recent", pages=2, start_page=1,
) as stream:
async for result in stream:
print(result.unwrap().video_id)
Each filter accepts a sequence of handles/tags, not a scalar string or object URL. Channel/performer multiselects use the site's OR filter. Duration bounds are seconds, nonnegative integers, and ordered when both are supplied; the website's UI may express these in minutes, which does not change this API's units. Empty valid listings yield nothing. Empty results are not substituted with promoted or recommended videos.
Filters are available on get_videos(), not as arbitrary keyword arguments to search().
Profile .videos() methods accept sort/pagination/iterator controls. The performer
directory supports gender="female", "male", or "all" through get_performers().
See Client, Sort, and
iteration.
Pagination, iteration, and ScrapeResult — Bellesa API
Listings return ScrapeStream objects yielding ScrapeResult[T]. Stream methods are
synchronous factories: use async with client.search(...) as stream, not
await client.search(...). Object getters such as get_video() are awaited.
from bellesa_api import Client
async def iterate():
async with Client() as client:
async with client.get_videos(pages=2, start_page=1) as stream:
async for result in stream:
video = result.unwrap()
print(video.video_id, video.title)
Pages and boundaries
start_page is one-based and positive. pages is a positive page count from that start,
or "all". Actual endpoint x-pagination-* headers bound the requested range. Beyond-last
and known empty listings yield nothing. The initial request validates the returned page
number; it raises if Bellesa returns a different page. There is no guessed page total.
| Listing | Items per page |
|---|---|
| General videos, channels, categories, search | 24 |
| Performer profile videos | 9 |
| Performer directory | 12 |
| Related videos | 6 |
The range includes partial final pages and exact full-page boundaries. Performer
video_count is not used to invent a free listing total. Channel pagination uses the
actual provider filter because the website's /channels/... redirect loses page queries.
Recommendations, campaigns and autoplay rails are separate from these listings.
Defaults and IteratorConfig
Provider defaults load API metadata, use ResultOrder.ORIGINAL, and raise terminal page
and item errors. A supplied IteratorConfig is honored instead. A bare shared config
has no requested metadata sources/fields and uses completion ordering and yielded errors.
Set ordering explicitly when comparing complete ID sequences.
from base_api import ErrorMode, IteratorConfig, ResultOrder
from bellesa_api import Client
async def deferred_items():
options = IteratorConfig(
order=ResultOrder.ORIGINAL,
load_specific_sources=(),
load_specific_fields=(),
max_page_concurrency=1,
max_item_concurrency=2,
page_error_mode=ErrorMode.YIELD,
item_error_mode=ErrorMode.YIELD,
)
async with Client() as client:
async with client.get_videos(iterator_config=options) as stream:
async for result in stream:
if result.succeeded:
print(result.unwrap().video_id) # Metadata remains deferred.
else:
print(result.stage, result.url, result.error)
Use load_specific_sources=("html",) for HTML-loaded video results, or
load_specific_fields=("title",) to load just the required source. Requesting a field
loads its source mapping, not an invented field-only network endpoint. Profiles without
an HTML source must use API/field loading. Concurrency, bounded retries and error handlers
are shared backend contracts; the initial pagination request precedes Helper scheduling
and propagates its own transport errors directly.
ScrapeResult and cleanup
Results expose stage, url, page_index, item_index, attempts, item, error,
and succeeded. unwrap() returns the item or raises its typed terminal scrape error.
Stage indexes describe the scheduler's source range; they are not website page numbers.
Always use async with when breaking early. Exhaustion cleans up naturally; explicit
stream contexts cancel pending work on partial consumption. Keep the owning core open
while consuming the stream.
Downloading — Bellesa API
Video.download(configuration) uses the supplied BaseCore and accepts
DownloadConfigHLS or DownloadConfigRAW. Only publicly accessible video records are
eligible. Metadata and media are loaded lazily; missing/unavailable sources raise errors.
import asyncio
from bellesa_api import Client, DownloadConfigHLS, DownloadConfigRAW
async def main():
async with Client() as client:
video = await client.get_video("https://www.bellesa.co/videos/7054")
await video.load_sources("media")
print(video.available_qualities)
hls = DownloadConfigHLS(
quality="worst", path="./hls/", remux=True, return_report=True,
)
report = await video.download(hls)
print(report["status"])
direct = DownloadConfigRAW(quality="worst", path="./mp4/")
print(await video.download(direct))
if __name__ == "__main__":
asyncio.run(main())
HLS uses the master and shared quality selection; remux=True produces MP4 and requires
FFmpeg. return_report=True requests the shared report, which includes status and
metadata-writing results. Inspect completion status. RAW uses the measured direct-source
map and returns a boolean. Transfer callbacks, cancellation, supported resume configuration
and HLS export metadata pass through the shared backend.
Configuration is copied before applying filenames and stream URLs; callback and stop-event
objects remain shared with the caller. By default path is a directory and a sanitized
title plus .mp4 is appended. For an exact output file, use no_title=True and pass the
full filename as path. The input config's path and HLS URL are not overwritten.
Actual qualities versus source labels
Quality keys use the shorter actual dimension, matching the backend. Metadata's
advertised_qualities and each rendition's source_quality retain Bellesa's original
file labels. They can differ from actual dimensions:
| Sample | Site labels | Actual qualities |
|---|---|---|
| Video 7054 | 360, 480, 720, 1080, 1440, 2160 | 360, 450, 720, 1080, 1440, 2160 |
| Video 1189 | 360, 480, 720 | 360, 450, 720 |
| Video 4651 | 360, 480, 720, 1080 | 360, 450, 720, 1080 |
| Video 1094 (4:3) | 360, 480, 720 | 480, 600, 960 |
The media source reads actual master renditions. If metadata lacks resolution labels, the public player's observed 360/480/720 default requests a master, and only returned renditions are exposed. A marketing label does not prove a measured quality.
best chooses the highest actual quality; worst the lowest; half the middle of the
sorted list (upper middle for an even-length list). Numeric selections use nearest
available quality, preferring the higher tier on equal distances. On 7054, requesting
480 selects actual 450. On 1094, requesting 720 selects actual 600. Inspect renditions
for exact dimensions, FPS, codecs, bitrate and URLs.
Refresh, context and errors
fresh = await video.refresh_media() returns a new API/media-loaded object with refreshed
source data. Perform subsequent operations on fresh; the existing Video is unchanged.
Media queries/signatures are not stripped and session headers are not changed per video.
Observed public requests work without extra Referer/Origin headers. No authorization or
protected stream is fabricated.
VideoUnavailable and cancellation retain shared availability/cancellation behavior;
other preparation/transfer failures are wrapped in DownloadFailed with causes.
See errors, live validation, and
shared download options.
Live validation — Bellesa API
The implementation validation on 2026-11-10 covered 107 distinct final live test cases across runs, with no unresolved final-case failures or skips, and 49 finished media files. This is recorded implementation evidence, not a claim that the entire changing catalog or a new connection has just been retested by reading this document.
The initial 103-case full run had 100 passed / 3 failed / 0 skipped in about 28 minutes.
Three test expectations were corrected: two expected bare NotFound instead of shared
MediaLoadError preserving NotFound; one assumed an unmatched search query even though
the site matched one of its terms. A corrected 57-case metadata/profile/listing/search
run passed; the corrected three regressions also passed after the original process ended.
An added HLS metadata-export test and a missing-resolution-label test each passed.
The final union covers 107 distinct cases, rather than 107 passing in a single run.
The connection was mobile while travelling. Slow/transient requests were treated as environmental uncertainty; a timeout was not evidence of a site outage. Shared Tenacity deprecation warnings were observed. Python 3.15/Linux and FFmpeg/ffprobe were used.
Complete files and quality matrix
Both HLS (remuxed MP4) and direct MP4 were fully downloaded for every actual quality in four samples: 7054 (six renditions), 1189 (three), 4651 (four), and 1094 (three unusual 4:3 renditions). This accounts for 32 per-quality files. Additional complete files covered best/half/worst/numeric fallback in both formats, two different videos concurrently on one core, refreshed repeats, two CLI formats, and HLS title/artist metadata export.
ffprobe checked dimensions, audio, plausible duration and a valid container. Callbacks
reached completion and caller configuration stayed unchanged. A HEAD request or one HLS
segment was not counted as a complete download. Temporary media was removed after checks.
See the measured quality matrix.
Complete profile reconciliation
Independent extraction from the exact rendered listing state was compared page-by-page and as a complete ordered sequence with API output. Recommendations and campaigns were excluded. No missing, unexpected or duplicate IDs remained.
| Profile | Listing pages | Expected free IDs | API IDs |
|---|---|---|---|
| Lily Starfire, performer | 3 | 3 | 3 |
| Kenna James, performer | 9+9+9+9+9+9+3 | 57 | 57 |
| Belle Says, channel | 4 | 4 | 4 |
| Zero To Hero, channel | 24+6 | 30 | 30 |
| Erika Lust, channel | 24 | 24 | 24 |
Lily Starfire's profile count of 7 spans more than the free listing's 3; no unrelated
videos were added to match it. Exact full-page, partial final-page, per-page, middle,
last, beyond-last and pages="all" behavior were checked where applicable. Profile sorts
matched the rendered free listing. Channel pagination uses /videos?providers=... to
avoid the page-dropping channel redirect.
Running the live suite
From the provider checkout with local backend dependencies installed:
.venv/bin/python -m pytest -q --junitxml=/tmp/bellesa-live-results.xml
.venv/bin/ruff check bellesa_api
uv build
Every provider test uses real requests. The full suite downloads complete files and may
transfer substantial media. The documentation build/link/catalog checks do not perform
these transfers. Historical XML artifacts were stored outside the checkout under /tmp;
keeping those artifacts elsewhere is the caller's responsibility.
Source-loading coverage includes API-only, HTML-only, both orders, combined/deferred
loading, field serialization, custom iterator source/field loading, missing resources,
and a real video (2013) with absent resolution labels. Both accepted page hosts preserved
identity and usable media. Representative routes include /videos/7054, /videos/1094,
/videos/11157, /videos/2773, /performers/kenna-james, and /channels/zero-to-hero.
Authenticated/premium-only playback, encrypted/DRM media, regional access bypass, non-English hosts, proxies, non-video story/article APIs and unobserved layouts remain outside the verified scope. See supported hosts.
Errors and troubleshooting — Bellesa API
Bellesa re-exports shared provider errors through bellesa_api.modules.errors; use
shared base_api imports for media-loading and iterator contracts.
| Error | Meaning / handling |
|---|---|
ValueError / TypeError |
Unsupported URL, inconsistent video ID, invalid filter types, bounds, sort or page arguments |
DataNotLoadedError |
A lazy field has not been loaded; await load_fields()/load_sources() |
LoaderConfigurationError |
A source does not exist for the model, such as HTML for Channel |
MediaLoadError |
One source failed; inspect original_error and the cause chain |
MediaLoadErrors |
Multiple source failures; inspect errors |
NotFound |
HTTP 404 or target identity/application data missing; often wrapped by MediaLoadError |
NetworkError |
Translated request/HTTP transport failure; inspect original causes |
BotDetection, ProxyError, UnknownNetworkError |
Shared translated transport conditions |
PageFetchError / ItemFetchError |
Terminal scheduler failure, yielded or raised according to iterator policy |
VideoUnavailable |
No eligible public video/source or availability failure |
DownloadFailed |
Download preparation/transfer failed; __cause__ retains the underlying failure |
DownloadCancelled / asyncio.CancelledError |
Cancellation propagates without conversion to DownloadFailed |
from base_api import MediaLoadError, is_resource_gone
from bellesa_api import Client
async def inspect_missing():
async with Client() as client:
try:
await client.get_video("https://www.bellesa.co/videos/999999999")
except MediaLoadError as error:
if is_resource_gone(error):
print("Resource unavailable", error.original_error)
else:
raise
Do not catch only bare NotFound around a source-loading getter: the shared media
contract wraps ordinary loader exceptions. Translation retains causes, URL and context.
The initial pagination request is outside Helper scheduling and can raise a core request
exception directly. Under default iterator policy terminal stage errors raise; under
YIELD policy, inspect every result or call unwrap() so failures are not silently ignored.
If a title exists but measured qualities are unresolved, load media. Missing HTML-only
canonical metadata requires loading HTML, not another download attempt. A loaded None
description is valid on sparse pages. Use rendition dimensions to diagnose source label
mismatches; do not assume Bellesa's 480 label guarantees 480 actual pixels.
Timeouts while travelling, using mobile data, or changing a VPN can originate in the connection. Preserve the cause/status and use bounded shared retries and configurable pacing; do not classify a transport timeout as a deleted resource or claim a site outage. A VPN does not establish that a previous transfer completed. Recheck only unresolved checks, and avoid repeating already validated media transfers for documentation changes.
No login-cookie, account, or protection-bypass workaround is part of this provider. See shared errors and source loading.
Supported platforms and hosts — Bellesa API
The implementation was validated on Linux x86_64 with Python 3.15 and FFmpeg/ffprobe. Python 3.12+ is required. Other operating systems were not verified in that run.
Accepted page hosts are exactly bellesa.co and www.bellesa.co, using HTTP/HTTPS input
and normalized HTTPS model URLs. Bare-host HTML redirects to www; model URLs retain the
supplied accepted host. Video IDs and handle identity are checked before loaded data
is accepted. Arbitrary lookalike hosts, unrelated subdomains, and nonstandard ports are
not accepted. Public media uses s.bellesa.co.
| Object | Accepted path |
|---|---|
| Video | /videos/{positive-numeric-id} or /videos/{id}/{title-slug} |
| Performer | /performers/{handle} |
| Channel | /channels/{handle} |
| Category | /videos/categories/{handle} |
Object getters require complete URLs. Query/fragment data is removed from object
identity. There is no base_url argument, bare-ID convenience getter, arbitrary CDN URL
input, locale selector or mirror-host pattern.
Observed locale: English. No language subdomain or alternate/proxy host was verified. Bellesa Plus, BBoutique, BellesaCams and non-video story/article resources are separate services outside these public provider objects. No authenticated account, saved playlist, Model/Pornstar subtype separate from Performer, or user-upload profile was verified.
See getting started and validation.
Changelog — Bellesa API
1.0.0 — current source
The current checkout declares version 1.0.0. This entry describes its public interface.
- Typed Client, Video, Performer, Channel and Category objects using
eaf-base-api. - Independent API/HTML video and performer metadata with deferred source/field loading.
- Lazy live-master media inspection and actual-quality direct-source mappings.
- Public video, performer and channel search; category, performer and filtered video listings.
- Header-bounded pagination, explicit iterator configuration and complete profile reconciliation.
- HLS/MP4 downloads, CLI, refreshed media objects, shared reports/callbacks and resource cleanup.
- Measured quality handling for labels that differ from actual dimensions, including 4:3 encodes.
- Documentation for both public HTML and the MCP Markdown catalog.
See live validation for the recorded test history and limits.