Compare commits

...
8 Commits
Author SHA1 Message Date
CaliBrain 5b3df2a463 docs(hardcover): list the API key scopes Shelfmark needs (#1243)
Hardcover's August 2026 token system replaced blanket access with
per-token scopes, and nothing in the docs said which ones Shelfmark
actually uses. A key missing write:library or write:lists still passes
Test Connection -- the reading-status and auto-remove-on-download calls
just fail silently afterwards.

Verified against a live hc_pat_ key: every scope in the table backs a
query or mutation the provider really issues, and the omitted ones
(journal, goals, reviews, prompts, notifications, account) are absent
from the provider entirely.

Refs #1240
2026-08-20 19:29:55 -04:00
CaliBrain 5247ec6124 fix(bypass): close the gaps a helper that outlives its request opened (#1244)
assumptions the code around it still made were written for a helper that
was killed after every request.

A bypass that hits the child's deadline is cancelled from the calling
thread, which returns the moment the cancellation is scheduled - so the
helper went on to serve the next request while the abandoned one was
still closing its browser, on the same loop, sharing the DISPLAY globals
and one process group. The deadline now lives inside the loop, where
asyncio.wait_for() waits for the unwind before it raises, with the
calling thread keeping a bounded backstop in case the cleanup wedges
too. Both budgets are set so the child still answers before the parent
gives up on it.

The helper's cookie store survived the request as well, and the whole of
it is exported back to the parent on every answer - so clearance the
parent had purged for one host came back the next time some other host
was solved, the dead-cookie resurrection _redirect_loop_handoff purges
to avoid. The child starts each request from an empty store again; the
parent already runs the cached-cookie check against a superset of it.

DNS config is compared against what the helper is actually resolving
through rather than skipped whenever the parent reports "auto", so a
user flipping CUSTOM_DNS back to auto - which applies live - reaches a
warm helper instead of leaving it on an abandoned DoH resolver.

The 15s exit grace is now asked only of a helper that can still read its
stdin. One dropped mid-bypass never returns to that read, so the grace
could only end in the kill - while a user cancelling a download, and
every bypass queued behind them on LOCKED, waited it out.

Result files are cleaned on the timeout and cancellation paths too,
staging file included, rather than only when the answer was read.
2026-08-20 19:29:33 -04:00
CaliBrain bd21ec1257 fix(audiobookbay): search the ASCII punctuation ABB actually stores (#1242)
WordPress texturizes punctuation on output only, so a post stored as
"The
Stranger's Wife" renders as "The Stranger’s Wife". ABB's search matches
the
stored value and ANDs its terms, so one typographic character in the
query
empties the entire result set rather than merely ranking worse. Book
metadata
and mobile keyboards both hand us those characters.

Map curly quotes, dashes and ellipses to ASCII before a query goes out,
and on
both sides of the relevance comparison, since scraped titles carry the
rendered
forms. Release titles are still stored and displayed exactly as ABB
renders
them; only matching normalizes.

Also percent-encode the search query properly. The hand-rolled encoder
only
escaped double quotes and spaces, so a bare "&" started a new query
parameter
and silently truncated the search: "detective dan riley books 1 & 2
weatherley"
reached ABB as "detective dan riley books 1" and returned six
confident-looking
results without the requested book among them. "%" and "+" were mangled
too.
2026-08-20 19:00:27 -04:00
helgehelge123andhelgehelge123 7b9c416df8 perf(bypass): keep the helper subprocess alive between bypasses (#1222)
Every protected request spawns a fresh helper subprocess, paying
interpreter start and imports before any work begins. Measured inside
the container, five consecutive runs of `python -c "import
shelfmark.bypass.internal_bypasser"`:

```
3.53s  3.45s  3.55s  3.54s  3.46s
```

A single search issues several protected requests, so that is paid
several times over per search.

## What changed

The helper now serves one JSON request per line of stdin until the
parent closes the pipe, and an idle timer
(`BYPASS_BROWSER_IDLE_TIMEOUT`, default 180s) shuts it down once
searching stops.

Answers still travel by result file, but the file is now written to a
`.part` path and renamed into place — the parent treats the file's
existence as the answer, so it must never observe a half-written one.
stdout and stderr stay attached to the parent's, so helper logs keep
appearing in `docker logs` exactly as before.

Failure handling, since a warm helper is exposed to more of it than a
per-request one ever was:

| Situation | Handling |
| --- | --- |
| Helper died between requests | Detected via `poll()`, respawned |
| Pipe broken at write time (`poll()` can miss this) | One retry on a
fresh process; a fresh one failing there is a real failure |
| Helper exits without writing a result | `RuntimeError` naming the exit
code |
| Wedged past the timeout, or cancelled mid-bypass | Helper killed, then
`_cleanup_orphan_processes` because a killed helper never got to close
Chrome |
| Idle reaper racing an arriving request | Re-checks the deadline under
the lock and re-arms instead of killing a helper that just did work |

The DNS config now travels with every request rather than only at spawn:
a warm helper outlives changes the parent makes to its provider.

## `BYPASS_REUSE_BROWSER`, off by default

This parks the CDP driver between bypasses. A driver's websockets are
bound to the loop that opened them and cannot outlive their process, so
the persistent helper is what makes this possible at all — and the warm
path runs on `_CDP_WORKER`'s long-lived loop rather than `asyncio.run`
for the same reason.

The mechanism works. With it on, the browser start disappears from the
second request onward: 0.7s from `Reusing warm Chrome browser` to the
first bypass attempt, against roughly 16s cold.

**It still ships off, because a matched-pair test shows it is a net loss
against DDoS-Guard.** Each round primed with one cold bypass, waited
10s, then measured a second — identical timing in both arms, only the
browser strategy differing, order balanced (fresh, warm, warm, fresh) so
drift over the session cannot masquerade as an effect:

| Arm | Measured request |
| --- | --- |
| fresh browser | 42.8s, 40.6s |
| warm browser | 57.1s, 59.6s |

Spread within each arm is 2.2s and 2.5s, against 16.7s between them.
Reuse removes the ~15s browser start and then gives back roughly twice
that in solving: a returning browser draws a harder challenge. Where the
cold browser is through on the second bypass method, the warm one fails
the first three and only `_bypass_method_humanlike` gets it, at ~30s for
that method alone.

Worth separating from a second effect I ran into while measuring: five
back-to-back searches slow from ~32s to 51–98s with reuse **disabled**
as well, so DDoS-Guard escalates on request rate independently of any of
this. That is why the pairs above are timed identically rather than
simply run in sequence. It is the larger of the two effects, but not
something this project can patch around.

Reuse is left available rather than dropped because Cloudflare sites may
not respond the same way, and because the two concerns are independent:
the helper start is pure overhead and always worth removing, the browser
is not.

## Verification

- 2559 unit tests pass (2542 before, 17 added in
`tests/bypass/test_warm_browser.py`)
- `ruff check`, `ruff format`, `basedpyright` over backend and tests,
and `vulture` all clean
- `docs/environment-variables.md` regenerated via
`scripts/generate_env_docs.py`
- Live against Anna's Archive on a warm helper: searches return their
usual ~760KB and 667 results, the app's own search warm-up completes
with 50 results, and the container is left with no orphan
chrome/Xvfb/ffmpeg processes

Happy to drop the `BYPASS_REUSE_BROWSER` half entirely if you would
rather not carry a default-off path — the helper persistence stands on
its own.

Co-authored-by: helgehelge123 <helge.neumann@zollsoft.de>
2026-08-20 19:00:06 -04:00
CaliBrain 646b531669 fix(hardcover): accept the short hc_pat_ keys Hardcover issues now (#1241)
Hardcover replaced its ~500 char JWTs with short opaque personal access
tokens ("hc_pat_..."), and the connection test rejected anything under
100 chars before a request ever left Shelfmark, so every newly created
key failed with "API key seems too short".

The length floor now applies only to keys without the hc_pat_ prefix; a
prefixed key goes straight to Hardcover, which is the authority on
whether it is valid. Also strip a pasted "bearer " prefix regardless of
casing -- Hardcover's docs tell users to paste the token into an
"authorization" header, so the prefix rides along on the copy, and the
old case-sensitive removeprefix() sent it through as part of the token.
The API key field now names the expected shape.

Note that Hardcover's PAT path currently answers every hc_pat_ token
with a 500, a fabricated one included, while non-PAT tokens still get a
clean 401. So a new key cannot connect yet regardless of this change --
that failure is server-side and not something this code can reach.

Refs #1240
2026-08-20 14:42:45 -04:00
dependabot[bot] eafb965662 build(deps): bump qbittorrent-api from 2026.8.0 to 2026.8.1 in the python-deps group (#1236)
Bumps the python-deps group with 1 update:
[qbittorrent-api](https://github.com/rmartin16/qbittorrent-api).

Updates `qbittorrent-api` from 2026.8.0 to 2026.8.1
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/rmartin16/qbittorrent-api/releases">qbittorrent-api's
releases</a>.</em></p>
<blockquote>
<h2>release-2026.8.1</h2>
<h2>What's Changed</h2>
<h3>Features</h3>
<ul>
<li>feat: add missing <code>torrents/add</code> and
<code>torrents/reannounce</code> parameters by <a
href="https://github.com/rmartin16"><code>@​rmartin16</code></a> in <a
href="https://redirect.github.com/rmartin16/qbittorrent-api/pull/656">rmartin16/qbittorrent-api#656</a></li>
<li>feat: add endpoints shipped in qBittorrent v5.2.x by <a
href="https://github.com/rmartin16"><code>@​rmartin16</code></a> in <a
href="https://redirect.github.com/rmartin16/qbittorrent-api/pull/658">rmartin16/qbittorrent-api#658</a></li>
</ul>
<h3>Fixes</h3>
<ul>
<li>fix: accept and forward <code>**kwargs</code> on all API methods by
<a href="https://github.com/rmartin16"><code>@​rmartin16</code></a> in
<a
href="https://redirect.github.com/rmartin16/qbittorrent-api/pull/655">rmartin16/qbittorrent-api#655</a></li>
<li>fix: send <code>seedMode</code> for <code>is_skip_checking</code> on
Web API v2.16.0 by <a
href="https://github.com/rmartin16"><code>@​rmartin16</code></a> in <a
href="https://redirect.github.com/rmartin16/qbittorrent-api/pull/654">rmartin16/qbittorrent-api#654</a></li>
<li>docs: correct version annotations by <a
href="https://github.com/rmartin16"><code>@​rmartin16</code></a> in <a
href="https://redirect.github.com/rmartin16/qbittorrent-api/pull/657">rmartin16/qbittorrent-api#657</a></li>
</ul>
<h3>Chores</h3>
<ul>
<li>Bump cryptography from 48.0.1 to 50.0.0 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/rmartin16/qbittorrent-api/pull/652">rmartin16/qbittorrent-api#652</a></li>
<li>retire codeql by <a
href="https://github.com/rmartin16"><code>@​rmartin16</code></a> in <a
href="https://redirect.github.com/rmartin16/qbittorrent-api/pull/660">rmartin16/qbittorrent-api#660</a></li>
<li>remove xfail for previous python 3.15 issue by <a
href="https://github.com/rmartin16"><code>@​rmartin16</code></a> in <a
href="https://redirect.github.com/rmartin16/qbittorrent-api/pull/638">rmartin16/qbittorrent-api#638</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/rmartin16/qbittorrent-api/compare/v2026.8.0...v2026.8.1">https://github.com/rmartin16/qbittorrent-api/compare/v2026.8.0...v2026.8.1</a></p>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/rmartin16/qbittorrent-api/blob/main/CHANGELOG.md">qbittorrent-api's
changelog</a>.</em></p>
<blockquote>
<h3>v2026.8.1 (16 aug 2026)</h3>
<ul>
<li>Add support for <code>app/rotateAPIKey</code> and
<code>app/deleteAPIKey</code> (<a
href="https://redirect.github.com/rmartin16/qbittorrent-api/issues/658">#658</a>)</li>
<li>Add support for <code>torrents/SSLParameters</code> and
<code>torrents/setSSLParameters</code> (<a
href="https://redirect.github.com/rmartin16/qbittorrent-api/issues/658">#658</a>)</li>
<li>Add support for <code>torrents/fetchMetadata</code>,
<code>torrents/parseMetadata</code>, and
<code>torrents/saveMetadata</code> (<a
href="https://redirect.github.com/rmartin16/qbittorrent-api/issues/658">#658</a>)</li>
<li>Add support for <code>torrents/pieceAvailability</code> (<a
href="https://redirect.github.com/rmartin16/qbittorrent-api/issues/658">#658</a>)</li>
<li>Add support for <code>clientdata/load</code> and
<code>clientdata/store</code> (<a
href="https://redirect.github.com/rmartin16/qbittorrent-api/issues/658">#658</a>)</li>
<li>Add <code>file_priorities</code> and <code>downloader</code> for
<code>torrents/add</code> (<a
href="https://redirect.github.com/rmartin16/qbittorrent-api/issues/656">#656</a>)</li>
<li>Add <code>urls</code> for <code>torrents/reannounce</code> (<a
href="https://redirect.github.com/rmartin16/qbittorrent-api/issues/656">#656</a>)</li>
<li>Fix <code>is_skip_checking</code> for <code>torrents/add</code>
being ignored by qBittorrent v5.3.0 (<a
href="https://redirect.github.com/rmartin16/qbittorrent-api/issues/654">#654</a>)</li>
<li>Fix missing <code>**kwargs</code> for several endpoints (<a
href="https://redirect.github.com/rmartin16/qbittorrent-api/issues/655">#655</a>)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/rmartin16/qbittorrent-api/commit/97e5f577df0bcfd4a7986c79e8d21e88ae8e64d0"><code>97e5f57</code></a>
bump to v2026.8.1 (<a
href="https://redirect.github.com/rmartin16/qbittorrent-api/issues/661">#661</a>)</li>
<li><a
href="https://github.com/rmartin16/qbittorrent-api/commit/39e05d109dbb427b1e75a33fc96903ee22e3abd3"><code>39e05d1</code></a>
remove xfail for previous python 3.15 issue (<a
href="https://redirect.github.com/rmartin16/qbittorrent-api/issues/638">#638</a>)</li>
<li><a
href="https://github.com/rmartin16/qbittorrent-api/commit/0c067fdd356b3c2da0cdfbc1501c3badeb697e22"><code>0c067fd</code></a>
feat: add endpoints shipped in qBittorrent v5.2.x (<a
href="https://redirect.github.com/rmartin16/qbittorrent-api/issues/658">#658</a>)</li>
<li><a
href="https://github.com/rmartin16/qbittorrent-api/commit/64f65eacb62d33b54449ef413047779f6d766022"><code>64f65ea</code></a>
fix: send seedMode for is_skip_checking on Web API v2.16.0 (<a
href="https://redirect.github.com/rmartin16/qbittorrent-api/issues/654">#654</a>)</li>
<li><a
href="https://github.com/rmartin16/qbittorrent-api/commit/f302ac473a66d3943878bf8491080f3ab800264e"><code>f302ac4</code></a>
docs: correct version annotations (<a
href="https://redirect.github.com/rmartin16/qbittorrent-api/issues/657">#657</a>)</li>
<li><a
href="https://github.com/rmartin16/qbittorrent-api/commit/cd9650277f572d26b1c2c09ff8e86a9917cfe435"><code>cd96502</code></a>
feat: add missing torrents/add and torrents/reannounce parameters (<a
href="https://redirect.github.com/rmartin16/qbittorrent-api/issues/656">#656</a>)</li>
<li><a
href="https://github.com/rmartin16/qbittorrent-api/commit/7ed08865b11e6f57c7ef0395e8d929c8842de340"><code>7ed0886</code></a>
fix: accept and forward **kwargs on all API methods (<a
href="https://redirect.github.com/rmartin16/qbittorrent-api/issues/655">#655</a>)</li>
<li><a
href="https://github.com/rmartin16/qbittorrent-api/commit/0b293e28356e74b3aead64ac3fd1e02e85d5de87"><code>0b293e2</code></a>
retire codeql (<a
href="https://redirect.github.com/rmartin16/qbittorrent-api/issues/660">#660</a>)</li>
<li><a
href="https://github.com/rmartin16/qbittorrent-api/commit/fd276e2d918c31d78528acc280fbbd36b354cf88"><code>fd276e2</code></a>
Bump cryptography from 48.0.1 to 50.0.0 (<a
href="https://redirect.github.com/rmartin16/qbittorrent-api/issues/652">#652</a>)</li>
<li>See full diff in <a
href="https://github.com/rmartin16/qbittorrent-api/compare/v2026.8.0...v2026.8.1">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=qbittorrent-api&package-manager=uv&previous-version=2026.8.0&new-version=2026.8.1)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore <dependency name> major version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's major version (unless you unignore this specific
dependency's major version or upgrade to it yourself)
- `@dependabot ignore <dependency name> minor version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's minor version (unless you unignore this specific
dependency's minor version or upgrade to it yourself)
- `@dependabot ignore <dependency name>` will close this group update PR
and stop Dependabot creating any more for the specific dependency
(unless you unignore this specific dependency or upgrade to it yourself)
- `@dependabot unignore <dependency name>` will remove all of the ignore
conditions of the specified dependency
- `@dependabot unignore <dependency name> <ignore condition>` will
remove the ignore condition of the specified dependency and ignore
conditions


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-20 10:49:39 -04:00
CaliBrain 7193036626 fix(rtorrent): apply the audiobook label to audiobook downloads (#1239)
add_download() picks self._audiobook_label from a content_type kwarg,
but the only call site never passed one, so is_audiobook was always
False and every download got RTORRENT_LABEL. category does not fill
the gap: _get_category_for_task() returns None for rTorrent, which has
no category concept, leaving content_type as its only audiobook signal.

Pass task.content_type through from base_handler, and match it with the
shared is_audiobook() helper instead of == "audiobook".
normalize_content_type()
treats "book (audiobook)" as an audiobook, so the exact-string check
would have mislabeled that value even once it arrived.

The existing rTorrent tests passed content_type straight to the client,
which is why nothing caught the missing wiring; the new handler test
covers the call site itself.

Post-processing was never affected: destination.py reads
task.content_type directly, so files already landed in
DESTINATION_AUDIOBOOK correctly.

Fixes #1235
2026-08-20 10:39:50 -04:00
CaliBrain 12d554a92f fix(download): hand a 503 carrying a challenge to the bypasser (#1238)
503 is in RETRYABLE_CODES, and the bypasser is only ever reached from
the 403
branch and the AA redirect-loop rescues. Once Z-Library re-serves its
DDoS-Guard
interstitial with the same cookie the #1188 handshake already echoed
back, the
request has nothing left to try and spends every attempt on the same
wall.

Gate the handoff on the response body rather than the status, so a
genuine
overloaded-origin 503 keeps its retry path, and on
allow_bypasser_fallback, so
best-effort fetches still fail fast. The challenge indicators move out
of
internal_bypasser into shelfmark/bypass/challenge.py so http.py can use
them
without importing SeleniumBase, which is lazily imported precisely
because it
is optional.

Refs #1233
2026-08-20 10:29:52 -04:00
23 changed files with 1714 additions and 120 deletions
+14 -2
View File
@@ -1977,7 +1977,7 @@ Move deletes the job from your usenet client after import; Copy keeps it in the
| Variable | Description | Type | Default |
|----------|-------------|------|---------|
| `HARDCOVER_ENABLED` | Enable Hardcover as a metadata provider for book searches | boolean | `false` |
| `HARDCOVER_API_KEY` | Get your API key from hardcover.app/account/api | string (secret) | _none_ |
| `HARDCOVER_API_KEY` | Get your API key from hardcover.app/account/api (starts with hc_pat_) | string (secret) | _none_ |
| `HARDCOVER_DEFAULT_SORT` | Default sort order for Hardcover search results. | string (choice) | `relevance` |
| `HARDCOVER_EXCLUDE_COMPILATIONS` | Filter out compilations, anthologies, and omnibus editions from search results | boolean | `false` |
| `HARDCOVER_EXCLUDE_UNRELEASED` | Filter out books with a release year in the future | boolean | `false` |
@@ -1999,7 +1999,7 @@ Enable Hardcover as a metadata provider for book searches
**API Key**
Get your API key from hardcover.app/account/api
Get your API key from hardcover.app/account/api (starts with hc_pat_)
- **Type:** string (secret)
- **Default:** _none_
@@ -2304,6 +2304,7 @@ Override destination based on content type metadata.
| `EXT_BYPASSER_URL` | URL of the external bypasser service (e.g., FlareSolverr). | string | `http://flaresolverr:8191` |
| `EXT_BYPASSER_PATH` | API path for the external bypasser. | string | `/v1` |
| `EXT_BYPASSER_TIMEOUT` | Timeout for external bypasser requests in milliseconds. | number | `60000` |
| `BYPASS_BROWSER_IDLE_TIMEOUT` | How long the bypass helper process may sit unused before it is shut down. Higher keeps more searches fast, lower frees memory sooner. | number | `180` |
<details>
<summary>Detailed descriptions</summary>
@@ -2359,6 +2360,17 @@ Timeout for external bypasser requests in milliseconds.
- **Requires restart:** Yes
- **Constraints:** min: 10000, max: 300000
#### `BYPASS_BROWSER_IDLE_TIMEOUT`
**Bypasser Idle Timeout (seconds)**
How long the bypass helper process may sit unused before it is shut down. Higher keeps more searches fast, lower frees memory sooner.
- **Type:** number
- **Default:** `180`
- **Requires restart:** Yes
- **Constraints:** min: 30, max: 3600
</details>
### Direct Download: Mirrors
+1 -1
View File
@@ -19,7 +19,7 @@ dependencies = [
"psutil",
"emoji",
"rarfile",
"qbittorrent-api>=2026.8.0",
"qbittorrent-api>=2026.8.1",
"transmission-rpc",
"authlib>=1.7.2,<1.8",
"apprise>=1.12.0",
+24
View File
@@ -95,6 +95,30 @@ volumes:
- Aggregates releases from multiple configured sources
- Full audiobook support
### Hardcover API Key
Hardcover powers metadata search in Universal mode. Create a token at
[hardcover.app/account/api](https://hardcover.app/account/api) — current keys start with `hc_pat_`
and are far shorter than the JWTs Hardcover issued before August 2026.
Tick these seven scopes on the token screen:
| Scope | Used for |
|-------|----------|
| `read:catalog` | Metadata search, plus book, edition, author and series lookups |
| `read:library` | Your reading status and shelf counts |
| `read:lists` | Your lists and the books on them |
| `read:me:content` | Test Connection and the "Connected as" label |
| `read:users` | Usernames shown alongside lists |
| `write:library` | Setting a book's reading status from Shelfmark |
| `write:lists` | Adding and removing books from lists, including auto-remove on download |
The two `write:` scopes matter only if you set reading status from Shelfmark or leave
**Auto-Remove from List on Download** enabled (it is on by default) — without them those actions
fail silently. Everything else Hardcover offers (journal, goals, reviews, prompts, notifications,
account) can stay unticked. The `all` scope works too, but it grants full account access including
deletion, so prefer the list above.
### Environment Variables
Environment variables work for initial setup and Docker deployments. They serve as defaults that can be overridden in the web interface.
+52
View File
@@ -0,0 +1,52 @@
"""Challenge-page detection shared by the bypassers and the HTTP retry path.
Kept out of `internal_bypasser` so the HTTP layer can recognise an interstitial
without importing SeleniumBase: that module is imported lazily precisely because its
browser dependencies are optional, and external-bypasser setups run without them.
"""
# Matched against lowercased text, so every entry must be lowercase.
CLOUDFLARE_INDICATORS = [
"just a moment",
"verify you are human",
"verifying you are human",
"cloudflare.com/products/turnstile",
]
DDOS_GUARD_INDICATORS = [
"ddos-guard",
"ddos guard",
"checking your browser before accessing",
"complete the manual check to continue",
"could not verify your browser automatically",
]
# Markers that exist only in raw markup: the bypassers scan rendered innerText, where
# a script src or a <title> never appears. The title match is scoped to the tag on
# purpose - hosts word the rest of that sentence differently, and matching "checking
# your browser" as free text would trip on any page that merely discusses a challenge.
_RAW_HTML_MARKERS = (
"<title>checking your browser",
"/cdn-cgi/challenge-platform",
"/.well-known/ddos-guard/",
)
# An interstitial is a few KB of markup. Past that it is a real page that happens to
# mention a marker - a protected site links its own DDoS-Guard endpoints on every page.
MAX_CHALLENGE_HTML_CHARS = 64 * 1024
def challenge_marker(html: str) -> str | None:
"""Return the marker proving `html` is an unsolved challenge page, or None.
Only meaningful for a response that already carries a challenge status: the
markers appear on protected sites' real pages too, so the status is what
separates "blocked" from "served".
"""
if not html or len(html) > MAX_CHALLENGE_HTML_CHARS:
return None
lowered = html.lower()
for marker in (*_RAW_HTML_MARKERS, *DDOS_GUARD_INDICATORS, *CLOUDFLARE_INDICATORS):
if marker in lowered:
return marker
return None
+338 -85
View File
@@ -27,6 +27,7 @@ from seleniumbase import cdp_driver
from seleniumbase.undetected.cdp_driver.connection import ProtocolException
from shelfmark.bypass import BypassCancelledError
from shelfmark.bypass.challenge import CLOUDFLARE_INDICATORS, DDOS_GUARD_INDICATORS
from shelfmark.bypass.cookie_store import (
clear_cf_cookies,
export_store,
@@ -57,28 +58,32 @@ _LOADING_BODY_LENGTH_MAX = 50
_PAGE_BODY_PREVIEW_CHARS = 500
_BROWSER_START_TIMEOUT_SECONDS = 45.0
_BYPASS_SUBPROCESS_TIMEOUT_SECONDS = 420.0
# Same budget as the Docker helper process, applied to the in-process CDP path so both
# branches of get() are bounded the same way.
_IN_PROCESS_BYPASS_TIMEOUT_SECONDS = _BYPASS_SUBPROCESS_TIMEOUT_SECONDS
# How long a cancelled bypass may take to close its browser before the calling thread
# stops waiting for it. Counted on top of the bypass deadline, so every budget below is
# set to leave room for it.
_CDP_UNWIND_GRACE_SECONDS = 15.0
# Same wall-clock budget as the Docker helper process, applied to the in-process CDP path
# so both branches of get() are bounded the same way: the deadline plus the unwind grace
# comes to _BYPASS_SUBPROCESS_TIMEOUT_SECONDS either way.
_IN_PROCESS_BYPASS_TIMEOUT_SECONDS = _BYPASS_SUBPROCESS_TIMEOUT_SECONDS - _CDP_UNWIND_GRACE_SECONDS
_BYPASS_CHILD_ENV = "SHELFMARK_INTERNAL_BYPASSER_CHILD"
# The helper bounds each bypass below the parent's deadline, so it is the side that gives
# up first: it still gets to report the timeout and close its browser, and stays available
# for the next request. A parent that hit its deadline first could only kill the helper,
# throwing away a process the next request would have to start again. The 30s covers the
# unwind grace as well, so a helper that times out and closes its browser as slowly as it
# is allowed to still answers with 15s to spare.
_CHILD_BYPASS_TIMEOUT_SECONDS = _BYPASS_SUBPROCESS_TIMEOUT_SECONDS - 30.0
# The helper publishes its answer by writing the result file the request named, so the
# parent waits by watching for that file rather than by reading a stream it would have to
# demultiplex from the helper's log output.
_HELPER_RESULT_POLL_SECONDS = 0.05
# Closing the helper's stdin asks it to shut down; this is how long it may take to finish
# what it is doing and exit before its session is killed instead.
_HELPER_SHUTDOWN_GRACE_SECONDS = 15.0
_HELPER_IDLE_TIMEOUT_DEFAULT = 180.0
_PARENT_WATCHDOG_INTERVAL_SECONDS = 5.0
# Challenge detection indicators
CLOUDFLARE_INDICATORS = [
"just a moment",
"verify you are human",
"verifying you are human",
"cloudflare.com/products/turnstile",
]
DDOS_GUARD_INDICATORS = [
"ddos-guard",
"ddos guard",
"checking your browser before accessing",
"complete the manual check to continue",
"could not verify your browser automatically",
]
class _DisplayState(TypedDict):
ffmpeg: subprocess.Popen[bytes] | None
@@ -222,14 +227,33 @@ class _CdpWorker:
msg = "CDP worker loop failed to start"
raise RuntimeError(msg)
@staticmethod
async def _bounded(coro: Any, timeout: float | None) -> Any:
"""Run the coroutine under its deadline, on the loop that owns it.
The deadline has to be enforced from inside the loop rather than by the calling
thread: asyncio.wait_for() cancels the bypass and then *waits for it to unwind*,
so `finally: await _close_cdp_driver(driver)` has finished by the time this
raises. Cancelling from outside returns the moment the cancellation is scheduled,
which in a helper serving many requests let the abandoned bypass close its browser
while the next one was already opening its own - on the same loop, sharing the
DISPLAY globals and one process group.
"""
if timeout is None:
return await coro
return await asyncio.wait_for(coro, timeout)
def run(self, coro: Any, timeout: float | None = None) -> Any:
self.start()
if not self._loop or self._loop.is_closed():
msg = "CDP worker loop not available"
raise RuntimeError(msg)
future = asyncio.run_coroutine_threadsafe(coro, self._loop)
future = asyncio.run_coroutine_threadsafe(self._bounded(coro, timeout), self._loop)
# Backstop for an unwind that wedges too: _close_cdp_driver awaits websockets that
# a dead browser may never answer, and _bounded cannot outlive its own cleanup.
wait_for = None if timeout is None else timeout + _CDP_UNWIND_GRACE_SECONDS
try:
return future.result(timeout=timeout)
return future.result(timeout=wait_for)
except TimeoutError:
# Otherwise the coroutine keeps running in the worker loop after we stop
# waiting, holding the browser and racing the next bypass.
@@ -820,13 +844,21 @@ def _run_bypass_in_current_process(url: str, retry: int, cancel_flag: Event | No
if driver:
await _close_cdp_driver(driver)
if os.environ.get(_BYPASS_CHILD_ENV) == "1":
return asyncio.run(_run_bypass())
# Bound the wait: this path runs in-process (non-Docker installs), holds the module-wide
# LOCKED for its whole duration, and neither page.get() nor page.wait() has a timeout of
# its own. Without a deadline here a single wedged CDP session blocks every subsequent
# bypass in the process forever.
return _CDP_WORKER.run(_run_bypass(), timeout=_IN_PROCESS_BYPASS_TIMEOUT_SECONDS)
# Bound the wait: this holds the module-wide LOCKED for its whole duration, and neither
# page.get() nor page.wait() has a timeout of its own. Without a deadline here a single
# wedged CDP session blocks every subsequent bypass in the process forever.
#
# The helper goes through the worker too, rather than asyncio.run: that owns a loop for
# one call and closes it on the way out, so a helper serving many requests would build
# and tear down a loop per bypass and would carry no deadline of its own. The worker's
# loop lives in a thread, outlives any single bypass, and cancels the coroutine when the
# deadline passes.
timeout = (
_CHILD_BYPASS_TIMEOUT_SECONDS
if os.environ.get(_BYPASS_CHILD_ENV) == "1"
else _IN_PROCESS_BYPASS_TIMEOUT_SECONDS
)
return _CDP_WORKER.run(_run_bypass(), timeout=timeout)
def _store_child_bypass_state(payload: dict[str, Any]) -> None:
@@ -868,6 +900,213 @@ def _terminate_helper_session(proc: subprocess.Popen[str]) -> None:
proc.wait(timeout=5)
def _part_path(result_path: Path) -> Path:
"""Where the helper stages a result before renaming it into place."""
return result_path.with_name(result_path.name + ".part")
class _BypassHelper:
"""The helper subprocess that runs the bypasses, kept alive across them.
Spawning it costs about 4.5 seconds of interpreter start and imports before any work
begins, paid on every protected request - and a single search issues several. What it
keeps is the process, not the browser: each bypass still starts and closes its own
Chrome, so nothing accumulates between requests.
Protocol: one JSON request per line on stdin, answered by writing the result file that
request named. stdout and stderr stay attached to the parent's, so helper logs keep
showing up in `docker logs` as before.
Only one request is ever in flight - get() serializes every bypass behind LOCKED. The
lock here is for the idle reaper, which runs on a timer thread.
"""
def __init__(self) -> None:
self._lock = threading.RLock()
self._proc: subprocess.Popen[str] | None = None
self._last_used = 0.0
self._idle_timer: threading.Timer | None = None
def _idle_timeout(self) -> float:
return _coerce_non_negative_float(
app_config.get("BYPASS_BROWSER_IDLE_TIMEOUT", _HELPER_IDLE_TIMEOUT_DEFAULT),
_HELPER_IDLE_TIMEOUT_DEFAULT,
)
def _spawn(self) -> subprocess.Popen[str]:
env_vars = os.environ.copy()
env_vars[_BYPASS_CHILD_ENV] = "1"
env_vars = _prepare_child_browser_env(env_vars)
return subprocess.Popen(
[sys.executable, "-m", "shelfmark.bypass.internal_bypasser"],
stdin=subprocess.PIPE,
text=True,
env=env_vars,
# Give the helper its own session: Chrome, Xvfb and ffmpeg inherit its process
# group, which is what lets the cleanup sweep tell this helper's browsers apart
# from a concurrent worker's (#1231) and lets us kill the whole tree below.
start_new_session=True,
)
def _running(self) -> subprocess.Popen[str] | None:
proc = self._proc
if proc is None:
return None
if proc.poll() is not None or proc.stdin is None or proc.stdin.closed:
return None
return proc
def _ensure_running(self) -> subprocess.Popen[str]:
proc = self._running()
if proc is not None:
return proc
if self._proc is not None:
logger.info("Bypass helper exited (code %s), starting a new one", self._proc.returncode)
self._discard()
self._proc = self._spawn()
return self._proc
def _discard(self, *, wait_for_exit: bool = True) -> None:
"""Stop the helper and forget it.
`wait_for_exit` belongs to a helper that could still act on the closed pipe: an
idle one is sitting in its stdin read, notices EOF and exits on its own. A helper
dropped mid-bypass is blocked inside the solve and will not return to that read,
so the grace cannot end in anything but the kill below - and the caller waiting it
out is a user cancelling a download, holding LOCKED while every other bypass in
the worker queues behind them.
"""
proc = self._proc
self._proc = None
if proc is None:
return
# Closing stdin ends the helper's request loop, so an idle helper gets to exit on
# its own. One mid-bypass cannot answer, and is killed below.
with suppress(OSError):
if proc.stdin is not None and not proc.stdin.closed:
proc.stdin.close()
if wait_for_exit:
try:
proc.wait(timeout=_HELPER_SHUTDOWN_GRACE_SECONDS)
except subprocess.TimeoutExpired:
logger.warning("Bypass helper did not exit on request, killing its session")
# Tear the session down either way: a helper killed mid-bypass leaves its Chrome
# and Xvfb running, and those leftovers are what made the next worker's browser
# fail to start. Harmless once it has already exited.
_terminate_helper_session(proc)
def _cancel_idle_timer(self) -> None:
if self._idle_timer is not None:
self._idle_timer.cancel()
self._idle_timer = None
def _arm_idle_timer(self) -> None:
self._cancel_idle_timer()
timeout = self._idle_timeout()
if self._proc is None or timeout <= 0:
return
timer = threading.Timer(timeout, self._reap_if_idle)
timer.daemon = True
self._idle_timer = timer
timer.start()
def _reap_if_idle(self) -> None:
with self._lock:
if self._proc is None:
return
idle_for = time.monotonic() - self._last_used
timeout = self._idle_timeout()
if idle_for < timeout:
# A bypass started while this timer was waiting for the lock.
self._arm_idle_timer()
return
logger.info("Closing idle bypass helper after %.0fs without work", idle_for)
self._discard()
def run(
self,
payload: dict[str, Any],
timeout: float,
cancel_flag: Event | None,
) -> dict[str, Any]:
with self._lock:
self._cancel_idle_timer()
try:
return self._exchange(payload, timeout, cancel_flag)
finally:
self._last_used = time.monotonic()
self._arm_idle_timer()
def _exchange(
self,
payload: dict[str, Any],
timeout: float,
cancel_flag: Event | None,
) -> dict[str, Any]:
request_line = json.dumps(payload) + "\n"
proc = self._ensure_running()
try:
self._write(proc, request_line)
except OSError as exc:
# A live helper can die between the liveness check and the write, so one retry
# on a fresh process. A fresh one failing here is a real failure.
logger.info("Bypass helper closed its pipe (%s), retrying on a new one", exc)
# Nothing to ask of a helper we cannot write to: its read end is already gone.
self._discard(wait_for_exit=False)
proc = self._ensure_running()
self._write(proc, request_line)
return self._await_result(proc, Path(str(payload["result_path"])), timeout, cancel_flag)
def _write(self, proc: subprocess.Popen[str], request_line: str) -> None:
if proc.stdin is None:
msg = "Bypass helper has no stdin pipe"
raise OSError(msg)
proc.stdin.write(request_line)
proc.stdin.flush()
def _await_result(
self,
proc: subprocess.Popen[str],
result_path: Path,
timeout: float,
cancel_flag: Event | None,
) -> dict[str, Any]:
deadline = time.monotonic() + timeout
try:
while not result_path.exists():
if proc.poll() is not None:
returncode = proc.returncode
self._discard(wait_for_exit=False)
msg = f"Internal bypasser helper exited without a result (code {returncode})"
raise RuntimeError(msg)
if cancel_flag is not None and cancel_flag.is_set():
# The helper is mid-bypass and cannot be told to stop, so it goes.
self._discard(wait_for_exit=False)
_check_cancellation(cancel_flag, "Bypass cancelled while waiting for helper")
if time.monotonic() >= deadline:
self._discard(wait_for_exit=False)
msg = "Internal bypasser helper process timed out"
raise TimeoutError(msg)
time.sleep(_HELPER_RESULT_POLL_SECONDS)
return json.loads(result_path.read_text(encoding="utf-8"))
finally:
# Every way out of here is final for this request: either the answer has been
# read, or the helper that would have written it has just been killed. Nothing
# will write these paths afterwards and nothing will come looking for them, so
# they are cleaned on the failure paths too - otherwise every cancelled
# download and every wedged solve leaves one behind for the container's life.
for path in (result_path, _part_path(result_path)):
with suppress(OSError):
path.unlink()
_BYPASS_HELPER = _BypassHelper()
def _get_via_subprocess(url: str, retry: int, cancel_flag: Event | None = None) -> str:
"""Run the browser bypass in a helper process isolated from gunicorn/gevent."""
_check_cancellation(cancel_flag, "Bypass cancelled before helper process")
@@ -878,50 +1117,15 @@ def _get_via_subprocess(url: str, retry: int, cancel_flag: Event | None = None)
# freshly spawned helper would otherwise pre-resolve AA hostnames against the system
# resolver - which may be blocked or hijacked by the user's ISP. Pass the parent's
# active DNS config so the helper mirrors it (e.g. DoH) when building Chrome's host
# resolver rules.
# resolver rules. Sent with every request, not just at spawn, because a helper outlives
# changes the parent makes to its DNS provider.
payload = {
"url": url,
"retry": retry,
"result_path": str(result_path),
"dns_config": network.get_dns_config(),
}
env_vars = os.environ.copy()
env_vars[_BYPASS_CHILD_ENV] = "1"
env_vars = _prepare_child_browser_env(env_vars)
proc = subprocess.Popen(
[sys.executable, "-m", "shelfmark.bypass.internal_bypasser"],
stdin=subprocess.PIPE,
text=True,
env=env_vars,
# Give the helper its own session: Chrome, Xvfb and ffmpeg inherit its process
# group, which is what lets the cleanup sweep tell this bypass's browsers apart
# from a concurrent worker's (#1231) and lets us kill the whole tree below.
start_new_session=True,
)
timed_out = False
try:
proc.communicate(json.dumps(payload), timeout=_BYPASS_SUBPROCESS_TIMEOUT_SECONDS)
except subprocess.TimeoutExpired:
timed_out = True
finally:
# Always tear the session down, not just on timeout: killing the helper alone
# leaves its Chrome and Xvfb running, and those leftovers are what made the next
# worker's browser fail to start in the first place.
_terminate_helper_session(proc)
if timed_out:
msg = "Internal bypasser helper process timed out"
raise TimeoutError(msg)
try:
result = json.loads(result_path.read_text())
except FileNotFoundError as exc:
msg = f"Internal bypasser helper exited without a result (code {proc.returncode})"
raise RuntimeError(msg) from exc
finally:
with suppress(OSError):
result_path.unlink()
result = _BYPASS_HELPER.run(payload, _BYPASS_SUBPROCESS_TIMEOUT_SECONDS, cancel_flag)
if not isinstance(result, dict):
msg = "Internal bypasser helper returned an invalid result"
@@ -1258,26 +1462,38 @@ def get_bypassed_page(
return response_html
def _dns_fingerprint(dns_config: dict[str, Any]) -> tuple[str, tuple[str, ...], bool]:
"""Reduce a DNS config to what has to match for two of them to be the same one."""
provider = str(dns_config.get("provider") or "").strip().lower()
servers = dns_config.get("servers") if provider == "manual" else None
server_list = tuple(str(server) for server in servers) if isinstance(servers, list) else ()
return (provider, server_list, bool(dns_config.get("doh_enabled")))
def _apply_parent_dns_config(dns_config: dict[str, Any]) -> None:
"""Mirror the parent process's active DNS provider in this helper subprocess.
DNS state is in-memory only, so a fresh helper defaults to system DNS and would
pre-resolve AA hostnames (for Chrome's --host-resolver-rules) against a resolver
that may be blocked/hijacked. Re-applying the parent's provider keeps the helper on
the same DoH/custom resolver the parent already validated.
DNS state is in-memory only, so a helper left to itself would pre-resolve AA hostnames
(for Chrome's --host-resolver-rules) against a resolver that may be blocked or
hijacked. Re-applying the parent's provider keeps the helper on the same DoH/custom
resolver the parent already validated.
Compared against what this process is *actually* resolving through, rather than
against the last config it happened to be handed. The helper now outlives the request,
so it has to be able to travel back to auto as well as away from it - which a user
flipping CUSTOM_DNS in settings does live, without a restart - and asking the network
module what it is doing beats keeping a second, drifting copy of that answer here.
"""
provider = str(dns_config.get("provider") or "").strip().lower()
# "auto" means the parent has not rotated off system DNS yet, so the helper's own
# default initialization already matches it - nothing to override.
if not provider or provider == "auto":
wanted = _dns_fingerprint(dns_config)
provider, servers, use_doh = wanted
if not provider:
return
manual_servers = dns_config.get("servers") if provider == "manual" else None
# set_dns_provider() rebuilds the resolvers, so it is worth doing only on a real change.
if wanted == _dns_fingerprint(network.get_dns_config()):
return
try:
network.set_dns_provider(
provider,
manual_servers,
use_doh=bool(dns_config.get("doh_enabled")),
)
network.set_dns_provider(provider, list(servers) or None, use_doh=use_doh)
except (OSError, RuntimeError, ValueError) as exc:
logger.warning("Could not apply parent DNS config (%s): %s", provider, exc)
@@ -1314,9 +1530,20 @@ def _start_parent_watchdog() -> None:
).start()
def _run_child_process() -> int:
"""CLI entrypoint used by the Docker helper subprocess."""
request = json.loads(sys.stdin.read() or "{}")
def _publish_result(result_path: Path, payload: dict[str, Any]) -> None:
"""Write the result file atomically.
The parent decides the request is answered the moment this path exists, so it must
never observe a half-written file. Rename within the same directory is atomic.
"""
tmp_path = _part_path(result_path)
tmp_path.write_text(json.dumps(payload), encoding="utf-8")
tmp_path.replace(result_path)
def _handle_child_request(request_line: str) -> int:
"""Answer one request from the parent."""
request = json.loads(request_line or "{}")
result_path = Path(str(request["result_path"]))
url = str(request["url"])
retry = _coerce_positive_int(
@@ -1327,6 +1554,15 @@ def _run_child_process() -> int:
if isinstance(dns_config, dict):
_apply_parent_dns_config(dns_config)
# The parent owns the cookie store; this process only solves. Starting each request
# from an empty store is what a helper spawned per request gave for free, and losing
# it is what let clearance the parent had deliberately purged for some *other* host
# survive here and get merged back over the parent's copy by the export below - the
# dead-cookie resurrection that http.py's _redirect_loop_handoff purges to avoid.
# Nothing is lost by dropping it: get() below re-checks cached cookies, and the
# parent already ran that same check against a store that is a superset of this one.
clear_cf_cookies()
try:
html = get(url, retry=retry)
cookies, user_agents = export_store()
@@ -1336,7 +1572,7 @@ def _run_child_process() -> int:
"cookies": cookies,
"user_agents": user_agents,
}
result_path.write_text(json.dumps(payload), encoding="utf-8")
_publish_result(result_path, payload)
except Exception as exc: # noqa: BLE001 - helper boundary must serialize failures.
payload = {
"ok": False,
@@ -1344,11 +1580,28 @@ def _run_child_process() -> int:
"error": str(exc),
"traceback": traceback.format_exc(),
}
result_path.write_text(json.dumps(payload), encoding="utf-8")
_publish_result(result_path, payload)
return 1
return 0
def _run_child_process() -> int:
"""CLI entrypoint used by the Docker helper subprocess.
Serves one request per line of stdin until the parent closes the pipe, so a burst of
protected requests - a single search is several - pays the interpreter start and imports
once instead of per request. Each bypass still gets its own browser, closed before the
answer is published.
"""
exit_code = 0
for line in sys.stdin:
request_line = line.strip()
if not request_line:
continue
exit_code = _handle_child_request(request_line)
return exit_code
if __name__ == "__main__":
# Started here rather than in _run_child_process() so it only ever watches a real
# spawned helper, never a test or an embedded call.
+13
View File
@@ -1655,6 +1655,19 @@ def cloudflare_bypass_settings() -> list[SettingsField]:
requires_restart=True,
show_when={"field": "USING_EXTERNAL_BYPASSER", "value": True},
),
NumberField(
key="BYPASS_BROWSER_IDLE_TIMEOUT",
label="Bypasser Idle Timeout (seconds)",
description=(
"How long the bypass helper process may sit unused before it is shut down. "
"Higher keeps more searches fast, lower frees memory sooner."
),
default=180,
min_value=30,
max_value=3600,
requires_restart=True,
show_when={"field": "USING_EXTERNAL_BYPASSER", "value": False},
),
]
@@ -843,6 +843,9 @@ class ExternalClientHandler(DownloadHandler, ABC):
expected_hash=request.expected_hash,
seeding_time_limit=request.seeding_time_limit,
ratio_limit=request.ratio_limit,
# rTorrent has no category concept, so its audiobook label
# can only be chosen from the content type (#1235).
content_type=task.content_type,
)
except Exception as e:
if not refresh_attempted:
+8 -2
View File
@@ -11,7 +11,12 @@ from urllib.parse import urlparse
from shelfmark.core.config import config
from shelfmark.core.logger import setup_logger
from shelfmark.core.utils import get_hardened_xmlrpc_client
from shelfmark.core.utils import (
get_hardened_xmlrpc_client,
)
from shelfmark.core.utils import (
is_audiobook as check_audiobook,
)
from shelfmark.download.clients import (
DownloadClient,
DownloadStatus,
@@ -173,7 +178,8 @@ class RTorrentClient(DownloadClient):
commands = []
is_audiobook = kwargs.get("content_type") == "audiobook"
content_type = kwargs.get("content_type")
is_audiobook = check_audiobook(content_type if isinstance(content_type, str) else None)
default_label = (
self._audiobook_label if is_audiobook and self._audiobook_label else self._label
)
+46
View File
@@ -11,6 +11,7 @@ import requests
from tqdm import tqdm
from shelfmark.bypass import BypassCancelledError, cookie_store
from shelfmark.bypass.challenge import challenge_marker
from shelfmark.core.config import config as app_config
from shelfmark.core.logger import setup_logger
from shelfmark.core.request_helpers import coerce_bool, normalize_positive_int
@@ -233,6 +234,22 @@ def _is_retryable_error(e: Exception) -> bool:
_DEAD_MIRROR_CODES = (410, 451)
def _response_challenge_marker(response: requests.Response) -> str | None:
"""The challenge marker in a response body, or None if it carries no challenge.
Content type is checked first so a JSON or octet-stream error body is never
decoded just to be scanned; a missing header is scanned anyway, since an
interstitial served without one is still an interstitial.
"""
content_type = response.headers.get("Content-Type", "")
if content_type and "html" not in content_type.lower():
return None
try:
return challenge_marker(response.text)
except UnicodeDecodeError, ValueError:
return None
def _fatal_mirror_reason(e: Exception) -> str | None:
"""Return why ``e`` proves the mirror is unusable, or None if it may recover.
@@ -455,6 +472,35 @@ def html_get_page(
)
continue
# A 503 still serving a challenge is protection, not a busy origin. The
# handshake above has nothing left to echo back, and 503 is in
# RETRYABLE_CODES, so without this the request spends every attempt on
# the same wall: the bypasser is only ever reached from the 403 branch
# and the AA redirect rescues. Gate on the body, not the status, so a
# genuine overloaded-origin 503 keeps its retry path.
if response.status_code == _HTTP_STATUS_SERVICE_UNAVAILABLE:
marker = _response_challenge_marker(response)
if marker and _bypass_handoff_allowed():
if cookies:
# Challenged while presenting clearance means those cookies
# are dead; same reasoning as the 403 branch below.
logger.debug(
"503 challenge with cookies presented; purging: %s", current_url
)
_purge_clearance(current_url)
logger.info(
"503 challenge detected (%s); switching to bypasser: %s",
marker,
current_url,
)
return _run_bypasser(current_url)
if marker:
logger.debug(
"503 challenge (%s) but no bypasser handoff available: %s",
marker,
current_url,
)
if is_aa_url and response.is_redirect:
location = response.headers.get("Location", "")
if not location:
+10 -5
View File
@@ -48,6 +48,10 @@ HARDCOVER_PAGE_SIZE = 25 # Hardcover API returns max 25 results per page
HARDCOVER_MIN_AUTHOR_PARTS = 2
HARDCOVER_MIN_TYPEAHEAD_QUERY_LENGTH = 2
HARDCOVER_MAX_SERIES_OPTIONS = 7
# Hardcover hands out short opaque tokens now ("hc_pat_...") instead of the ~500 char
# JWTs it used to, so the length floor only applies to keys without that prefix.
HARDCOVER_API_KEY_PREFIX = "hc_pat_"
HARDCOVER_BEARER_PREFIX_PATTERN = re.compile(r"^bearer\s+", re.IGNORECASE)
HARDCOVER_API_KEY_MIN_LENGTH = 100
HARDCOVER_LIST_URL_PATTERN = re.compile(
r"^/(?:@([\w.-]+)/)?lists?/([\w-]+)/?$",
@@ -670,7 +674,7 @@ def _normalize_series_position(value: Any) -> float | None:
def _normalize_hardcover_api_key(value: object) -> str:
"""Normalize Hardcover API keys, stripping copied auth-header prefixes."""
normalized_value = normalize_optional_text(value) or ""
return normalized_value.removeprefix("Bearer ").strip()
return HARDCOVER_BEARER_PREFIX_PATTERN.sub("", normalized_value.strip()).strip()
def _normalize_search_text(value: str) -> str:
@@ -3019,12 +3023,13 @@ def _test_hardcover_connection(current_values: dict[str, Any] | None = None) ->
_save_connected_user(None, None)
return {"success": False, "message": "API key is required"}
if key_len < HARDCOVER_API_KEY_MIN_LENGTH:
is_prefixed_key = api_key.startswith(HARDCOVER_API_KEY_PREFIX)
if not is_prefixed_key and key_len < HARDCOVER_API_KEY_MIN_LENGTH:
return {
"success": False,
"message": (
f"API key seems too short ({key_len} chars). "
f"Expected {HARDCOVER_API_KEY_MIN_LENGTH}+ chars."
f"API key seems too short ({key_len} chars). Expected a key starting "
f"with {HARDCOVER_API_KEY_PREFIX} or {HARDCOVER_API_KEY_MIN_LENGTH}+ chars."
),
}
@@ -3131,7 +3136,7 @@ def hardcover_settings() -> list[SettingsField]:
PasswordField(
key="HARDCOVER_API_KEY",
label="API Key",
description="Get your API key from hardcover.app/account/api",
description="Get your API key from hardcover.app/account/api (starts with hc_pat_)",
required=True,
),
ActionButton(
@@ -2,7 +2,7 @@
import re
import time
from urllib.parse import quote
from urllib.parse import quote, quote_plus
import requests
from bs4 import BeautifulSoup
@@ -10,6 +10,7 @@ from bs4 import BeautifulSoup
from shelfmark.core.config import config
from shelfmark.core.logger import setup_logger
from shelfmark.download import http as downloader
from shelfmark.release_sources.audiobookbay.utils import normalize_search_punctuation
logger = setup_logger(__name__)
@@ -98,8 +99,10 @@ def _encode_search_query(query: str, *, exact_phrase: bool) -> str:
and not (search_query.startswith('"') and search_query.endswith('"'))
):
search_query = f'"{search_query}"'
# Keep ABB-friendly encoding style (spaces as '+') while percent-encoding quotes.
return search_query.replace('"', "%22").replace(" ", "+")
# Keep ABB's space-as-'+' style, but percent-encode everything else: a bare
# '&' would otherwise start a new query parameter, '%' would open an invalid
# escape, and a literal '+' would arrive as a space.
return quote_plus(search_query)
def _normalize_result_url(url: str, hostname: str) -> str:
@@ -153,6 +156,9 @@ def search_audiobookbay(
"""
results = []
# ABB matches the stored, untexturized title, so a curly apostrophe reaching
# the search returns nothing at all rather than merely ranking worse.
query = normalize_search_punctuation(query)
rate_limit_delay = _coerce_non_negative_float(config.get("ABB_RATE_LIMIT_DELAY", 1.0), 1.0)
session = requests.Session()
@@ -23,7 +23,11 @@ from shelfmark.release_sources import (
register_source,
)
from shelfmark.release_sources.audiobookbay import scraper
from shelfmark.release_sources.audiobookbay.utils import normalize_hostname, parse_size
from shelfmark.release_sources.audiobookbay.utils import (
normalize_hostname,
normalize_search_punctuation,
parse_size,
)
logger = setup_logger(__name__)
MIN_RELEVANCE_QUERY_WORD_LENGTH = 2
@@ -227,10 +231,12 @@ class AudiobookBaySource(ReleaseSource):
deduped_queries[index + 1].lower(),
)
# Extract query words for relevance checking
# Extract query words for relevance checking. Both sides of the
# comparison are punctuation-normalized: scraped titles carry the
# typographic forms WordPress renders, queries carry the ASCII ones.
query_words = {
word.lower()
for word in query_lower.split()
for word in normalize_search_punctuation(query_lower).split()
if len(word) > MIN_RELEVANCE_QUERY_WORD_LENGTH
}
@@ -239,7 +245,7 @@ class AudiobookBaySource(ReleaseSource):
try:
raw_title = result["title"]
title, author = _split_title_and_author(raw_title)
title_for_filter = raw_title.lower()
title_for_filter = normalize_search_punctuation(raw_title).lower()
# Basic relevance check: ensure title contains at least one query word
# This filters out homepage "Latest" feed items that may leak through
@@ -2,6 +2,63 @@
import re
# WordPress texturizes punctuation on output only: a post stored as "The
# Stranger's Wife" is rendered as "The Stranger’s Wife". ABB's search matches the
# stored value, so a query carrying the typographic form matches nothing -- and
# because ABB ANDs its search terms, one such term empties the entire result set.
# Book metadata and phone keyboards both hand us the typographic forms, so map
# them back before they reach a search or a title comparison.
_ASCII_PUNCTUATION = str.maketrans(
{
# Single quotes
"‘": "'", # left single quotation mark
"’": "'", # right single quotation mark
"‚": "'", # single low-9 quotation mark
"‛": "'", # single high-reversed-9 quotation mark
"′": "'", # prime
"´": "'", # acute accent
"`": "'", # grave accent
# Double quotes
"“": '"', # left double quotation mark
"”": '"', # right double quotation mark
"„": '"', # double low-9 quotation mark
"‟": '"', # double high-reversed-9 quotation mark
"″": '"', # double prime
# Dashes
"‐": "-", # hyphen
"‑": "-", # non-breaking hyphen
"‒": "-", # figure dash
"–": "-", # en dash
"—": "-", # em dash
"―": "-", # horizontal bar
"−": "-", # minus sign
"﹘": "-", # small em dash
"﹣": "-", # small hyphen-minus
"-": "-", # fullwidth hyphen-minus
# Ellipsis
"…": "...", # horizontal ellipsis
}
)
def normalize_search_punctuation(text: str) -> str:
"""Replace typographic punctuation with the ASCII forms ABB stores.
Each character is mapped individually rather than collapsing runs, so an
ASCII "--" is left alone: only characters ABB cannot have stored are
rewritten.
Args:
text: A search query, or a scraped title being compared against one.
Returns:
The text with curly quotes, dashes and ellipses mapped to ASCII.
"""
if not text:
return text
return text.translate(_ASCII_PUNCTUATION)
def normalize_hostname(raw: str | None) -> str:
"""Normalize a user-supplied hostname for URL construction.
+38
View File
@@ -317,6 +317,44 @@ class TestSearchAudiobookbay:
assert "s=%22test+query%22" in requested_url
assert "cat=undefined%2Cundefined" in requested_url
@patch("shelfmark.release_sources.audiobookbay.scraper.downloader.html_get_page")
@patch("shelfmark.release_sources.audiobookbay.scraper.config.get")
def test_search_audiobookbay_normalizes_curly_apostrophe(self, mock_config_get, mock_html_get):
"""Test curly apostrophes are searched as the ASCII form ABB stores."""
mock_config_get.return_value = 0.0
mock_html_get.return_value = (SAMPLE_SEARCH_HTML, "https://audiobookbay.lu/?s=x")
scraper.search_audiobookbay(
"the stranger’s wife",
max_pages=1,
hostname="audiobookbay.lu",
)
requested_url = mock_html_get.call_args.args[0]
assert "s=the+stranger%27s+wife" in requested_url
assert "%E2%80%99" not in requested_url
@patch("shelfmark.release_sources.audiobookbay.scraper.downloader.html_get_page")
@patch("shelfmark.release_sources.audiobookbay.scraper.config.get")
def test_search_audiobookbay_percent_encodes_reserved_characters(
self, mock_config_get, mock_html_get
):
"""Test reserved characters cannot break out of the search parameter."""
mock_config_get.return_value = 0.0
mock_html_get.return_value = (SAMPLE_SEARCH_HTML, "https://audiobookbay.lu/?s=x")
scraper.search_audiobookbay(
"sense & sensibility 100% c++",
max_pages=1,
hostname="audiobookbay.lu",
)
requested_url = mock_html_get.call_args.args[0]
assert "s=sense+%26+sensibility+100%25+c%2B%2B" in requested_url
# The only surviving '&' introduces the legacy category parameter.
assert requested_url.count("&") == 1
assert requested_url.endswith("&cat=undefined%2Cundefined")
@patch("shelfmark.release_sources.audiobookbay.scraper.downloader.html_get_page")
@patch("shelfmark.release_sources.audiobookbay.scraper.config.get")
def test_search_audiobookbay_always_uses_legacy_category_query(
+36
View File
@@ -306,6 +306,42 @@ class TestAudiobookBaySource:
assert len(results) == 1
assert results[0].title == "Test Book by Test Author"
@patch("shelfmark.release_sources.audiobookbay.source.scraper.search_audiobookbay")
def test_search_relevance_filtering_spans_typographic_punctuation(self, mock_search):
"""Test an ASCII query still matches the typographic title ABB renders."""
mock_search.return_value = [
{
"title": "The Stranger’s Wife — Anna‑Lou Weatherley",
"link": "https://audiobookbay.lu/abss/the-strangers-wife/",
"format": "M4B",
"size": "259 MB",
"language": "English",
},
]
source = AudiobookBaySource()
book = BookMetadata(
provider="test",
provider_id="123",
title="Stranger's",
authors=["Anna-Lou Weatherley"],
)
# Every query word carries punctuation, so the result survives only when
# both sides of the comparison are normalized.
plan = ReleaseSearchPlan(
languages=["en"],
isbn_candidates=[],
author="",
title_variants=[ReleaseSearchVariant(title="Stranger's", author="")],
grouped_title_variants=[],
)
results = source.search(book, plan, content_type="audiobook")
assert len(results) == 1
# The release keeps the title as ABB rendered it; only matching normalizes.
assert results[0].title == "The Stranger’s Wife — Anna‑Lou Weatherley"
@patch("shelfmark.release_sources.audiobookbay.source.scraper.search_audiobookbay")
def test_search_result_mapping(self, mock_search):
"""Test conversion of scraper results to Release objects."""
+46 -1
View File
@@ -2,7 +2,52 @@
Tests for AudiobookBay utility functions.
"""
from shelfmark.release_sources.audiobookbay.utils import parse_size
from shelfmark.release_sources.audiobookbay.utils import normalize_search_punctuation, parse_size
class TestNormalizeSearchPunctuation:
"""Tests for the normalize_search_punctuation function."""
def test_curly_apostrophe_becomes_ascii(self):
"""ABB matches the stored ASCII apostrophe, not the rendered curly one."""
assert normalize_search_punctuation("The Stranger’s Wife") == "The Stranger's Wife"
def test_all_single_quote_variants(self):
"""Every single-quote lookalike collapses to the ASCII apostrophe."""
for variant in ("‘", "’", "‚", "‛", "′", "´", "`"):
assert normalize_search_punctuation(f"don{variant}t") == "don't"
def test_all_double_quote_variants(self):
"""Every double-quote lookalike collapses to the ASCII double quote."""
for variant in ("“", "”", "„", "‟", "″"):
assert normalize_search_punctuation(f"{variant}quoted{variant}") == '"quoted"'
def test_all_dash_variants(self):
"""Every dash lookalike collapses to the ASCII hyphen."""
for variant in ("‐", "‑", "‒", "–", "—", "―", "−", "﹘", "﹣", "-"):
assert normalize_search_punctuation(f"anna{variant}lou") == "anna-lou"
def test_ellipsis_expands_to_three_dots(self):
"""WordPress renders '...' as a single ellipsis character."""
assert normalize_search_punctuation("And Then…") == "And Then..."
def test_ascii_query_is_unchanged(self):
"""A query that is already ASCII passes through untouched."""
assert normalize_search_punctuation("The Stranger's Wife") == "The Stranger's Wife"
def test_ascii_dash_runs_are_not_collapsed(self):
"""Only characters ABB cannot have stored are rewritten."""
assert normalize_search_punctuation("Book -- Subtitle") == "Book -- Subtitle"
def test_other_punctuation_is_preserved(self):
"""Colons and commas carry search signal and are left alone."""
assert normalize_search_punctuation("Weatherley: Book 3, Part 1") == (
"Weatherley: Book 3, Part 1"
)
def test_empty_query(self):
"""An empty query is returned as-is."""
assert normalize_search_punctuation("") == ""
class TestParseSize:
+81 -13
View File
@@ -1,6 +1,5 @@
import asyncio
import json
import subprocess
import threading
from pathlib import Path
@@ -545,30 +544,59 @@ def test_cleanup_is_skipped_without_proc(monkeypatch, tmp_path):
assert internal_bypasser._cleanup_orphan_processes() == 0
class _FakeHelperStdin:
"""The request pipe: a write is how the helper receives one request."""
def __init__(self, process):
self._process = process
self.closed = False
def write(self, data):
self._process.serve(data)
def flush(self):
return None
def close(self):
self.closed = True
class _FakeHelperProcess:
"""Stand-in for the bypass helper subprocess."""
"""Stand-in for the bypass helper subprocess.
The helper serves one request per line of stdin and answers by writing the result file
the request named, so that is what this fakes: a write produces an answer.
"""
def __init__(self, *_args, **kwargs):
self.kwargs = kwargs
self.pid = 4242
self.returncode = 0
self.timed_out = False
self.returncode = None
self.answers = True
self.killed = False
self.waited = False
self.stdin = _FakeHelperStdin(self)
self.requests: list[dict] = []
def communicate(self, payload, timeout=None):
if self.timed_out:
raise subprocess.TimeoutExpired(cmd="helper", timeout=timeout)
def serve(self, payload):
request = json.loads(payload)
self.requests.append(request)
if not self.answers:
return
result = {"ok": True, "html": "<html>solved</html>", "cookies": {}, "user_agents": {}}
Path(request["result_path"]).write_text(json.dumps(result), encoding="utf-8")
return "", ""
def poll(self):
return self.returncode
def kill(self):
self.killed = True
self.returncode = -9
def wait(self, timeout=None):
self.waited = True
if self.returncode is None:
self.returncode = 0
return self.returncode
@@ -578,13 +606,47 @@ def _patch_helper_subprocess(monkeypatch, internal_bypasser, process, killed_gro
monkeypatch.setattr(
internal_bypasser.os, "killpg", lambda pgid, _sig: killed_groups.append(pgid)
)
# A fresh helper per test: the module-level one is shared, and a process parked by one
# test would be handed to the next.
helper = internal_bypasser._BypassHelper()
monkeypatch.setattr(internal_bypasser._BypassHelper, "_idle_timeout", lambda _self: 0.0)
monkeypatch.setattr(internal_bypasser, "_BYPASS_HELPER", helper)
return helper
def test_helper_runs_in_its_own_session_and_is_torn_down(monkeypatch):
"""Regression test for issue #1231: the helper's Chrome and Xvfb must belong to the
helper's own process group, and the whole group must die with it - otherwise the
leftovers break the next worker's browser and can only be cleared by a sweep broad
enough to kill a concurrent worker's browser too."""
enough to kill a concurrent worker's browser too.
The helper outlives a single request, so the teardown happens when it is dropped rather
than after every solve. Each bypass still closes its own browser, so what survives in
between is the process, not a Chrome.
"""
import shelfmark.bypass.internal_bypasser as internal_bypasser
processes: list[_FakeHelperProcess] = []
killed_groups: list[int] = []
def _make_process(*args, **kwargs):
process = _FakeHelperProcess(*args, **kwargs)
processes.append(process)
return process
helper = _patch_helper_subprocess(monkeypatch, internal_bypasser, _make_process, killed_groups)
assert internal_bypasser._get_via_subprocess("https://example.com", 1) == "<html>solved</html>"
assert processes[0].kwargs["start_new_session"] is True
assert killed_groups == [], "the helper was torn down after a single request"
helper._discard()
assert killed_groups == [processes[0].pid]
def test_helper_serves_a_second_request_without_respawning(monkeypatch):
"""The interpreter start and imports are paid once, not per protected request."""
import shelfmark.bypass.internal_bypasser as internal_bypasser
processes: list[_FakeHelperProcess] = []
@@ -597,9 +659,14 @@ def test_helper_runs_in_its_own_session_and_is_torn_down(monkeypatch):
_patch_helper_subprocess(monkeypatch, internal_bypasser, _make_process, killed_groups)
assert internal_bypasser._get_via_subprocess("https://example.com", 1) == "<html>solved</html>"
assert processes[0].kwargs["start_new_session"] is True
assert killed_groups == [processes[0].pid]
internal_bypasser._get_via_subprocess("https://example.com/one", 1)
internal_bypasser._get_via_subprocess("https://example.com/two", 1)
assert len(processes) == 1
assert [request["url"] for request in processes[0].requests] == [
"https://example.com/one",
"https://example.com/two",
]
def test_helper_timeout_kills_the_whole_session(monkeypatch):
@@ -611,11 +678,12 @@ def test_helper_timeout_kills_the_whole_session(monkeypatch):
def _make_process(*args, **kwargs):
process = _FakeHelperProcess(*args, **kwargs)
process.timed_out = True
process.answers = False # accepts the request, never writes a result
processes.append(process)
return process
_patch_helper_subprocess(monkeypatch, internal_bypasser, _make_process, killed_groups)
monkeypatch.setattr(internal_bypasser, "_BYPASS_SUBPROCESS_TIMEOUT_SECONDS", 0.1)
with pytest.raises(TimeoutError):
internal_bypasser._get_via_subprocess("https://example.com", 1)
+658
View File
@@ -0,0 +1,658 @@
"""Tests for keeping the bypass helper process alive between requests.
The browser is deliberately not kept: every bypass starts and closes its own Chrome. What
survives is the helper process, whose interpreter start and imports are pure overhead.
"""
import asyncio
import json
import pytest
class _FakeStdin:
def __init__(self) -> None:
self.closed = False
self.written: list[str] = []
def write(self, data: str) -> None:
if self.closed:
raise BrokenPipeError("stdin is closed")
self.written.append(data)
def flush(self) -> None:
return None
def close(self) -> None:
self.closed = True
class _FakeProc:
"""Enough of subprocess.Popen for the helper's process bookkeeping."""
_next_pid = 90001
def __init__(self) -> None:
self.stdin = _FakeStdin()
self.returncode: int | None = None
self.waited = False
# A pid nothing may actually be signalled by: _terminate_helper_session is patched
# out in these tests, and a stray killpg on a live pid would take out the test run.
type(self)._next_pid += 1
self.pid = type(self)._next_pid
def poll(self) -> int | None:
return self.returncode
def wait(self, timeout: float | None = None) -> int:
self.waited = True
if self.returncode is None:
self.returncode = 0
return self.returncode
def kill(self) -> None:
self.returncode = -9
def _helper_with_fake_spawn(monkeypatch, procs: list[_FakeProc], terminated=None):
"""Build a helper that hands out fake processes and never arms a real timer."""
import shelfmark.bypass.internal_bypasser as internal_bypasser
def _spawn(_self) -> _FakeProc:
proc = _FakeProc()
procs.append(proc)
return proc
def _terminate(proc) -> None:
if terminated is not None:
terminated.append(proc)
monkeypatch.setattr(internal_bypasser._BypassHelper, "_spawn", _spawn)
monkeypatch.setattr(internal_bypasser._BypassHelper, "_idle_timeout", lambda _self: 0.0)
monkeypatch.setattr(internal_bypasser, "_terminate_helper_session", _terminate)
return internal_bypasser._BypassHelper()
def _answered_payload(tmp_path, name: str = "result.json") -> dict:
"""A request whose result file already exists, so the helper resolves immediately."""
result_path = tmp_path / name
result_path.write_text(json.dumps({"ok": True, "html": "<html/>"}), encoding="utf-8")
return {"url": "https://example.com", "retry": 1, "result_path": str(result_path)}
def test_helper_serves_consecutive_requests_from_one_process(monkeypatch, tmp_path):
"""The point of the whole thing: request two and three must not re-pay the spawn."""
procs: list[_FakeProc] = []
helper = _helper_with_fake_spawn(monkeypatch, procs)
for i in range(3):
result = helper.run(_answered_payload(tmp_path, f"r{i}.json"), timeout=5, cancel_flag=None)
assert result["ok"] is True
assert len(procs) == 1, "each request spawned its own helper"
assert len(procs[0].stdin.written) == 3
assert all(line.endswith("\n") for line in procs[0].stdin.written), (
"requests must be newline-delimited or the helper's loop cannot split them"
)
def test_helper_respawns_after_the_previous_one_died(monkeypatch, tmp_path):
"""A helper can be reaped while idle; the next request must not fail on it."""
procs: list[_FakeProc] = []
helper = _helper_with_fake_spawn(monkeypatch, procs)
helper.run(_answered_payload(tmp_path, "a.json"), timeout=5, cancel_flag=None)
procs[0].returncode = 1 # died between requests
result = helper.run(_answered_payload(tmp_path, "b.json"), timeout=5, cancel_flag=None)
assert result["ok"] is True
assert len(procs) == 2
def test_helper_retries_once_when_the_pipe_breaks_on_write(monkeypatch, tmp_path):
"""poll() can still say alive when the far end is already gone."""
procs: list[_FakeProc] = []
helper = _helper_with_fake_spawn(monkeypatch, procs)
helper.run(_answered_payload(tmp_path, "a.json"), timeout=5, cancel_flag=None)
procs[0].stdin.closed = True # pipe gone, but poll() still reports running
result = helper.run(_answered_payload(tmp_path, "b.json"), timeout=5, cancel_flag=None)
assert result["ok"] is True
assert len(procs) == 2
def test_helper_reports_a_helper_that_exits_without_answering(monkeypatch, tmp_path):
import shelfmark.bypass.internal_bypasser as internal_bypasser
procs: list[_FakeProc] = []
helper = _helper_with_fake_spawn(monkeypatch, procs)
payload = {
"url": "https://example.com",
"retry": 1,
"result_path": str(tmp_path / "never-written.json"),
}
def _die_on_write(_self, proc, _line) -> None:
proc.returncode = 3
monkeypatch.setattr(internal_bypasser._BypassHelper, "_write", _die_on_write)
with pytest.raises(RuntimeError, match="exited without a result"):
helper.run(payload, timeout=5, cancel_flag=None)
def test_helper_times_out_and_discards_the_wedged_process(monkeypatch, tmp_path):
procs: list[_FakeProc] = []
helper = _helper_with_fake_spawn(monkeypatch, procs)
payload = {
"url": "https://example.com",
"retry": 1,
"result_path": str(tmp_path / "never-written.json"),
}
with pytest.raises(TimeoutError):
helper.run(payload, timeout=0.05, cancel_flag=None)
assert helper._proc is None, "a wedged helper must not be handed to the next request"
def test_idle_reaper_rearms_when_work_arrived_while_it_waited(monkeypatch, tmp_path):
"""The timer fires on its own thread and can lose the race against a new request."""
import time
import shelfmark.bypass.internal_bypasser as internal_bypasser
procs: list[_FakeProc] = []
helper = _helper_with_fake_spawn(monkeypatch, procs)
helper.run(_answered_payload(tmp_path), timeout=5, cancel_flag=None)
rearmed: list[bool] = []
monkeypatch.setattr(internal_bypasser._BypassHelper, "_idle_timeout", lambda _self: 3600.0)
monkeypatch.setattr(
internal_bypasser._BypassHelper, "_arm_idle_timer", lambda _self: rearmed.append(True)
)
helper._last_used = time.monotonic()
helper._reap_if_idle()
assert rearmed == [True]
assert helper._proc is not None, "helper was killed despite recent work"
def test_idle_reaper_closes_a_genuinely_idle_helper(monkeypatch, tmp_path):
import time
import shelfmark.bypass.internal_bypasser as internal_bypasser
procs: list[_FakeProc] = []
helper = _helper_with_fake_spawn(monkeypatch, procs)
helper.run(_answered_payload(tmp_path), timeout=5, cancel_flag=None)
monkeypatch.setattr(internal_bypasser._BypassHelper, "_idle_timeout", lambda _self: 60.0)
helper._last_used = time.monotonic() - 120
helper._reap_if_idle()
assert helper._proc is None
assert procs[0].stdin.closed
def test_discard_tears_down_the_whole_session(monkeypatch, tmp_path):
"""Dropping the helper must reach its browser tree, not just the helper itself.
The helper is a session leader (start_new_session), so a Chrome left behind by one
killed mid-bypass would keep a process group alive that the cleanup sweep is then not
allowed to reclaim - the leak #1231 was about.
"""
procs: list[_FakeProc] = []
terminated: list[_FakeProc] = []
helper = _helper_with_fake_spawn(monkeypatch, procs, terminated)
helper.run(_answered_payload(tmp_path), timeout=5, cancel_flag=None)
helper._discard()
assert terminated == [procs[0]]
def test_helper_asks_before_it_kills(monkeypatch, tmp_path):
"""An idle helper should get to exit on its own; the kill is the fallback."""
procs: list[_FakeProc] = []
helper = _helper_with_fake_spawn(monkeypatch, procs)
helper.run(_answered_payload(tmp_path), timeout=5, cancel_flag=None)
helper._discard()
assert procs[0].stdin.closed, "stdin must be closed to end the helper's request loop"
assert procs[0].returncode == 0, "an idle helper should have exited on its own"
def _bypass_with_recorded_driver(monkeypatch, get_impl):
"""Wire up a bypass whose browser creation and closing are observable."""
import shelfmark.bypass.internal_bypasser as internal_bypasser
driver = object()
closed: list[object] = []
async def _create(_url):
return driver
async def _close(drv):
closed.append(drv)
monkeypatch.setattr(internal_bypasser, "_create_cdp_browser", _create)
monkeypatch.setattr(internal_bypasser, "_get", get_impl)
monkeypatch.setattr(internal_bypasser, "_close_cdp_driver", _close)
return driver, closed
def test_successful_bypass_closes_its_browser(monkeypatch):
"""A living helper must not accumulate browsers: each bypass ends with Chrome gone."""
import shelfmark.bypass.internal_bypasser as internal_bypasser
async def _get(_url, _driver, _cancel=None):
return "<html>ok</html>"
driver, closed = _bypass_with_recorded_driver(monkeypatch, _get)
result = internal_bypasser._run_bypass_in_current_process("https://example.com", 1)
assert result == "<html>ok</html>"
assert closed == [driver]
def test_failed_bypass_closes_its_browser(monkeypatch):
"""The same has to hold when the bypass raises on its way out."""
import shelfmark.bypass.internal_bypasser as internal_bypasser
async def _get(_url, _driver, _cancel=None):
raise internal_bypasser.BypassCancelledError("cancelled")
driver, closed = _bypass_with_recorded_driver(monkeypatch, _get)
with pytest.raises(internal_bypasser.BypassCancelledError):
internal_bypasser._run_bypass_in_current_process("https://example.com", 1)
assert closed == [driver]
def test_child_process_serves_every_line_it_is_given(monkeypatch, tmp_path):
"""One helper, several requests: the loop is what saves the repeated process start."""
import io
import shelfmark.bypass.internal_bypasser as internal_bypasser
urls: list[str] = []
def _fake_get(url, retry=None, cancel_flag=None):
urls.append(url)
return f"<html>{url}</html>"
requests = [
{"url": "https://example.com/one", "retry": 1, "result_path": str(tmp_path / "1.json")},
{"url": "https://example.com/two", "retry": 1, "result_path": str(tmp_path / "2.json")},
]
stdin = io.StringIO("\n".join(json.dumps(request) for request in requests) + "\n")
monkeypatch.setattr(internal_bypasser, "get", _fake_get)
monkeypatch.setattr(internal_bypasser.sys, "stdin", stdin)
assert internal_bypasser._run_child_process() == 0
assert urls == ["https://example.com/one", "https://example.com/two"]
for index, request in enumerate(requests, start=1):
result = json.loads((tmp_path / f"{index}.json").read_text(encoding="utf-8"))
assert result["ok"] is True
assert result["html"] == f"<html>{request['url']}</html>"
def test_child_process_keeps_serving_after_a_failed_request(monkeypatch, tmp_path):
"""One failing URL must not take the helper - and everything queued - down."""
import io
import shelfmark.bypass.internal_bypasser as internal_bypasser
def _fake_get(url, retry=None, cancel_flag=None):
if url.endswith("boom"):
raise RuntimeError("bypass exploded")
return "<html>ok</html>"
requests = [
{"url": "https://example.com/boom", "retry": 1, "result_path": str(tmp_path / "1.json")},
{"url": "https://example.com/fine", "retry": 1, "result_path": str(tmp_path / "2.json")},
]
stdin = io.StringIO("\n".join(json.dumps(request) for request in requests) + "\n")
monkeypatch.setattr(internal_bypasser, "get", _fake_get)
monkeypatch.setattr(internal_bypasser.sys, "stdin", stdin)
assert internal_bypasser._run_child_process() == 0
failed = json.loads((tmp_path / "1.json").read_text(encoding="utf-8"))
assert failed["ok"] is False
assert failed["error"] == "bypass exploded"
served = json.loads((tmp_path / "2.json").read_text(encoding="utf-8"))
assert served["ok"] is True
def test_result_file_becomes_visible_only_when_complete(tmp_path):
"""The parent treats the file's existence as the answer, so no partial writes."""
import shelfmark.bypass.internal_bypasser as internal_bypasser
result_path = tmp_path / "result.json"
internal_bypasser._publish_result(result_path, {"ok": True, "html": "<html/>"})
assert json.loads(result_path.read_text(encoding="utf-8"))["ok"] is True
assert list(tmp_path.iterdir()) == [result_path], "temporary file was left behind"
def test_child_bypass_runs_on_the_long_lived_worker_loop(monkeypatch):
"""A helper serving many requests must not build and close a loop per bypass.
asyncio.run() owns the loop for one call and closes it on the way out, which is why the
child goes through the worker unconditionally: one loop for the process's lifetime.
"""
import shelfmark.bypass.internal_bypasser as internal_bypasser
monkeypatch.setenv("SHELFMARK_INTERNAL_BYPASSER_CHILD", "1")
loops: list[asyncio.AbstractEventLoop] = []
async def _record_loop(_url, _driver, _cancel=None):
loops.append(asyncio.get_running_loop())
return "<html>ok</html>"
_bypass_with_recorded_driver(monkeypatch, _record_loop)
internal_bypasser._run_bypass_in_current_process("https://example.com", 1)
internal_bypasser._run_bypass_in_current_process("https://example.com", 1)
assert len(loops) == 2
assert loops[0] is loops[1], "second bypass ran on a different loop than the first"
assert not loops[0].is_closed()
def test_child_bypass_carries_its_own_deadline(monkeypatch):
"""The child bounds itself, rather than relying only on the parent's deadline."""
import shelfmark.bypass.internal_bypasser as internal_bypasser
monkeypatch.setenv("SHELFMARK_INTERNAL_BYPASSER_CHILD", "1")
timeouts: list[float | None] = []
real_run = internal_bypasser._CDP_WORKER.run
def _record_timeout(coro, timeout=None):
timeouts.append(timeout)
return real_run(coro, timeout=timeout)
async def _get(_url, _driver, _cancel=None):
return "<html>ok</html>"
_bypass_with_recorded_driver(monkeypatch, _get)
monkeypatch.setattr(internal_bypasser._CDP_WORKER, "run", _record_timeout)
internal_bypasser._run_bypass_in_current_process("https://example.com", 1)
assert timeouts == [internal_bypasser._CHILD_BYPASS_TIMEOUT_SECONDS]
def test_child_deadline_leaves_the_parent_room_to_hear_the_answer(monkeypatch):
"""If the parent gave up first it could only kill the helper, losing a warm process."""
import shelfmark.bypass.internal_bypasser as internal_bypasser
# The child's worst case is its deadline plus the grace it is given to close the
# browser after that deadline cancels the bypass, and all of it has to fit inside the
# parent's wait - otherwise the parent gives up first and kills a helper that was
# about to answer.
assert (
internal_bypasser._CHILD_BYPASS_TIMEOUT_SECONDS
+ internal_bypasser._CDP_UNWIND_GRACE_SECONDS
< internal_bypasser._BYPASS_SUBPROCESS_TIMEOUT_SECONDS
)
def test_in_process_bypass_keeps_the_parents_budget(monkeypatch):
"""Non-Docker installs run in-process, where there is no helper to outlive anything."""
import shelfmark.bypass.internal_bypasser as internal_bypasser
monkeypatch.delenv("SHELFMARK_INTERNAL_BYPASSER_CHILD", raising=False)
timeouts: list[float | None] = []
real_run = internal_bypasser._CDP_WORKER.run
def _record_timeout(coro, timeout=None):
timeouts.append(timeout)
return real_run(coro, timeout=timeout)
async def _get(_url, _driver, _cancel=None):
return "<html>ok</html>"
_bypass_with_recorded_driver(monkeypatch, _get)
monkeypatch.setattr(internal_bypasser._CDP_WORKER, "run", _record_timeout)
internal_bypasser._run_bypass_in_current_process("https://example.com", 1)
assert timeouts == [internal_bypasser._IN_PROCESS_BYPASS_TIMEOUT_SECONDS]
def test_timed_out_bypass_finishes_unwinding_before_the_call_returns():
"""A helper serving the next request must not race the browser teardown of the last.
The deadline cancels the bypass, but cancelling from the calling thread only schedules
that - it returns while `finally: await _close_cdp_driver(driver)` is still running.
In a helper that now outlives the request, the next bypass would open its Chrome on the
same loop while the abandoned one was still closing its own, sharing the DISPLAY
globals and one process group.
"""
import shelfmark.bypass.internal_bypasser as internal_bypasser
events: list[str] = []
async def _wedged():
try:
await asyncio.sleep(30)
finally:
# Teardown that yields, the way closing websockets and Chrome does.
await asyncio.sleep(0.05)
events.append("browser closed")
with pytest.raises(TimeoutError):
internal_bypasser._CDP_WORKER.run(_wedged(), timeout=0.1)
assert events == ["browser closed"], "run() returned before the bypass had unwound"
def test_unwind_that_wedges_does_not_hold_the_caller_forever(monkeypatch):
"""The grace is a bound, not a promise: cleanup can hang on a dead browser too."""
import shelfmark.bypass.internal_bypasser as internal_bypasser
monkeypatch.setattr(internal_bypasser, "_CDP_UNWIND_GRACE_SECONDS", 0.1)
async def _wedged_on_both_ends():
try:
await asyncio.sleep(30)
finally:
await asyncio.sleep(30)
with pytest.raises(TimeoutError):
internal_bypasser._CDP_WORKER.run(_wedged_on_both_ends(), timeout=0.1)
def test_cancelling_does_not_wait_out_the_shutdown_grace(monkeypatch, tmp_path):
"""The grace only helps a helper that can still read its stdin.
One dropped mid-bypass is blocked inside the solve and will never reach its read loop,
so waiting it out cannot end in anything but the kill - while the user who asked to
cancel, and every bypass queued behind them on LOCKED, waits for it.
"""
import threading
import shelfmark.bypass.internal_bypasser as internal_bypasser
procs: list[_FakeProc] = []
helper = _helper_with_fake_spawn(monkeypatch, procs)
cancel_flag = threading.Event()
cancel_flag.set()
payload = {
"url": "https://example.com",
"retry": 1,
"result_path": str(tmp_path / "never-written.json"),
}
with pytest.raises(internal_bypasser.BypassCancelledError):
helper.run(payload, timeout=5, cancel_flag=cancel_flag)
assert not procs[0].waited, "a helper wedged mid-bypass was given the full exit grace"
assert procs[0].stdin.closed
def test_idle_helper_still_gets_its_grace(monkeypatch, tmp_path):
"""The reaper drops a helper that *is* in its read loop, and that one gets to exit."""
procs: list[_FakeProc] = []
helper = _helper_with_fake_spawn(monkeypatch, procs)
helper.run(_answered_payload(tmp_path), timeout=5, cancel_flag=None)
helper._discard()
assert procs[0].waited, "an idle helper should be asked to exit before being killed"
def test_failed_request_leaves_no_result_files_behind(monkeypatch, tmp_path):
"""Result paths are unique per request, so anything left is left for good."""
procs: list[_FakeProc] = []
helper = _helper_with_fake_spawn(monkeypatch, procs)
result_path = tmp_path / "result.json"
# A helper killed part-way through _publish_result leaves the staging file.
(tmp_path / "result.json.part").write_text('{"ok": tr', encoding="utf-8")
payload = {"url": "https://example.com", "retry": 1, "result_path": str(result_path)}
with pytest.raises(TimeoutError):
helper.run(payload, timeout=0.05, cancel_flag=None)
assert list(tmp_path.iterdir()) == []
def test_child_does_not_export_cookies_left_by_an_earlier_request(monkeypatch, tmp_path):
"""The parent owns the store; a warm helper must not push its own history back over it.
http.py purges a host's clearance the moment that host challenges a request carrying
it. A helper that kept its store across requests would still be holding the purged
cookies, and the next solve - for some entirely different host - would export them and
the parent would merge them straight back in.
"""
import shelfmark.bypass.internal_bypasser as internal_bypasser
def _solve_host(url, retry=None, cancel_flag=None):
# A solve fills the store for the host it solved, which is all it should report.
host = url.rsplit("/", 1)[-1]
internal_bypasser.import_store({host: {"cf_clearance": "fresh"}}, {host: "UA"})
return "<html>ok</html>"
monkeypatch.setattr(internal_bypasser, "get", _solve_host)
internal_bypasser.clear_cf_cookies()
for index, host in enumerate(("first.example", "second.example")):
internal_bypasser._handle_child_request(
json.dumps(
{
"url": f"https://example.com/{host}",
"retry": 1,
"result_path": str(tmp_path / f"{index}.json"),
}
)
)
second = json.loads((tmp_path / "1.json").read_text(encoding="utf-8"))
assert list(second["cookies"]) == ["second.example"], (
"the helper exported clearance won by an earlier request"
)
assert list(second["user_agents"]) == ["second.example"]
internal_bypasser.clear_cf_cookies()
def _record_dns_calls(monkeypatch):
"""Stand in for the network module: report a resolver state, record changes to it.
A helper starts on system DNS, which is what the parent reports as "auto".
"""
import shelfmark.bypass.internal_bypasser as internal_bypasser
calls: list[tuple] = []
state = {"provider": "auto", "servers": [], "doh_enabled": False}
def _set(provider, servers=None, use_doh=None):
calls.append((provider, servers, use_doh))
state.update({"provider": provider, "servers": servers or [], "doh_enabled": bool(use_doh)})
monkeypatch.setattr(internal_bypasser.network, "set_dns_provider", _set)
monkeypatch.setattr(internal_bypasser.network, "get_dns_config", lambda: dict(state))
return internal_bypasser, calls
def test_helper_follows_the_parent_back_to_auto_dns(monkeypatch):
"""A user flipping CUSTOM_DNS back to auto applies live - the helper has to hear it.
The old early-return on "auto" was correct only because a fresh helper had never been
told anything else. One that outlives the request has, and would go on resolving AA
through a resolver the parent has already abandoned.
"""
internal_bypasser, calls = _record_dns_calls(monkeypatch)
internal_bypasser._apply_parent_dns_config(
{"provider": "cloudflare", "servers": [], "doh_enabled": True}
)
internal_bypasser._apply_parent_dns_config(
{"provider": "auto", "servers": [], "doh_enabled": False}
)
assert calls == [("cloudflare", None, True), ("auto", None, False)]
def test_helper_does_not_reinitialize_dns_for_an_unchanged_config(monkeypatch):
"""set_dns_provider() rebuilds resolvers; every request would pay for it otherwise."""
internal_bypasser, calls = _record_dns_calls(monkeypatch)
for _ in range(3):
internal_bypasser._apply_parent_dns_config(
{"provider": "quad9", "servers": [], "doh_enabled": True}
)
assert calls == [("quad9", None, True)]
def test_fresh_helper_leaves_auto_dns_alone(monkeypatch):
"""A helper starts on system DNS, which is what the parent reports as auto."""
internal_bypasser, calls = _record_dns_calls(monkeypatch)
internal_bypasser._apply_parent_dns_config(
{"provider": "auto", "servers": [], "doh_enabled": False}
)
assert calls == []
def test_failed_dns_apply_is_retried_on_the_next_request(monkeypatch):
"""A provider that did not land leaves the resolver where it was, so the next request
sees the same mismatch and tries again."""
internal_bypasser, calls = _record_dns_calls(monkeypatch)
def _explode(provider, servers=None, use_doh=None):
calls.append((provider, servers, use_doh))
msg = "resolver unreachable"
raise OSError(msg)
monkeypatch.setattr(internal_bypasser.network, "set_dns_provider", _explode)
config = {"provider": "google", "servers": [], "doh_enabled": True}
internal_bypasser._apply_parent_dns_config(config)
internal_bypasser._apply_parent_dns_config(config)
assert calls == [("google", None, True), ("google", None, True)]
+127
View File
@@ -0,0 +1,127 @@
"""Tests for handing a 503 that carries a browser challenge to the bypasser."""
import requests
_CHALLENGE_HTML = (
"<html><head><title>Checking your browser before accessing z-lib.gd</title>"
"<script src='/.well-known/ddos-guard/check.js'></script></head>"
"<body>Please wait...</body></html>"
)
class _FakeResponse:
"""Minimal stand-in for requests.Response covering what html_get_page touches."""
def __init__(
self,
status_code: int,
*,
url: str = "https://z-lib.gd/md5/abc",
text: str = "",
cookies: dict[str, str] | None = None,
) -> None:
self.status_code = status_code
self.url = url
self.text = text
self.cookies = cookies or {}
self.headers = {"Content-Type": "text/html;charset=utf-8"}
self.is_redirect = False
def raise_for_status(self) -> None:
if self.status_code >= 400:
error = requests.exceptions.HTTPError(f"{self.status_code} Error")
error.response = self
raise error
def _neutralize_network(monkeypatch, http):
monkeypatch.setattr(http, "_apply_cf_bypass", lambda _url, _headers: {})
monkeypatch.setattr(http, "get_proxies", lambda _url: {})
monkeypatch.setattr(http, "get_ssl_verify", lambda _url: True)
monkeypatch.setattr(http.network, "should_rotate_dns_for_url", lambda _url: False)
monkeypatch.setattr(http.time, "sleep", lambda _seconds: None)
def test_503_challenge_is_handed_to_the_bypasser(monkeypatch):
"""The reissued-cookie 503 from #1233 reaches the bypasser instead of retrying."""
import shelfmark.download.http as http
_neutralize_network(monkeypatch, http)
monkeypatch.setattr(http, "_is_cf_bypass_enabled", lambda: True)
attempts: list[dict[str, str]] = []
bypassed: list[str] = []
def fake_get(_url: str, **kwargs):
attempts.append(dict(kwargs["cookies"]))
# Hit 1 issues the cookie; every later hit re-serves the challenge unchanged,
# which is what leaves the handshake with nothing to echo back.
if len(attempts) == 1:
return _FakeResponse(503, cookies={"bsrv": "1"})
return _FakeResponse(503, text=_CHALLENGE_HTML, cookies={"bsrv": "1"})
def fake_bypass(url: str, _selector=None, _cancel_flag=None):
bypassed.append(url)
return "<html>real page</html>"
monkeypatch.setattr(http.requests, "get", fake_get)
monkeypatch.setattr(http, "get_bypassed_page", fake_bypass)
html = http.html_get_page("https://z-lib.gd/md5/abc", retry=10, success_delay=0)
assert html == "<html>real page</html>"
assert bypassed == ["https://z-lib.gd/md5/abc"]
# The handshake still gets its echo; the challenge ends the loop on the second hit
# rather than burning all ten attempts.
assert attempts == [{}, {"bsrv": "1"}]
def test_plain_503_still_retries_without_bypassing(monkeypatch):
"""An overloaded origin has no challenge marker, so its retry path is untouched."""
import shelfmark.download.http as http
_neutralize_network(monkeypatch, http)
monkeypatch.setattr(http, "_is_cf_bypass_enabled", lambda: True)
attempts: list[dict[str, str]] = []
bypassed: list[str] = []
def fake_get(_url: str, **kwargs):
attempts.append(dict(kwargs["cookies"]))
return _FakeResponse(503, text="<html><body>Service Unavailable</body></html>")
monkeypatch.setattr(http.requests, "get", fake_get)
monkeypatch.setattr(
http, "get_bypassed_page", lambda url, *_a, **_k: bypassed.append(url) or ""
)
html = http.html_get_page("https://z-lib.gd/md5/abc", retry=3, success_delay=0)
assert html == ""
assert bypassed == []
assert attempts == [{}, {}, {}]
def test_503_challenge_respects_disabled_bypasser_fallback(monkeypatch):
"""Best-effort fetches must not stall on a minutes-long solve."""
import shelfmark.download.http as http
_neutralize_network(monkeypatch, http)
monkeypatch.setattr(http, "_is_cf_bypass_enabled", lambda: True)
bypassed: list[str] = []
monkeypatch.setattr(
http.requests,
"get",
lambda _url, **_kwargs: _FakeResponse(503, text=_CHALLENGE_HTML),
)
monkeypatch.setattr(
http, "get_bypassed_page", lambda url, *_a, **_k: bypassed.append(url) or ""
)
html = http.html_get_page(
"https://z-lib.gd/md5/abc", retry=1, success_delay=0, allow_bypasser_fallback=False
)
assert html == ""
assert bypassed == []
+53
View File
@@ -0,0 +1,53 @@
import pytest
from shelfmark.metadata_providers import hardcover
from shelfmark.metadata_providers.hardcover import (
HardcoverProvider,
_test_hardcover_connection,
)
# Hardcover replaced its ~500 char JWTs with short opaque tokens.
PAT = "hc_pat_" + "a" * 32
@pytest.fixture(autouse=True)
def _no_config_writes(monkeypatch):
"""Keep the connection test from touching the on-disk provider config."""
monkeypatch.setattr(hardcover, "_save_connected_user", lambda user_id, username: None)
class TestHardcoverApiKey:
def test_personal_access_token_is_accepted(self, monkeypatch):
monkeypatch.setattr(
HardcoverProvider,
"_execute_query",
lambda self, query, variables: {"me": [{"id": 1, "username": "alex"}]},
)
result = _test_hardcover_connection({"HARDCOVER_API_KEY": PAT})
assert result == {"success": True, "message": "Connected as: alex"}
def test_short_key_without_the_prefix_is_rejected(self):
result = _test_hardcover_connection({"HARDCOVER_API_KEY": "eyJhbGciOiJIUzI1NiJ9.short"})
assert result["success"] is False
assert "too short" in result["message"]
def test_short_prefixed_key_still_reaches_the_api(self, monkeypatch):
"""A key wearing the hc_pat_ prefix is Hardcover's to accept or reject."""
monkeypatch.setattr(
HardcoverProvider,
"_execute_query",
lambda self, query, variables: None,
)
result = _test_hardcover_connection({"HARDCOVER_API_KEY": "hc_pat_ab"})
assert result == {"success": False, "message": "API request failed - check your API key"}
@pytest.mark.parametrize("pasted", [f"Bearer {PAT}", f"bearer {PAT}", f" {PAT} "])
def test_pasted_auth_header_noise_is_stripped(self, pasted):
provider = HardcoverProvider(api_key=pasted)
assert provider.session.headers["Authorization"] == f"Bearer {PAT}"
+56
View File
@@ -727,6 +727,62 @@ class TestProwlarrHandlerSeedCriteria:
assert call_kwargs["ratio_limit"] == 1.25
class TestProwlarrHandlerContentType:
"""Regression tests for issue #1235 — content type must reach the client."""
def test_download_passes_content_type_to_client(self):
"""rTorrent picks its audiobook label from content_type, not category."""
mock_client = MagicMock()
mock_client.name = "rtorrent"
mock_client.find_existing.return_value = None
mock_client.add_download.return_value = "download_id"
with (
patch(
"shelfmark.release_sources.prowlarr.handler.get_release",
return_value={
"protocol": "torrent",
"title": "Test Release",
"magnetUrl": "magnet:?xt=urn:btih:abc123",
},
),
patch(
"shelfmark.release_sources.prowlarr.handler.get_client",
return_value=mock_client,
),
patch(
"shelfmark.release_sources.prowlarr.handler.remove_release",
),
patch("shelfmark.release_sources.prowlarr.handler.config.get", return_value=True),
patch.object(
ProwlarrHandler,
"_poll_and_complete",
return_value=None,
),
):
handler = ProwlarrHandler()
task = DownloadTask(
task_id="content-type-pass-through",
source="prowlarr",
title="Test Audiobook",
content_type="audiobook",
)
cancel_flag = Event()
recorder = ProgressRecorder()
handler.download(
task=task,
cancel_flag=cancel_flag,
progress_callback=recorder.progress_callback,
status_callback=recorder.status_callback,
)
call_kwargs = mock_client.add_download.call_args.kwargs
assert call_kwargs["content_type"] == "audiobook"
# rTorrent gets no category, so content_type is its only audiobook signal.
assert call_kwargs["category"] is None
class TestProwlarrHandlerExistingDownload:
"""Tests for handling existing downloads."""
+30
View File
@@ -563,6 +563,36 @@ class TestRTorrentClientAudiobookLabel:
assert "d.custom1.set=books" in args[2]
assert "d.custom1.set=audiobooks" not in args[2]
def test_uses_audiobook_label_for_compound_content_type(self, monkeypatch):
"""Issue #1235 — an exact "audiobook" match missed forms like "book (audiobook)"."""
config_values = {
"RTORRENT_URL": "http://localhost:8080/RPC2",
"RTORRENT_LABEL": "books",
"RTORRENT_AUDIOBOOK_LABEL": "audiobooks",
"RTORRENT_DOWNLOAD_DIR": "/downloads",
}
mock_rpc, mock_xmlrpc, mock_torrent_info = self._make_client(monkeypatch, config_values)
with patch.dict("sys.modules", {"xmlrpc.client": mock_xmlrpc}):
with patch(
"shelfmark.download.clients.torrent_utils.extract_torrent_info",
return_value=mock_torrent_info,
):
if "shelfmark.download.clients.rtorrent" in sys.modules:
del sys.modules["shelfmark.download.clients.rtorrent"]
from shelfmark.download.clients.rtorrent import RTorrentClient
client = RTorrentClient()
client.add_download(
"magnet:?xt=urn:btih:abc123",
"Test Audiobook",
content_type="Book (Audiobook)",
)
args = mock_rpc.load.start.call_args[0]
assert "d.custom1.set=audiobooks" in args[2]
assert "d.custom1.set=books" not in args[2]
class TestRTorrentClientGetStatus:
"""Tests for RTorrentClient.get_status()."""
Generated
+4 -4
View File
@@ -1243,16 +1243,16 @@ wheels = [
[[package]]
name = "qbittorrent-api"
version = "2026.8.0"
version = "2026.8.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "packaging" },
{ name = "requests" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/eb/d2/4a6d6ae6aaca1ba83039d27f28e5bf30bacef27ff4eb635c729d2d582363/qbittorrent_api-2026.8.0.tar.gz", hash = "sha256:4127a9c1d5c7ad9cd4e8de16f83daeea2e8b2ed962f99f284d238f927479391a", size = 1430678, upload-time = "2026-08-01T21:05:55.88Z" }
sdist = { url = "https://files.pythonhosted.org/packages/04/00/b7f41dfe8af6c3991f7182f9c14c46851379b8e66ac74c819f0952e9923d/qbittorrent_api-2026.8.1.tar.gz", hash = "sha256:9642c4528baee67216eab87022ce297048ac96df2f8c9f1cf920de078eb50b62", size = 1436760, upload-time = "2026-08-16T20:30:28.35Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5b/81/b456688c20e75cae5964700b58712630930b9dfaf32ce7c6a8881658d0d0/qbittorrent_api-2026.8.0-py3-none-any.whl", hash = "sha256:03b3db598d351194fd3c65f1d3e53843f65f85bef1d5077791efaa2e26869d2b", size = 68196, upload-time = "2026-08-01T21:05:54.376Z" },
{ url = "https://files.pythonhosted.org/packages/e6/60/77c7b63675f12ba492dee9f8aabab0fc61ced5d2131a83bd7e92dcc335c1/qbittorrent_api-2026.8.1-py3-none-any.whl", hash = "sha256:c17260e416ec5832c3ed7aff46fb0e2b47e70b90de182e89c34133b9d4ce5a6e", size = 71970, upload-time = "2026-08-16T20:30:26.67Z" },
]
[[package]]
@@ -1516,7 +1516,7 @@ requires-dist = [
{ name = "python-socketio" },
{ name = "python-xlib", marker = "extra == 'browser'" },
{ name = "pyvirtualdisplay", marker = "extra == 'browser'" },
{ name = "qbittorrent-api", specifier = ">=2026.8.0" },
{ name = "qbittorrent-api", specifier = ">=2026.8.1" },
{ name = "rarfile" },
{ name = "requests", extras = ["socks"] },
{ name = "seleniumbase", marker = "extra == 'browser'", specifier = "==4.51.12" },