Python Async v1.0.0

Bellesa API

An asynchronous Python API for Bellesa videos, performers, channels, categories, typed search, lazy API/HTML metadata, and HLS/MP4 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.

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.

See shared IteratorConfig and shared ScrapeStream/Result.

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.