H2HDB Downloader (h2hdb-downloader)
Automates downloading galleries from exhentai/e-hentai (via hbrowser) and
recording their state in an h2hdb database. It has no CLI or standalone
runtime of its own — it's a library consumed by another project that owns
the browser session and the overall process lifecycle.
Concepts
- Gallery — a single exhentai/e-hentai gallery, identified by a
gid(numeric id) and represented ash2h_galleryinfo_parser.GalleryURLParseronce its URL is known. - Dedup — before issuing a real network download, the package reads live h2hdb state to see if the gid is already settled (downloaded, with no redownload flag or durable request). Settled gids are skipped — except periodically, at a random interval (1 to 19 attempts), when one is force-redownloaded as an integrity re-check.
- Durable requests — immediately before a real download starts, the
package creates a tokenized request in h2hdb's
todownload_gidstable. It conditionally completes that exact token only after success. AFalseresult, exception, cancellation, or process termination leaves resumable work behind, while a newer request for the same gid cannot be erased by an older attempt finishing late. h2hdb also uses this table to publish a redownload request after all active deletion-candidate folders for a gid have actually disappeared. For a deep root job, success means that the root gallery has resolved and its entire related-tag cascade has returned successfully; the root request remains queued throughout that traversal. Its exact-token deletion and download-to-ingest handoff then occur in one atomic h2hdb transaction. A late worker that has lost its turn therefore cannot delete the recovered root request. If another caller has replaced the request token while the turn is still valid, that newer request remains queued while the completed turn still hands off successfully. A GID is recorded as removed only from hbrowser's explicitConfirmedGalleryMissingresult, never by interpreting an empty or malformed page. For a coordinated root, the removed marker, exact-token deletion, and fenced handoff are one transaction. The marker and deletion occur only if that exact request token is still current; a newer request fences both mutations. - Database coordination — every short h2hdb read/write section enters h2hdb's cross-process maintenance gate with a five-minute wait interval. Browser search, downloads, retry sleeps, and tag traversal stay outside the gate, so maintenance is never blocked by network work.
- Ingest backpressure — each public deep-download root claims h2hdb's
durable download turn before doing network work. An asynchronous heartbeat
renews its lease while the complete related-tag cascade runs. On success,
a resolved root atomically finishes its exact request and requests ingest.
A confirmed-missing root atomically records the removed marker, finishes
its exact request, and requests ingest only while that token remains current;
the handoff still succeeds without those mutations when a newer token exists.
An unresolved, failed, or cancelled root requests ingest without removing its
durable request. Once this generic handoff commits, a later finish replay
cannot convert it into a success or missing mutation. If the exception
handoff is rejected because the turn was lost, the downloader raises
DownloadTurnLostErrorwith the original failure as its cause. After a normal root return, the downloader waits until h2hdb has completed that turn's generation before another root may start. If the process is killed, the lease expires so h2hdb can ingest files already written to disk. This coordination is logical state made of short database calls: it never holds a transaction or database maintenance gate across browser work. SQLite may temporarily reportBUSYorLOCKEDwhile another h2hdb process holds the exclusive lock needed byVACUUM; only the ready-turn claim and completed-generation polling boundaries retry those lock codes atturn_poll_seconds. Other SQLite errors and all non-polling operation failures still propagate immediately. - Manual queue — add a
(gid, url)row to the CSV configured bycsv_path. It is converted into the same durable request and picked up the next time the queue is drained. Before replay, the inbox is atomically rotated to a same-directory hidden claim file; interrupted claims are replayed automatically on the next run. - Deep download — download a gallery, then look at its
artist/grouptags and download sibling galleries that match a set of search conditions (e.g. other-language releases of the same work).
API
Downloader is the public service object. TagCascadePolicy and
DownloadTurnLostError are the other public exports. Every method either acts
on a target you explicitly pass in or, for the two queue-reading methods below,
hands back a plain value with no further bookkeeping required from you.
There is no "run the whole thing" method: deciding when to stop, what order
to process things in, and how to report progress is the calling
application's job, not the library's.
Downloader(
driver: ExHDriver, # an un-entered driver; see below
config_path: str, # path to the h2hdb JSON config
csv_path: str | None = None, # path to the manual download-queue CSV
*,
wait4client: int, # seconds to wait before retrying after ClientOfflineException
retry2download: int, # seconds to wait before retrying after InsufficientFundsException
turn_poll_seconds: float = 5, # wait interval for a turn / ingest completion
turn_lease_seconds: int = 300, # recoverable ownership lease
turn_heartbeat_seconds: float = 60, # renewal interval; shorter than the lease
)
csv_path only enables the optional "queue a gid/url by editing a CSV file"
feature described above. Leave it as None if you don't need that; durable
database requests and live deduplication still work.
The turn timing defaults normally need no adjustment. All three values must be
positive and finite, turn_lease_seconds must be an integer, and the heartbeat
interval must be shorter than the lease.
Coordinated methods raise DownloadTurnLostError if their lease can no longer
be renewed or the conditional handoff proves that another process owns the
turn. Callers may catch it separately from browser/download failures; the
durable root request for unfinished work remains available for a later retry.
Downloader is itself an async context manager that opens and closes the
browser session for you, so driver is expected un-entered:
async with Downloader(ExHDriver(headless=False), ...) as downloader:
...
If you'd rather manage the driver's lifecycle yourself, pass an
already-entered driver and skip async with downloader.
Method names follow one rule throughout: no suffix means it operates
directly on a GalleryURLParser you already have; _by_gid means it
resolves a bare gid through hbrowser's exact typed lookup first, then does the same
thing.
await download_by_gallery(target)— download oneGalleryURLParser, or an iterable of them. Returns{gid: downloaded}for each. Retries automatically onClientOfflineException(waitswait4clientseconds) andInsufficientFundsException(waitsretry2downloadseconds); a wait of0means "don't retry, raise immediately." This is a direct API and does not claim a download turn or wait for h2hdb ingest.await download_by_gid(gid)— resolve a bare gid through hbrowser's exact lookup, then download it. Only an explicit, independently confirmed missing result is recorded as removed in h2hdb; challenge, authentication, malformed, pagination, navigation, and bounded-search failures raise and leave the request retryable. A later successful lookup clears any stale removed marker. If the gid resolves to a different gid (the gallery was merged/redirected), the original gid is flagged for deletion after the replacement downloads successfully. This is also a direct, uncoordinated API.await download_by_tag(tag, conditions)— download every gallery under ahbrowserTag, once per search condition inconditions(or unconditionally ifconditionsis empty). This is also a direct, uncoordinated API.await deep_download_by_gallery(gallery, policy, skip_check=False)— downloadgallery, then for each tag inpolicy.filters(e.g."artist","group") on that gallery, calldownload_by_tagwithpolicy.conditions. The cascade only runs if the initial download actually happened, unlessskip_check=Trueforces it to run regardless (useful when you already know the gallery is downloaded from a separate call and just want the cascade).policyis aTagCascadePolicy(filters, conditions)— both fields always travel together, so they're grouped into one frozen value object rather than two parallel parameters. The whole call is one coordinated root: it claims a turn, keeps its durable root request until the cascade finishes, atomically finishes the exact request while handing the turn to h2hdb, and waits for that generation to be ingested.await deep_download_by_gid(gid, policy, skip_check=False)— same gid-resolution asdownload_by_gid, but deep and coordinated as one root.await drain_queue(policy, skip_check=True)— absorb the manual CSV and process one live snapshot of durable database requests. A request is removed only after a successful root and complete related-tag cascade, confirmed removal, or successful redirect. Each request receives its own download turn and h2hdb ingest wait before the next snapshot entry begins. A URL-to-gid fallback remains part of the same turn. The method doesn't loop for newly queued work after its snapshot.pending_redownload_gids()— a snapshot list of gids h2hdb currently flags as needing a periodic redownload. Every call reads live database state; read-only and safe to call repeatedly as you work through it.
Example
The calling application owns the loop. A typical one drains the queue once, then walks the pending-redownload list, deep-downloading anything that actually got (re)downloaded. The queue drain and each deep download in that pending loop apply ingest backpressure between root jobs:
import asyncio
from h2hdb_downloader import Downloader, TagCascadePolicy
from hbrowser import ExHDriver
from h2h_galleryinfo_parser import GalleryURLParser
policy = TagCascadePolicy(
filters=("artist", "group"),
conditions=("language:chinese$", "language:speechless$"),
)
async def main():
async with Downloader(
ExHDriver(headless=True),
config_path="h2hdb-config.json",
csv_path="todownload_gids.csv",
wait4client=30 * 60,
retry2download=4 * 60 * 60,
) as downloader:
gallery = GalleryURLParser("https://exhentai.org/g/123/456/")
await downloader.download_by_gallery(gallery)
await downloader.download_by_gid(666)
await downloader.deep_download_by_gallery(gallery, policy)
await downloader.drain_queue(policy, skip_check=True)
for gid in downloader.pending_redownload_gids():
await downloader.deep_download_by_gid(gid, policy, skip_check=True)
asyncio.run(main())
License
This project is distributed under the terms of the GNU General Public Licence
(GPL). For detailed licence terms, see the LICENSE file included in this
distribution.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file h2hdb_downloader-0.5.0.tar.gz.
File metadata
- Download URL: h2hdb_downloader-0.5.0.tar.gz
- Upload date:
- Size: 44.6 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
bfb4496659c0c0f22254cc71c8c0f35a514f04aba8520c3efdaf559b7101f0bb
|
|
| MD5 |
9d80af6d368db8af229bcbbed6e4b7a1
|
|
| BLAKE2b-256 |
8d1368422ca6e15bd58d943d3702ba31d6261182e9d232a085f7074e9a428d0c
|
Provenance
The following attestation bundles were made for h2hdb_downloader-0.5.0.tar.gz:
Publisher:
publish.yml on Kuan-Lun/h2hdb-downloader
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
h2hdb_downloader-0.5.0.tar.gz -
Subject digest:
bfb4496659c0c0f22254cc71c8c0f35a514f04aba8520c3efdaf559b7101f0bb - Sigstore transparency entry: 2300354302
- Sigstore integration time:
-
Permalink:
Kuan-Lun/h2hdb-downloader@9618ce492b2e053497d5c14646068def701567b8 -
Branch / Tag:
refs/heads/master - Owner: https://github.com/Kuan-Lun
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@9618ce492b2e053497d5c14646068def701567b8 -
Trigger Event:
push
-
Statement type:
File details
Details for the file h2hdb_downloader-0.5.0-py3-none-any.whl.
File metadata
- Download URL: h2hdb_downloader-0.5.0-py3-none-any.whl
- Upload date:
- Size: 27.8 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
17db8241420e0206d47ceb6904e5815f0d726be5469ac50defd6179513805d86
|
|
| MD5 |
8145ed62ae6574c1b0a7f736795ef4cd
|
|
| BLAKE2b-256 |
73232e1335fafeff1fd9cc030165d4399730bcda9d8569ef35625f221646037c
|
Provenance
The following attestation bundles were made for h2hdb_downloader-0.5.0-py3-none-any.whl:
Publisher:
publish.yml on Kuan-Lun/h2hdb-downloader
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
h2hdb_downloader-0.5.0-py3-none-any.whl -
Subject digest:
17db8241420e0206d47ceb6904e5815f0d726be5469ac50defd6179513805d86 - Sigstore transparency entry: 2300354338
- Sigstore integration time:
-
Permalink:
Kuan-Lun/h2hdb-downloader@9618ce492b2e053497d5c14646068def701567b8 -
Branch / Tag:
refs/heads/master - Owner: https://github.com/Kuan-Lun
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@9618ce492b2e053497d5c14646068def701567b8 -
Trigger Event:
push
-
Statement type: