Metadata-Version: 2.5
Name: nhentaipy-v2
Version: 0.1.0
Summary: Async and sync Python wrapper for the NHentai V2 API
Project-URL: Homepage, https://github.com/GoonerDev69/nhentaipy
Project-URL: Repository, https://github.com/GoonerDev69/nhentaipy
Project-URL: Issues, https://github.com/GoonerDev69/nhentaipy/issues
Project-URL: Changelog, https://github.com/GoonerDev69/nhentaipy/blob/main/CHANGELOG.md
Author: GoonerDev69
License-Expression: MIT
License-File: LICENSE
Keywords: api,async,nhentai,wrapper
Classifier: Development Status :: 4 - Beta
Classifier: Framework :: Pydantic :: 2
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Typing :: Typed
Requires-Python: >=3.10
Requires-Dist: httpx>=0.26.0
Requires-Dist: pydantic>=2.0.0
Provides-Extra: dev
Requires-Dist: build>=1.0; extra == 'dev'
Requires-Dist: hypothesis>=6.80.0; extra == 'dev'
Requires-Dist: mypy>=1.0; extra == 'dev'
Requires-Dist: pylint>=3.0; extra == 'dev'
Requires-Dist: pyright>=1.1.350; extra == 'dev'
Requires-Dist: pytest-asyncio>=0.21; extra == 'dev'
Requires-Dist: pytest-cov>=5.0; extra == 'dev'
Requires-Dist: pytest-httpx>=0.21; extra == 'dev'
Requires-Dist: pytest>=7.0; extra == 'dev'
Requires-Dist: ruff>=0.1.0; extra == 'dev'
Requires-Dist: twine>=5.0; extra == 'dev'
Description-Content-Type: text/markdown

# nhentaipy

An asynchronous and synchronous Python wrapper for the NHentai V2 API.

[![PyPI version](https://badge.fury.io/py/nhentaipy-v2.svg)](https://badge.fury.io/py/nhentaipy-v2)
[![Python versions](https://img.shields.io/pypi/pyversions/nhentaipy-v2.svg)](https://pypi.org/project/nhentaipy-v2/)

It wraps everything `GET /api/v2/openapi.json` declared when this release was cut: 114 operations,
the admin-only `/moderation/users` routes included. [Endpoint Coverage](#endpoint-coverage) has the
full list. There is a blocking client and an async one, Pydantic v2 models for every payload, an
exception hierarchy you can catch by status code, and a rate limiter that keeps a burst from tripping
nhentai's own limits.

Responses that are a bare array or a scalar (`BlacklistIds` and `CommentCount`, for instance) get
their own models instead of being cast, so every published payload decodes into a type.

## Installation

Needs Python 3.10 or newer. The dependencies are `httpx>=0.26.0` (the first release whose `proxy=`
argument the client passes through) and `pydantic>=2.0.0`.

```bash
pip install nhentaipy-v2
```

The distribution is named `nhentaipy-v2` because PyPI's similarity rules read `nhentaipy` and the
older `nhentai.py` project as the same name. The import name is `nhentaipy`, so nothing in the code
below changes.

## Quick Start

### Synchronous

```python
from nhentaipy import NHentai

with NHentai(token="your-user-token") as client:
    gallery_id = client.galleries.get_random().id
    gallery = client.galleries.get_gallery(gallery_id)
    print(gallery.id, gallery.title.pretty)

    for tag in client.tags.search(q="translated"):
        print(tag.name)
```

### Asynchronous

```python
import asyncio
from nhentaipy import AsyncNHentai

async def main():
    async with AsyncNHentai(token="your-user-token") as client:
        gallery_id = (await client.galleries.get_random()).id
        gallery = await client.galleries.get_gallery(gallery_id)
        print(gallery.id, gallery.title.pretty)

        async for gallery in client.galleries.paginate_search(query="language:english"):
            print(gallery.id, gallery.title.pretty)

asyncio.run(main())
```

### Command line

```bash
python -m nhentaipy doctor --json                      # httpx version vs floor, credentials, one live call
python -m nhentaipy search "language:english" --json
python -m nhentaipy gallery 1
python -m nhentaipy pow --action gts_create --solve
```

Credentials come from `NHENTAI_USER_TOKEN` or `NHENTAI_API_KEY`. The read-only subcommands
(`search`, `galleries`, `gallery`, `comments`, `tags`, `pow`, `doctor`) just run; the ones that change
server state or write files (`download`) stop unless you pass `--yes`. `--json` prints one
machine-readable payload.

## Features

- A blocking client and an async one with the same methods.
- Pydantic v2 models for every published payload, bare arrays and scalars included.
- `DecodeError` keeps "the server sent something unexpected" apart from `NHentaiAPIError` and its
  per-status subclasses.
- Retries only on idempotent methods (GET, HEAD, OPTIONS, PUT, DELETE), with bounded backoff and
  cancellation safety.
- One rate limiter per client, applied to every API call and retunable at runtime with
  `set_rate_limit`, so a burst gets paced instead of rejected.
- Subclients: `system`, `auth`, `galleries`, `tags`, `taxonomy`, `mod`, `user`, `zones`, `gts`.

## Authentication

Two kinds of credential, and you use one or the other. Passing both raises `ValueError`:

| Constructor argument | Header sent | Use |
| --- | --- | --- |
| *(none)* | n/a | public reads |
| `token="..."` | `Authorization: User <token>` | everything tied to an account: favorites, comments, suggestions, sessions, moderation |
| `key="..."` | `Authorization: Key <key>` | API-key operations |

```python
from nhentaipy import NHentai

client = NHentai()                          # anonymous
client = NHentai(token="...")               # user token
client = NHentai(key="...")                 # API key
```

The published document marks the moderation and administration routes staff-only or superuser-only,
but the API itself only takes the `User` and `Key` schemes: send a `Staff` or `Superuser` header and
you get `401 {"error":"Malformed Authorization header: unknown auth scheme 'Staff'; expected 'Key'"}`.
Those rights ride on a `User` token whose account has them. See
[Known Limitations](#known-limitations).

The credential header belongs to the client. A per-request `headers={"Authorization": ...}` or
`auth=(...)` raises `TypeError` rather than quietly swapping the identity, because `httpx` only
strips `Authorization` on cross-origin redirects. To rotate, call `client.update_token(...)` (or
`update_refresh_token(...)`), or build a second client.

### Username/password login is not usable headlessly

`auth.login` needs a solved proof of work and a captcha. Against the live API, a correctly solved PoW
still comes back `400 {"error":"Validation error","details":["body -> captcha_response: Field
required"]}` when the captcha field is missing, and `400 Invalid CAPTCHA solution` when the captcha
value cannot be verified (a wrong `pow_nonce` gives `400 Invalid proof of work solution`). The captcha
token has to come out of a browser, so for anything unattended use a user token or an API key.

The username/password methods (`auth.login`, `auth.register`, `auth.request_password_reset`,
`auth.confirm_password_reset`) are wired correctly and work fine with a human at the keyboard; they
just cannot be driven headlessly. When `client.auth.login` does succeed, it refreshes `client.http`
credentials for you.

## Endpoint Coverage

`GET /api/v2/openapi.json` (OpenAPI 3.1.0) declared 114 operations over 98 paths when this release
was cut. The document belongs to nhentai and will grow. Every operation in it has a method here, and
no method calls a route it does not declare. [Method Reference](#method-reference) lists all 121
public methods with the route each one hits and the credential it needs.

| Subclient | Operations | Surface |
| --- | --- | --- |
| `system` | 5 | root/health, PoW challenge, CDN config, config, captcha info |
| `auth` | 9 | login, register, logout, sessions, password reset |
| `galleries` | 20 | detail, related, list, tagged, search, popular, random, download, favorites, edits, tag-change suggestions, comments |
| `tags` | 4 | list, detail, slug lookup, search |
| `taxonomy` | 12 | taxonomy suggestions, comments, votes |
| `mod` | 44 | moderation queues and actions for galleries, comments, taxonomy, users, API keys, IP lookup |
| `user` | 14 | profile, account deletion, blacklist, favorites, API keys, reports |
| `zones` | 4 | ad-zone inventory and hit recording |
| `gts` | 2 | the global tag-suggestion index: backlog and newly minted tags |

Every published operation has a method, including the six `Admin *` account administration routes
under `/moderation/users`: `mod.search_users(q)`, `mod.reset_user_about`, `mod.reset_user_avatar`,
`mod.set_user_password(user_id, password)`, `mod.send_password_reset` and
`mod.set_username(user_id, username)`, alongside the corrected
`mod.delete_account(user_id, confirmation)`.

Those are the only methods here that reach *another* account's identity or credentials, so they are
not passive wrappers. The API reserves all of them for an account with administrator rights (see
[Authentication](#authentication)); `delete_account` refuses to run unless `confirmation` is the
target's exact username, and `set_user_password` hands over a credential rather than minting one.

For the caller's own account, use `user.delete_account(password, confirmation)`: the API demands the
account password *plus* the username, and deletion cannot be undone.

A few details on the list methods:

- `galleries.list_galleries(page=, per_page=)`, `paginate_galleries()`, `galleries.list_tagged(tag_id, sort=, page=, per_page=)`, `paginate_tagged(tag_id)`
- `galleries.search(query, sort=, page=)` and `paginate_search(query)`; `search` takes no `per_page` because the upstream `/search` route ignores it and pages 25 items at a time
- `galleries.get_popular()` decodes the bare array of `GalleryListItem`; `galleries.get_random()` returns an ID only (`RandomGallery`), so fetch the gallery separately
- `galleries.get_suggestions(gallery_id, tier=, limit=)` / `create_suggestion` / `vote_on_suggestion` / `withdraw_suggestion`, and `galleries.submit_edit` (retired upstream: tag changes go through suggestions)
- `gts.list_backlog(page=, per_page=, tag_id=, action=, sort_by=, sort=)` and `gts.list_new_tags(limit=)`
- `tags.search(q, sort=, page=, limit=)`: `limit` defaults to 24; pass another value, or `limit=None` to omit the field and take the server default
- `user.flag_user(user_id, category, reason=)` and `mod.review_user_flag(flag_id, action)`
- `zones.record_popunder_hit(name=, type=, user_agent=, tor_session=, record=)` for ad events

Nothing except the endpoint itself is hard-coded: page, limit, sort and filter pass through as given,
and an unset value is left out of the request instead of being replaced with a client default. Two
validators remain on the client side, and both raise `ValueError` before the request goes out rather
than rewriting your input: `per_page` above 100 on `list_galleries`/`list_tagged`, and more than 100
IDs in `tags.get_by_ids`.

### Method Reference

Every public method in source order, with the arguments it takes, the model it returns, the route it
calls and the credential the API asks for. `wraps X` is a helper that drives another method instead of
sending its own request. Signatures are the async client's; on `NHentai` they are the same without
`await`, and `AsyncPaginator` is spelled `SyncPaginator`. `the configured base URL` is the API root
itself, and `staff account` / `admin account` mark the routes the API reserves for those accounts.

The table is the index; the docstring is the reference. Every method on every sub-client documents
what it does, what each argument means, the model it returns, the errors it raises and the rate limit
the API publishes for the route. Read one with `help(nh.galleries.get_gallery)`, or hover it in your
editor.

#### `system` - 5 methods

| Method | HTTP | Route | Auth |
| --- | --- | --- | --- |
| `get_root() -> ApiRoot` | GET | the configured base URL | public |
| `get_pow(action=) -> PowChallenge` | GET | `/pow` | public |
| `get_cdn_config() -> CdnConfig` | GET | `/cdn` | public |
| `get_config() -> Config` | GET | `/config` | public |
| `get_captcha_info(provider) -> CaptchaInfo` | GET | `/captcha` | public |

#### `auth` - 9 methods

| Method | HTTP | Route | Auth |
| --- | --- | --- | --- |
| `login(pow_challenge=, pow_nonce=) -> AuthResponse` | POST | `/auth/login` | public |
| `register(pow_challenge=, pow_nonce=) -> AuthResponse` | POST | `/auth/register` | public |
| `refresh(refresh_token=) -> AuthResponse` | POST | `/auth/refresh` | public |
| `logout(refresh_token=) -> EmptyResponse` | POST | `/auth/logout` | user token |
| `logout_all() -> EmptyResponse` | POST | `/auth/logout/all` | user token |
| `get_sessions(refresh_token=) -> Pagination[Session]` | GET | `/auth/sessions` | user token |
| `revoke_session(session_id) -> EmptyResponse` | DELETE | `/auth/sessions/{session_id}` | user token |
| `request_password_reset(pow_challenge=, pow_nonce=) -> EmptyResponse` | POST | `/auth/reset` | public |
| `confirm_password_reset(pow_challenge=, pow_nonce=) -> EmptyResponse` | POST | `/auth/reset/confirm` | public |

#### `galleries` - 25 methods

| Method | HTTP | Route | Auth |
| --- | --- | --- | --- |
| `get_gallery(gallery_id) -> Gallery` | GET | `/galleries/{gallery_id}` | public (optional user token or API key) |
| `get_related(gallery_id) -> Pagination[Gallery]` | GET | `/galleries/{gallery_id}/related` | public |
| `list_galleries(page=, per_page=) -> Pagination[Gallery]` | GET | `/galleries` | public (optional user token or API key) |
| `paginate_galleries(page=) -> AsyncPaginator[Gallery]` | - | wraps `list_galleries` | public (optional user token or API key) |
| `list_tagged(tag_id, sort=, page=, per_page=) -> Pagination[Gallery]` | GET | `/galleries/tagged` | public (optional user token or API key) |
| `paginate_tagged(tag_id, sort=, page=) -> AsyncPaginator[Gallery]` | - | wraps `list_tagged` | public (optional user token or API key) |
| `search(query=, sort=, page=) -> Pagination[Gallery]` | GET | `/search` | public (optional user token or API key) |
| `paginate_search(query=, sort=) -> AsyncPaginator[Gallery]` | - | wraps `search` | public (optional user token or API key) |
| `get_popular() -> Pagination[GalleryListItem]` | GET | `/galleries/popular` | public (optional user token or API key) |
| `get_random() -> RandomGallery` | GET | `/galleries/random` | public (optional user token or API key) |
| `get_download_url(gallery_id, format=) -> DownloadUrl` | POST | `/galleries/{gallery_id}/download` | user token |
| `download_to_file(gallery_id, path=, max_bytes=, overwrite=) -> None` | - | streams the CDN URL from `get_download_url` | public |
| `add_favorite(gallery_id) -> FavoriteStatus` | POST | `/galleries/{gallery_id}/favorite` | user token |
| `remove_favorite(gallery_id) -> FavoriteStatus` | DELETE | `/galleries/{gallery_id}/favorite` | user token |
| `check_favorite(gallery_id) -> FavoriteStatus` | GET | `/galleries/{gallery_id}/favorite` | user token or API key |
| `submit_edit(gallery_id, created_tags=, added_tags=, removed_tags=) -> SubmitEditResponse` | POST | `/galleries/{gallery_id}/edit` | staff account |
| `get_suggestions(gallery_id, tier=, limit=) -> Pagination[Suggestion]` | GET | `/galleries/{gallery_id}/suggestions` | public (optional user token or API key) |
| `create_suggestion(gallery_id, pow_challenge=, pow_nonce=) -> Suggestion` | POST | `/galleries/{gallery_id}/suggestions` | user token |
| `vote_on_suggestion(gallery_id, suggestion_id, vote=, pow_challenge=, pow_nonce=) -> Suggestion` | POST | `/galleries/{gallery_id}/suggestions/{suggestion_id}/vote` | user token |
| `withdraw_suggestion(gallery_id, suggestion_id) -> EmptyResponse` | DELETE | `/galleries/{gallery_id}/suggestions/{suggestion_id}` | user token |
| `get_comments(gallery_id, page=, per_page=) -> Pagination[Comment]` | GET | `/galleries/{gallery_id}/comments` | public (optional user token or API key) |
| `paginate_comments(gallery_id, per_page=) -> AsyncPaginator[Comment]` | - | wraps `get_comments` | public (optional user token or API key) |
| `create_comment(gallery_id, pow_challenge=, pow_nonce=) -> Comment` | POST | `/galleries/{gallery_id}/comments` | user token |
| `get_comment_count(gallery_id) -> CommentCount` | GET | `/galleries/{gallery_id}/comments/count` | public |
| `flag_comment(comment_id) -> EmptyResponse` | POST | `/comments/{comment_id}/flag` | user token |

#### `tags` - 6 methods

| Method | HTTP | Route | Auth |
| --- | --- | --- | --- |
| `list_tags(tag_type, sort=, page=, per_page=) -> Pagination[Tag]` | GET | `/tags/{tag_type}` | public |
| `get_tag(tag_id) -> Pagination[Tag]` | - | wraps `list_tags` | public |
| `get_tag_by_slug(tag_type, slug) -> Tag` | GET | `/tags/{tag_type}/{slug}` | public |
| `get_by_ids(ids) -> TagList` | GET | `/tags/ids` | public |
| `search(q=, sort=, page=, limit=) -> Pagination[Tag]` | POST | `/tags/search` | public |
| `paginate_search(q=, sort=) -> AsyncPaginator[Tag]` | - | wraps `search` | public |

#### `taxonomy` - 12 methods

The suggestion, comment and edit routes keep the upstream operation names. Each docstring spells out
its arguments and the model it returns.

| Method | HTTP | Route | Auth |
| --- | --- | --- | --- |
| `list_taxonomy_suggestions(tier=, page=, per_page=, q=, target_tag_id=, sort_by=, sort=, action=, discussion=, edited=) -> Pagination[Suggestion]` | GET | `/taxonomy` | public (optional user token or API key) |
| `create_taxonomy_suggestion(pow_challenge=, pow_nonce=) -> Suggestion` | POST | `/taxonomy` | user token |
| `get_taxonomy_suggestion_stats() -> SuggestionStats` | GET | `/taxonomy/stats` | public |
| `list_resolved_taxonomy_suggestions(status=, q=, discussion=, edited=, action=, sort_by=, sort=, page=, per_page=) -> Pagination[Suggestion]` | GET | `/taxonomy/resolved` | public (optional user token or API key) |
| `get_taxonomy_suggestion(suggestion_id) -> Suggestion` | GET | `/taxonomy/{suggestion_id}` | public (optional user token or API key) |
| `remove_taxonomy_suggestion(suggestion_id) -> EmptyResponse` | DELETE | `/taxonomy/{suggestion_id}` | user token |
| `edit_taxonomy_suggestion(suggestion_id) -> Suggestion` | PATCH | `/taxonomy/{suggestion_id}` | user token |
| `list_taxonomy_comments(suggestion_id, page=, per_page=) -> Pagination[TaxonomyComment]` | GET | `/taxonomy/{suggestion_id}/comments` | public (optional user token or API key) |
| `create_taxonomy_comment(suggestion_id, pow_challenge=, pow_nonce=) -> TaxonomyComment` | POST | `/taxonomy/{suggestion_id}/comments` | user token |
| `delete_taxonomy_comment(suggestion_id, comment_id) -> EmptyResponse` | DELETE | `/taxonomy/{suggestion_id}/comments/{comment_id}` | user token |
| `vote_on_taxonomy_suggestion(suggestion_id, vote=, pow_challenge=, pow_nonce=) -> Suggestion` | POST | `/taxonomy/{suggestion_id}/vote` | user token |
| `list_taxonomy_edits(suggestion_id) -> Pagination[SuggestionEdit]` | GET | `/taxonomy/{suggestion_id}/edits` | public |

#### `mod` - 44 methods

Staff and administrator routes, named after the upstream operations. Each docstring spells out its
arguments and the model it returns, and the `Auth` column says which account the route needs.

| Method | HTTP | Route | Auth |
| --- | --- | --- | --- |
| `lookup_ip(ip) -> IpLookupResponse` | GET | `/moderation/ip/{ip}` | staff account |
| `list_pending_suggestions(status=, q=, sort=, tag_type=, page=, per_page=) -> Pagination[Suggestion]` | GET | `/moderation/gts` | staff account |
| `accept_suggestion(suggestion_id) -> EmptyResponse` | POST | `/moderation/gts/{suggestion_id}/accept` | staff account |
| `reject_suggestion(suggestion_id) -> EmptyResponse` | POST | `/moderation/gts/{suggestion_id}/reject` | staff account |
| `revert_suggestion(suggestion_id) -> EmptyResponse` | POST | `/moderation/gts/{suggestion_id}/revert` | staff account |
| `moderation_create_tag() -> Tag` | POST | `/moderation/tags` | staff account |
| `accept_taxonomy_suggestion(suggestion_id) -> EmptyResponse` | POST | `/moderation/taxonomy/{suggestion_id}/accept` | staff account |
| `delete_taxonomy_suggestion(suggestion_id) -> EmptyResponse` | DELETE | `/moderation/taxonomy/{suggestion_id}` | admin account |
| `reject_taxonomy_suggestion(suggestion_id) -> EmptyResponse` | POST | `/moderation/taxonomy/{suggestion_id}/reject` | staff account |
| `delete_comment(comment_id) -> EmptyResponse` | DELETE | `/comments/{comment_id}` | user token |
| `search_users(q) -> UserSearchResponse` | GET | `/moderation/users` | admin account |
| `delete_account(user_id, confirmation) -> EmptyResponse` | DELETE | `/moderation/users/{user_id}` | admin account |
| `get_user_mod_info(user_id) -> UserModInfo` | GET | `/moderation/users/{user_id}` | staff account |
| `reset_user_about(user_id) -> SuccessResponse` | DELETE | `/moderation/users/{user_id}/about` | admin account |
| `reset_user_avatar(user_id) -> AvatarResetResponse` | DELETE | `/moderation/users/{user_id}/avatar` | admin account |
| `set_user_password(user_id, password) -> SuccessResponse` | PUT | `/moderation/users/{user_id}/password` | admin account |
| `send_password_reset(user_id) -> EmailSentResponse` | POST | `/moderation/users/{user_id}/password-reset` | admin account |
| `set_username(user_id, username) -> UsernameChangeResponse` | PUT | `/moderation/users/{user_id}/username` | admin account |
| `shadowban_user(user_id) -> EmptyResponse` | PUT | `/moderation/users/{user_id}/shadowban` | staff account |
| `unshadowban_user(user_id) -> EmptyResponse` | DELETE | `/moderation/users/{user_id}/shadowban` | staff account |
| `list_hidden_galleries(page=, per_page=) -> Pagination[Gallery]` | GET | `/moderation/galleries/hidden` | staff account |
| `get_gallery_mod_info(gallery_id) -> GalleryModInfo` | GET | `/moderation/galleries/{gallery_id}` | staff account |
| `hide_gallery(gallery_id) -> EmptyResponse` | PUT | `/moderation/galleries/{gallery_id}/hidden` | staff account |
| `unhide_gallery(gallery_id) -> EmptyResponse` | DELETE | `/moderation/galleries/{gallery_id}/hidden` | staff account |
| `review_comment_flag(flag_id, action=) -> EmptyResponse` | POST | `/comments/flags/{flag_id}/review` | staff account |
| `review_user_flag(flag_id, action) -> SuccessResponse` | POST | `/users/flags/{flag_id}/review` | staff account |
| `get_pending_flags(page=, per_page=, q=, hide_shadowbanned=) -> Pagination[CommentFlag]` | GET | `/moderation/flags` | staff account |
| `get_pending_edits(limit=) -> Pagination[SuggestionEdit]` | GET | `/moderation/edits` | staff account |
| `get_edit(edit_id) -> SuggestionEdit` | GET | `/moderation/edits/{edit_id}` | staff account |
| `vote_on_edit(edit_id) -> SuggestionEdit` | POST | `/moderation/edits/{edit_id}/vote` | staff account |
| `apply_edit(edit_id) -> SuggestionEdit` | POST | `/moderation/edits/{edit_id}/apply` | staff account |
| `reject_edit(edit_id) -> SuggestionEdit` | POST | `/moderation/edits/{edit_id}/reject` | staff account |
| `get_recent_comments(page=, per_page=, q=) -> Pagination[Comment]` | GET | `/moderation/comments/recent` | admin account |
| `get_spam_comments(page=, per_page=, q=) -> Pagination[Comment]` | GET | `/moderation/comments/spam` | admin account |
| `hide_comment(comment_id) -> EmptyResponse` | PUT | `/moderation/comments/{comment_id}/hide` | staff account |
| `unhide_comment(comment_id) -> EmptyResponse` | DELETE | `/moderation/comments/{comment_id}/hide` | staff account |
| `bulk_hide(comment_ids=) -> EmptyResponse` | POST | `/moderation/bulk/hide` | staff account |
| `bulk_unhide(comment_ids=) -> EmptyResponse` | POST | `/moderation/bulk/unhide` | staff account |
| `bulk_shadowban(user_ids=) -> EmptyResponse` | POST | `/moderation/bulk/shadowban` | staff account |
| `bulk_unshadowban(user_ids=) -> EmptyResponse` | POST | `/moderation/bulk/unshadowban` | staff account |
| `list_all_api_keys(page=, per_page=, sort=, has_purpose=, q=, key_id=) -> Pagination[ApiKey]` | GET | `/moderation/api-keys` | admin account |
| `revoke_api_key_admin(key_id) -> EmptyResponse` | DELETE | `/moderation/api-keys/{key_id}` | admin account |
| `get_spam_config() -> SpamConfig` | GET | `/moderation/spam/config` | staff account |
| `update_spam_config(name) -> SpamConfig` | PUT | `/moderation/spam/config/{name}` | staff account |

#### `user` - 14 methods

| Method | HTTP | Route | Auth |
| --- | --- | --- | --- |
| `get_me() -> User` | GET | `/user` | user token or API key |
| `update_profile() -> UpdateProfileResponse` | PUT | `/user` | user token |
| `delete_account(password, confirmation) -> DeleteProfileResponse` | DELETE | `/user` | user token |
| `upload_avatar() -> User` | POST | `/user/avatar` | user token |
| `list_api_keys() -> Pagination[ApiKey]` | GET | `/user/keys` | user token |
| `create_api_key(pow_challenge=, pow_nonce=) -> ApiKey` | POST | `/user/keys` | user token |
| `revoke_api_key(key_id) -> EmptyResponse` | DELETE | `/user/keys/{key_id}` | user token |
| `get_favorites(q=, page=) -> Pagination[Gallery]` | GET | `/favorites` | user token or API key |
| `get_random_favorite() -> RandomGallery` | GET | `/favorites/random` | user token or API key |
| `get_blacklist() -> Pagination[Tag]` | GET | `/blacklist` | user token or API key |
| `update_blacklist() -> EmptyResponse` | POST | `/blacklist` | user token or API key |
| `get_blacklist_ids() -> BlacklistIds` | GET | `/blacklist/ids` | user token or API key |
| `get_user_profile(user_id, slug) -> User` | GET | `/users/{user_id}/{slug}` | public (optional user token or API key) |
| `flag_user(user_id, category, reason=) -> SuccessResponse` | POST | `/users/{user_id}/flag` | user token |

#### `zones` - 4 methods

| Method | HTTP | Route | Auth |
| --- | --- | --- | --- |
| `get_zones(user_agent=, cf_ipcountry=) -> ZoneInventory` | GET | `/zones` | public |
| `get_popunder_inventory(user_agent=, cf_ipcountry=, tor_session=) -> ZoneInventory` | GET | `/zones/i` | public |
| `record_popunder_hit(user_agent=, tor_session=, name=, type=, record=) -> EmptyResponse` | POST | `/zones/h` | public |
| `popunder_redirect(name=, out=, user_agent=, tor_session=) -> PopunderRedirect` | GET | `/zones/pu` | public |

#### `gts` - 2 methods

| Method | HTTP | Route | Auth |
| --- | --- | --- | --- |
| `list_backlog(page=, per_page=, tag_id=, action=, sort_by=, sort=) -> Pagination[BacklogRow]` | GET | `/gts/backlog` | public (optional user token or API key) |
| `list_new_tags(limit=) -> Pagination[NewTagIndexEntry]` | GET | `/gts/new-tags` | public |

## Proof of Work and CAPTCHA

Eleven operations are gated behind a proof of work (`auth.login`, `auth.register`, both
password-reset steps, `galleries.create_comment`, `galleries.create_suggestion`,
`galleries.vote_on_suggestion`, `taxonomy.create_taxonomy_suggestion`,
`taxonomy.create_taxonomy_comment`, `taxonomy.vote_on_taxonomy_suggestion`, `user.create_api_key`).
Each takes a `pow_challenge` / `pow_nonce` pair, and the helpers mint both:

```python
from nhentaipy import AsyncNHentai, pow_solution

async with AsyncNHentai(token="...") as client:
    fields = await pow_solution(client, "gts_create")     # {"pow_challenge": ..., "pow_nonce": ...}
    await client.galleries.create_suggestion(1, **fields, json={"tag_id": 3, "action": "add"})
```

`sync_pow_solution(client, action)` is the blocking twin, and `solve_pow(challenge, difficulty, *,
start=0, max_attempts=DEFAULT_MAX_ATTEMPTS)` is the raw CPU primitive: SHA-256 over
`f"{challenge}{nonce}"`, stopping at the first nonce whose digest has at least `difficulty` leading
zero bits. `max_attempts` is yours to set and comes with a documented default rather than a hidden
ceiling; the search is deterministic and fails with a `RuntimeError` naming the budget instead of
returning a wrong nonce. A challenge minted for one action is rejected for another
(`403 Challenge difficulty does not match this action`).

Some operations also want a captcha. `system.get_captcha_info(provider)` reports the provider, but the
token itself has to come from a browser. At `difficulty` 0 the pair is an empty string: pass
`pow_challenge=""` and `pow_nonce=""`, which are sent, where `None` would be dropped.

## Rate Limits

Every client carries one token bucket that paces the API calls it makes, at `max_rate=120` per
`time_period=60` seconds unless you say otherwise. 120/min is twice the highest ceiling nhentai
documents for any credential, which keeps the client out of the way of the API's own limits instead
of becoming the binding constraint. Both numbers are constructor arguments
(`NHentai(max_rate=10, time_period=60)`), and `set_rate_limit(max_rate, time_period=None)` retunes a
live client, taking effect on the next request. The CDN host `galleries.download_to_file` streams
from is not the API, so those transfers do not go through the bucket.

A caller reserves its slot before sleeping, so after the first `max_rate` free calls a burst is spread
at exactly `time_period / max_rate` per call, and concurrent callers resume in order instead of
bunching up again after a full period. `examples/08_rate_limits.py` measures it: 5 calls at
`max_rate=3, time_period=1` finish in ≈0.67s, not `5 × 1s`.

A `429` comes back as `RateLimited`, whose `retry_after` carries the server's `Retry-After` (clamped
to 86400s). One attempt sleeps at most 60s for it, and every other call through the same client waits
that long too, so a burst cannot pile back onto an origin that just asked for quiet. A `Retry-After`
above 60s is not slept out: the client arms a 60s cooldown and raises instead. Per-endpoint numbers
live in each method's docstring, under `.. note:: Rate limits:`.

## Errors

```
NHentaiError
├── NHentaiConnectionError     transport-level failure after retries
├── DecodeError                the body is not the published schema
├── DownloadTooLarge           streamed download exceeded max_bytes
└── NHentaiAPIError            any non-2xx response: .status_code, .api_message, .retry_after
    ├── BadRequest    400        ├── ValidationError   422
    ├── Unauthorized  401        ├── RateLimited       429
    ├── Forbidden     403        └── ServerError       5xx
    ├── NotFound      404            ├── BadGateway        502
    ├── RequestTimeout 408          ├── ServiceUnavailable 503
    ├── Conflict      409           └── GatewayTimeout     504
    ├── Gone          410
    └── MisdirectedRequest 421
```

`DecodeError` is not an `NHentaiAPIError`. A 200 whose body has the wrong shape is a decoding
problem, not a request problem. `safe_decode_model` raises it with the payload attached (`.data`, or
`.response` for an HTTP response), so a schema change shows up where it happens instead of later as an
`AttributeError`.

## Troubleshooting

The exact server responses behind the failures people hit most, for comparing against
`exception.api_message`:

| Message | Cause |
| --- | --- |
| `Challenge difficulty does not match this action` (403) | PoW challenge minted for a different `action` than the call |
| `Challenge expired or already used` (403) | challenge reused, or solved too slowly; mint a fresh one per call |
| `Invalid CAPTCHA solution` (400) | the operation needs a captcha token from a browser (`auth.login` always does) |
| `Invalid proof of work solution` (400) | the `pow_nonce` does not solve the `pow_challenge` sent with it (wrong nonce, or a challenge from another `action`) |
| `{"error":"Validation error","details":["body -> captcha_response: Field required"]}` (400) | a body field the client deliberately does not invent: the server names it in `details` (which `api_message` collapses to `Validation error`); pass it via `json={...}` |
| `401` with credentials set | token/key passed to the wrong argument, or a client whose `update_token` was never called after a refresh |

## Examples

`examples/` has twelve scripts you can run. Each accepts `--dry-run`, which prints the exact requests
it would send without touching the network:

| Script | What it shows |
| --- | --- |
| `01_quickstart_async.py` | the async client end to end: `galleries.list_galleries`, the decoded `GalleryListItem` models, and the page/total fields on `Pagination` |
| `02_quickstart_sync.py` | the same call through the blocking client |
| `03_pagination.py` | walking `Pagination` pages by hand, then the `paginate_search` iterator across pages |
| `04_download.py` | `galleries.get_download_url` and `galleries.download_to_file`: the `--format` choices, the `--max-mb` cap, and the atomic `*.part` write |
| `05_auth_and_pow.py` | `system.get_pow` to a solved nonce to `auth.login`, the credential matrix, and an authenticated `user.get_me` |
| `06_reporting.py` | the writing half: `galleries.flag_comment` and `taxonomy.create_taxonomy_suggestion` with a solved proof of work |
| `07_endpoint_tour.py` | one read of every subclient (`system`, `auth`, `galleries`, `tags`, `taxonomy`, `gts`, `user`, `mod`, `zones`), with the credentialed sections gated on the token environment variables |
| `08_rate_limits.py` | the client-side bucket: a small budget paced in wall time, sequentially and with `asyncio.gather`, then the same burst after `set_rate_limit` retunes a live client |
| `09_search_and_filters.py` | search and the paginated listings: `list_galleries`, `search`/`paginate_search`, `list_tagged`/`paginate_tagged`, `get_popular`, `get_random`, and the client-side `per_page` cap |
| `10_tags_taxonomy_and_zones.py` | the metadata subclients: `tags` lookup and search, `taxonomy` suggestions and votes, the `zones` ad inventory, and the `gts` backlog |
| `11_account_and_favorites.py` | the account surface: profile, favorites, API keys, sessions and blacklist, then one favorite added and removed so the account ends the run as it started |
| `12_errors_and_retries.py` | the failure surface against a mocked transport: retries, `Retry-After`, the exception hierarchy, and the oversized-download abort |

```bash
python examples/01_quickstart_async.py
python examples/05_auth_and_pow.py --dry-run
python examples/11_account_and_favorites.py --dry-run
python examples/12_errors_and_retries.py
```

## Known Limitations

- The limiter gates the moment a request is sent, so a request already parked in httpx's connection
  pool can still go out just after a penalty starts. Fixing that would mean serialising the send.
- `follow_redirects` is a client-level setting and is off by default. A per-request value can only pin
  redirects off; opting in per request is refused, so a credential in a custom `api_key_header`, or in
  caller `headers=`, cannot cross an origin boundary.
- `galleries.download_to_file` fetches whatever host `get_download_url` returns. Pass
  `download_hosts=[...]` to restrict it to an allowlist (the hosts `system.get_cdn_config()` reports,
  for example). Non-http(s) URLs, and URLs with embedded credentials, are always refused. The archive
  goes to a `*.part` temp file and is `os.replace`d into place, so an interrupted download never
  leaves a partial file behind. There is no resume or Range support and no progress callback, and the
  API does not report the archive's content length up front.
- `galleries.submit_edit` and the `mod` routes are reserved for staff and administrator accounts, and
  the API takes only the `User` and `Key` schemes, so you reach them with a `token=` whose account has
  the rights. See [Authentication](#authentication).
- No headless login. See [Authentication](#usernamepassword-login-is-not-usable-headlessly).

## License

MIT License.
