Compare commits

..
13 Commits
Author SHA1 Message Date
CaliBrain 9452ebc70d fix(bypass): stop handing solvers DDoS-Guard's ?check=1 probe URL (#1300)
html_get_page follows Anna's Archive redirects by hand, and DDoS-Guard's
gate
answers /search with a 302 to the same path plus `check=1`. The follower
walks
that handshake by reassigning `current_url`, so every downstream handoff
- the
403 branch, the 503-challenge branch, both redirect-loop rescues -
passed the
*probe* URL to the bypasser rather than the page we actually wanted.

A solver opens that in a fresh browser holding none of the cookies the
probe
exists to collect, so DDoS-Guard cannot verify it automatically and
serves the
manual CAPTCHA page that nothing can solve. The #1292 log is exactly
that: a 403
handed off on `&check=1`, FlareSolverr answering "Challenge solved!",
and a
4721-byte DDOS-GUARD captcha page coming back.

- `_solvable_url()` strips the probe parameter, applied at the single
choke point
in `_run_bypasser` so all four handoffs are covered. Scoped to the hosts
whose
redirects we follow manually; a URL without the parameter is returned by
  identity, so nothing else is re-encoded.

The same reports showed three further defects, all of which stand
whatever the
host was reacting to:

- The external bypasser logged that the solve had not cleared the
protection and
then returned the challenge page as a success. That skipped the one
recovery
left - get_bypassed_page's retry-and-rotate loop, where the next mirror
is a
different DDoS-Guard host - and filed the captcha page's own __ddg
cookies as
that host's clearance, to be replayed on every later request. It now
raises
  ChallengeNotSolvedError before storing anything.

- "Check that the bypasser is reachable and working" was the one piece
of advice
guaranteed to waste the reporter's time: it was reachable, it ran a full
solve,
and it returned a captcha. ChallengeNotSolvedError carries the marker so
the
  search layer can name the host as the cause instead of the bypasser.

- The untabled-page fingerprint logged `attempt_url`, which
html_get_page has
since rotated past. The #1298 bundle reported the page against
annas-archive.gl
when the body had come from .pk - the triage cost #1289 added the line
to
remove. The search now asks for the response URL and logs that. Its
give-up
shape is the tuple ("", url), which is truthy, so the exhaustion check
reads
  the body rather than the response.

Regression fixtures are built from the pages in the reports. The two
behavioural
handoff tests were checked against the unfixed code: both fail there,
reproducing
the reporter's log line verbatim.

Refs #1292
Refs #1298
2026-09-02 16:12:52 -04:00
CaliBrain d3f4ccd79a seleniumbase==4.53.5 (#1299)
replaces #1296
2026-09-02 15:48:48 -04:00
CaliBrain cb690b45b8 fix(prowlarr): rank releases by author instead of querying for it (#1293) (#1295)
MyAnonamouse is the only indexer Shelfmark treats as enriched, and it
alone was sent {title} {author} while every other indexer got the title
on its own. MAM matches all search terms conjunctively, so whenever the
metadata provider spelled the author differently to the tracker -
Hardcover says Timothy Ferriss, MAM lists Tim Ferriss - the search came
back empty and the UI reported No releases found for this book, with the
release sitting on the tracker the whole time.

The enriched flag is a statement about responses: MAM returns clean
author and bookTitle attributes, which is why it earns format detection
and preferential ordering. Using that same flag to shape the request is
the actual defect, and it is why turning the flag off recovers the
search but takes format detection down with it.

So the query is title-only for every indexer now, and the author orders
the results rather than narrowing them. MAM already hands us its author
field, so agreement is judged on data we hold instead of by an AND we
cannot control. The ranking is three-way on purpose - agrees, no
metadata, disagrees - so an indexer reporting no author does not sort
below one reporting the wrong author.

A wrong verdict costs a release its position, never its visibility: a
transliteration such as Dostoevsky against Dostoyevsky sorts last
instead of vanishing. That is what makes the loose token comparison safe
to ship without a tuning knob.

Falling back to a title-only query on zero results was the alternative.
It only rescues total failure - if two of six editions happen to use the
provider's spelling, the search returns those two, no fallback fires,
and the user quietly gets a truncated list. It also spends a round trip
inside the search deadline and stacks a retry on an indexer that may
still be solving a challenge (#1249).

Manual queries skip author ranking: they are the user's own words and
should not be reordered against the metadata they were typed to
override.
2026-09-01 12:59:33 -04:00
CaliBrain 3d7ea40088 fix(search): reach the server's deadline, query one author (#1285, #1252) (#1287)
Two independent reasons a working search reported failure to the user.

1. The client gave up before the server did (#1285)

`/api/releases` bounds one release search with RELEASE_SEARCH_TIMEOUT
(default
300s) and answers a spent budget with a sentence naming the real cause -
the
machinery added for #1276. The frontend then aborted the direct_download
search
at a hard-coded 180s, so it always won the race: the user saw "Request
timed
out. Check your network connection or proxy configuration." instead, and
raising RELEASE_SEARCH_TIMEOUT changed nothing they could observe, the
180s
being baked into the hashed bundle inside the image.

- /api/config reports the effective (clamped) budget, and the client
derives its
  abort from it plus a margin, so the server always answers first.
- Direct-mode search shows what the server actually said. Every non-auth
failure
was relabelled "Unable to reach download source. Network may be
restricted or
mirrors blocked.", which discarded the explanation and blamed the user's
network. ApiResponseError now carries `serverMessage`, set only when the
server
  explained itself, so the status-line placeholder still falls back.

Two latency fixes for the cost that made the timeout reachable at all:

- Fetch each distinct AA search URL once per search. The language-filter
retry
re-runs every title variant, and with DIRECT_DOWNLOAD_LANGUAGE_FROM_PATH
on
both passes build a byte-identical URL - behind DDoS-Guard each repeat
is a
  fresh browser solve.
- Drop the solve-only bypass method. `_bypass_method_cdp_gui_click`
opens with
exactly that call and returns the moment it works, so the entry ahead of
it
could only repeat the half that had already failed, plus the backoff
before
the method that does work started. Reported at 0/19 successes and ~5.5s
of
  each ~26s solve against DDoS-Guard.

2. The query carried every contributor, not one author (#1252)

`_pick_search_author` returned `book.search_author` verbatim while the
authors[]
fallback beside it deliberately narrowed to the first name before a
comma. Both
fields routinely arrive holding every contributor joined with ", ": the
frontend
builds `book.author` as `authors.join(', ')` for display
(bookTransformers.ts)
and the release modal sends that display string straight back as the
`author`
parameter, and `browse_record_to_book_metadata` and the manual-search
branch
both split the joined text into `authors` while still passing the
unsplit string
as `search_author`, so the split was never used.

A book whose metadata lists translators was therefore searched for as

    Blindness Jose Saramago, Giovanni Pontiero, <persian translator>

which matches nothing on Anna's Archive. The bypass succeeds, the search
comes
back empty, and the user is told the book has no releases.

Narrowed in one place, `search_plan.first_author`, so the two branches
cannot
drift apart again, and applied to the IRC source, which built its query
with the
same verbatim preference. Hardcover is unaffected: it already sets
`search_author` from `_simplify_author_for_search(authors[0])`, which
resolves
"Last, First" itself and never yields a multi-author string.
2026-09-01 12:38:57 -04:00
CaliBrain 633004ecf0 fix(search): stop reading real Anna's Archive pages as unsolved challenges (#1294)
`_looks_like_challenge_page` substring-matched "ddos-guard"/"cloudflare"
over
the whole document. DDoS-Guard-fronted sites carry those strings on
their own
pages - Anna's Archive ships a `DDOS-GUARD` comment in the inline JS it
serves
on every page - so every real AA response that was not a results table
was
reported as an unsolved protection challenge, sending users off to fix a
bypasser that had just succeeded.

Measured against live pages: a served AA page (HTTP 200) is 182,685
bytes and
matched the old detector; the real interstitial is 902 bytes.

- `_looks_like_challenge_page` now delegates to the shared
`challenge_marker()`,
whose 64 KB cap is what separates a few-KB interstitial from the page
behind
it. `download/http.py` already used it; this module carried an unguarded
  private copy.
- `_looks_like_aa_page` is checked ahead of the challenge branch. A
genuine
interstitial carries no AA markers, so nothing actually blocked leaks
through.

Also adds the diagnostics whose absence made #1289 guesswork: the debug
bundle
carries no response bodies, so "unsolved protection challenge" and
FlareSolverr's
"Challenge solved!" were indistinguishable after the fact.

- `_log_untabled_search_page()` fingerprints the one ambiguous shape at
INFO -
size, size-cap verdict, AA markers, challenge marker - with a bounded
700-char
  head at DEBUG. Best-effort: it swallows its own errors.
- The external bypasser records what it actually returned, and warns
when it
  reports success while handing back a challenge page.

Regression tests use fixtures built from the live pages rather than
invented
ones; the previous fixtures were two-line synthetic pages with no
"ddos-guard"
substring, which is why nothing caught this.

Closes #1289
Closes #1292
2026-09-01 11:19:01 -04:00
dependabot[bot] c06b8ce8ef build(deps): bump the python-deps group with 3 updates (#1288)
Bumps the python-deps group with 3 updates:
[seleniumbase](https://github.com/seleniumbase/SeleniumBase),
[prek](https://github.com/j178/prek) and
[ruff](https://github.com/astral-sh/ruff).

Updates `seleniumbase` from 4.52.3 to 4.52.4
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/seleniumbase/SeleniumBase/releases">seleniumbase's
releases</a>.</em></p>
<blockquote>
<h2>4.52.4 - Add Remote WebDriver Timeout setting</h2>
<ul>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/cf4af2a92414010ca49c5ba14e59cf73dc8badea">Add
REMOTE_WEBDRIVER_TIMEOUT setting for Remote WebDriver HTTP
requests</a></li>
</ul>
<h2>What's Changed</h2>
<ul>
<li>Add REMOTE_WEBDRIVER_TIMEOUT setting for Remote WebDriver HTTP
requests by <a
href="https://github.com/TaylorMcGinnis"><code>@​TaylorMcGinnis</code></a>
in <a
href="https://redirect.github.com/seleniumbase/SeleniumBase/pull/4473">seleniumbase/SeleniumBase#4473</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/seleniumbase/SeleniumBase/compare/v4.52.3...v4.52.4">https://github.com/seleniumbase/SeleniumBase/compare/v4.52.3...v4.52.4</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/5879697828e55a6c6ef8f7436c01023e3af92108"><code>5879697</code></a>
Version 4.52.4</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/2182f3c9ece14ac0cc1d4fdb4c24470e0e0ac63e"><code>2182f3c</code></a>
Merge pull request <a
href="https://redirect.github.com/seleniumbase/SeleniumBase/issues/4473">#4473</a>
from TaylorMcGinnis/remote-webdriver-timeout</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/ffe06d572308b7cca77644dd22f7560dd3c4eda4"><code>ffe06d5</code></a>
Update GitHub Actions</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/cf4af2a92414010ca49c5ba14e59cf73dc8badea"><code>cf4af2a</code></a>
Add REMOTE_WEBDRIVER_TIMEOUT setting for Remote WebDriver HTTP
requests</li>
<li>See full diff in <a
href="https://github.com/seleniumbase/SeleniumBase/compare/v4.52.3...v4.52.4">compare
view</a></li>
</ul>
</details>
<br />

Updates `prek` from 0.4.14 to 0.5.0
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/j178/prek/releases">prek's
releases</a>.</em></p>
<blockquote>
<h2>0.5.0</h2>
<h2>Release Notes</h2>
<p>Released on 2026-08-27.</p>
<h3>Highlights</h3>
<h4>Choose where hook toolchains come from</h4>
<p><code>language_version</code> now accepts a source
<code>preference</code> alongside the version
<code>request</code>, letting you control where prek looks for a
compatible toolchain when
it creates a hook environment. Use <code>managed</code> (the default) or
<code>system</code> to choose
which source prek tries first while still allowing fallback and
downloads. Use
<code>only-managed</code> or <code>only-system</code> to require one
source.</p>
<p>For example, this local Ruff hook requires a Python 3.12 toolchain
managed by
prek:</p>
<pre lang="yaml"><code>repos:
  - repo: local
    hooks:
      - id: ruff
        name: ruff
        language: python
        entry: ruff check
        additional_dependencies: [ruff]
        language_version:
          request: &quot;3.12&quot;
          preference: only-managed
</code></pre>
<p>With <code>only-managed</code>, prek reuses a compatible toolchain
from its managed store
or downloads one when needed. It never falls back to Python from
<code>PATH</code>, an OS
package manager, or a version manager, so toolchain selection does not
depend on
the developer or CI machine's external environment.</p>
<p>Existing scalar values such as <code>language_version:
&quot;3.12&quot;</code> continue to work. See
<a
href="https://prek.j178.dev/0.5.0/languages/#toolchain-management-and-language_version">toolchain
management and <code>language_version</code></a>
for the full source-selection behavior. (<a
href="https://redirect.github.com/j178/prek/pull/2613">#2613</a>)</p>
<h3>Breaking changes</h3>
<p>The breaking changes in this release are mostly small cleanups, and
most users should not be affected.</p>
<ul>
<li>Group names can no longer start with <code>@</code>. This prefix is
now reserved for special group selectors such as the new
<code>@ungrouped</code> selector. (<a
href="https://redirect.github.com/j178/prek/pull/2617">#2617</a>)</li>
<li><code>PREK_MAX_CONCURRENCY</code> has been removed. Use
<code>PREK_CONCURRENT_HOOKS</code> and
<code>PREK_CONCURRENT_BATCHES</code> to control hook and per-hook batch
concurrency separately. (<a
href="https://redirect.github.com/j178/prek/pull/2620">#2620</a>)</li>
<li>The top-level <code>prek init-template-dir</code> command has been
removed. Use <code>prek util init-template-dir</code>, or <code>prek
init-templatedir</code> for drop-in compatibility with
<code>pre-commit</code>. (<a
href="https://redirect.github.com/j178/prek/pull/2623">#2623</a>)</li>
<li><code>prek auto-update</code> has been removed. Use <code>prek
update</code>, or <code>prek autoupdate</code> for drop-in compatibility
with <code>pre-commit</code>. (<a
href="https://redirect.github.com/j178/prek/pull/2619">#2619</a>)</li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/j178/prek/blob/master/CHANGELOG.md">prek's
changelog</a>.</em></p>
<blockquote>
<h2>0.5.0</h2>
<p>Released on 2026-08-27.</p>
<h3>Highlights</h3>
<h4>Choose where hook toolchains come from</h4>
<p><code>language_version</code> now accepts a source
<code>preference</code> alongside the version
<code>request</code>, letting you control where prek looks for a
compatible toolchain when
it creates a hook environment. Use <code>managed</code> (the default) or
<code>system</code> to choose
which source prek tries first while still allowing fallback and
downloads. Use
<code>only-managed</code> or <code>only-system</code> to require one
source.</p>
<p>For example, this local Ruff hook requires a Python 3.12 toolchain
managed by
prek:</p>
<pre lang="yaml"><code>repos:
  - repo: local
    hooks:
      - id: ruff
        name: ruff
        language: python
        entry: ruff check
        additional_dependencies: [ruff]
        language_version:
          request: &quot;3.12&quot;
          preference: only-managed
</code></pre>
<p>With <code>only-managed</code>, prek reuses a compatible toolchain
from its managed store
or downloads one when needed. It never falls back to Python from
<code>PATH</code>, an OS
package manager, or a version manager, so toolchain selection does not
depend on
the developer or CI machine's external environment.</p>
<p>Existing scalar values such as <code>language_version:
&quot;3.12&quot;</code> continue to work. See
<a
href="https://prek.j178.dev/0.5.0/languages/#toolchain-management-and-language_version">toolchain
management and <code>language_version</code></a>
for the full source-selection behavior. (<a
href="https://redirect.github.com/j178/prek/pull/2613">#2613</a>)</p>
<h3>Breaking changes</h3>
<p>The breaking changes in this release are mostly small cleanups, and
most users should not be affected.</p>
<ul>
<li>Group names can no longer start with <code>@</code>. This prefix is
now reserved for special group selectors such as the new
<code>@ungrouped</code> selector. (<a
href="https://redirect.github.com/j178/prek/pull/2617">#2617</a>)</li>
<li><code>PREK_MAX_CONCURRENCY</code> has been removed. Use
<code>PREK_CONCURRENT_HOOKS</code> and
<code>PREK_CONCURRENT_BATCHES</code> to control hook and per-hook batch
concurrency separately. (<a
href="https://redirect.github.com/j178/prek/pull/2620">#2620</a>)</li>
<li>The top-level <code>prek init-template-dir</code> command has been
removed. Use <code>prek util init-template-dir</code>, or <code>prek
init-templatedir</code> for drop-in compatibility with
<code>pre-commit</code>. (<a
href="https://redirect.github.com/j178/prek/pull/2623">#2623</a>)</li>
<li><code>prek auto-update</code> has been removed. Use <code>prek
update</code>, or <code>prek autoupdate</code> for drop-in compatibility
with <code>pre-commit</code>. (<a
href="https://redirect.github.com/j178/prek/pull/2619">#2619</a>)</li>
</ul>
<h3>Enhancements</h3>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/j178/prek/commit/67f85359486c57b0fc145ae948283713bf33bf94"><code>67f8535</code></a>
Bump version to 0.5.0 (<a
href="https://redirect.github.com/j178/prek/issues/2631">#2631</a>)</li>
<li><a
href="https://github.com/j178/prek/commit/4546befaacb22177be1a7590fb944cad0664ad73"><code>4546bef</code></a>
Remove legacy <code>init-template-dir</code> command (<a
href="https://redirect.github.com/j178/prek/issues/2623">#2623</a>)</li>
<li><a
href="https://github.com/j178/prek/commit/e63bd4be1ead8c09effeac5c451c5efd94e1f643"><code>e63bd4b</code></a>
Remove hook marker schema 0 (<a
href="https://redirect.github.com/j178/prek/issues/2622">#2622</a>)</li>
<li><a
href="https://github.com/j178/prek/commit/8c433ae73124cef61c390217e0163adbb6a17cd9"><code>8c433ae</code></a>
Remove config-tracking cache bootstrap (<a
href="https://redirect.github.com/j178/prek/issues/2621">#2621</a>)</li>
<li><a
href="https://github.com/j178/prek/commit/62ca460aea7d5e24ca27e79cd1128ab91ce396c1"><code>62ca460</code></a>
Remove <code>PREK_MAX_CONCURRENCY</code> (<a
href="https://redirect.github.com/j178/prek/issues/2620">#2620</a>)</li>
<li><a
href="https://github.com/j178/prek/commit/e2468eae017a0cb5e68fd54f96e6b66f5d6db598"><code>e2468ea</code></a>
Remove legacy update aliases (<a
href="https://redirect.github.com/j178/prek/issues/2619">#2619</a>)</li>
<li><a
href="https://github.com/j178/prek/commit/795c3a46b3c1b20a343a5fe8ec0d1b9fea79ade9"><code>795c3a4</code></a>
Group run options in CLI help (<a
href="https://redirect.github.com/j178/prek/issues/2629">#2629</a>)</li>
<li><a
href="https://github.com/j178/prek/commit/b27eb6edf4779994f4e484be605119291f858fc6"><code>b27eb6e</code></a>
Document prek run architecture (<a
href="https://redirect.github.com/j178/prek/issues/2630">#2630</a>)</li>
<li><a
href="https://github.com/j178/prek/commit/7dd9aa9149854bb0a37bde6ed64e3d1b42c46fdb"><code>7dd9aa9</code></a>
Avoid persisting docs workflow credentials (<a
href="https://redirect.github.com/j178/prek/issues/2627">#2627</a>)</li>
<li><a
href="https://github.com/j178/prek/commit/23815bdd83ce766e93647a98cfc091ec15b0d9ba"><code>23815bd</code></a>
Remove the <code>@builtin</code> group selector (<a
href="https://redirect.github.com/j178/prek/issues/2628">#2628</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/j178/prek/compare/v0.4.14...v0.5.0">compare
view</a></li>
</ul>
</details>
<br />

Updates `ruff` from 0.16.4 to 0.16.5
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/astral-sh/ruff/releases">ruff's
releases</a>.</em></p>
<blockquote>
<h2>0.16.5</h2>
<h2>Release Notes</h2>
<p>Released on 2026-08-27.</p>
<h3>Preview features</h3>
<ul>
<li>Allow rules without codes (<a
href="https://redirect.github.com/astral-sh/ruff/pull/28049">#28049</a>)</li>
<li>Introduce category selectors (<a
href="https://redirect.github.com/astral-sh/ruff/pull/27666">#27666</a>)</li>
<li>Update preview default rules and categories (<a
href="https://redirect.github.com/astral-sh/ruff/pull/27877">#27877</a>)</li>
</ul>
<h3>Bug fixes</h3>
<ul>
<li>[<code>flake8-async</code>] Detect blocking generic HTTP requests
(<code>ASYNC210</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/28024">#28024</a>)</li>
<li>[<code>flake8-datetimez</code>] Allow timezone-safe
<code>strptime</code> chains (<code>DTZ007</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/28023">#28023</a>)</li>
<li>[<code>flake8-simplify</code>] Respect side effects in
<code>lambda</code> defaults (<code>SIM401</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/28000">#28000</a>)</li>
</ul>
<h3>Server</h3>
<ul>
<li>Fix duplicated &quot;of&quot; in <code>ClientOptions</code> doc
comment (<a
href="https://redirect.github.com/astral-sh/ruff/pull/27978">#27978</a>)</li>
</ul>
<h3>Documentation</h3>
<ul>
<li>Document rule acceptance guidelines (<a
href="https://redirect.github.com/astral-sh/ruff/pull/27910">#27910</a>)</li>
<li>Document the new category selectors (<a
href="https://redirect.github.com/astral-sh/ruff/pull/27906">#27906</a>)</li>
</ul>
<h3>Contributors</h3>
<ul>
<li><a
href="https://github.com/AlexWaygood"><code>@​AlexWaygood</code></a></li>
<li><a href="https://github.com/sharkdp"><code>@​sharkdp</code></a></li>
<li><a
href="https://github.com/jelle-openai"><code>@​jelle-openai</code></a></li>
<li><a
href="https://github.com/charliermarsh"><code>@​charliermarsh</code></a></li>
<li><a href="https://github.com/ntBre"><code>@​ntBre</code></a></li>
<li><a
href="https://github.com/aarushkandukoori"><code>@​aarushkandukoori</code></a></li>
</ul>
<h2>Install ruff 0.16.5</h2>
<h3>Install prebuilt binaries via shell script</h3>
<pre lang="sh"><code>curl --proto '=https' --tlsv1.2 -LsSf
https://releases.astral.sh/github/ruff/releases/download/0.16.5/ruff-installer.sh
| sh
</code></pre>
<h3>Install prebuilt binaries via powershell script</h3>
<pre lang="sh"><code>powershell -ExecutionPolicy Bypass -c &quot;irm
https://releases.astral.sh/github/ruff/releases/download/0.16.5/ruff-installer.ps1
| iex&quot;
</code></pre>
<h2>Download ruff 0.16.5</h2>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md">ruff's
changelog</a>.</em></p>
<blockquote>
<h2>0.16.5</h2>
<p>Released on 2026-08-27.</p>
<h3>Preview features</h3>
<ul>
<li>Allow rules without codes (<a
href="https://redirect.github.com/astral-sh/ruff/pull/28049">#28049</a>)</li>
<li>Introduce category selectors (<a
href="https://redirect.github.com/astral-sh/ruff/pull/27666">#27666</a>)</li>
<li>Update preview default rules and categories (<a
href="https://redirect.github.com/astral-sh/ruff/pull/27877">#27877</a>)</li>
</ul>
<h3>Bug fixes</h3>
<ul>
<li>[<code>flake8-async</code>] Detect blocking generic HTTP requests
(<code>ASYNC210</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/28024">#28024</a>)</li>
<li>[<code>flake8-datetimez</code>] Allow timezone-safe
<code>strptime</code> chains (<code>DTZ007</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/28023">#28023</a>)</li>
<li>[<code>flake8-simplify</code>] Respect side effects in
<code>lambda</code> defaults (<code>SIM401</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/28000">#28000</a>)</li>
</ul>
<h3>Server</h3>
<ul>
<li>Fix duplicated &quot;of&quot; in <code>ClientOptions</code> doc
comment (<a
href="https://redirect.github.com/astral-sh/ruff/pull/27978">#27978</a>)</li>
</ul>
<h3>Documentation</h3>
<ul>
<li>Document rule acceptance guidelines (<a
href="https://redirect.github.com/astral-sh/ruff/pull/27910">#27910</a>)</li>
<li>Document the new category selectors (<a
href="https://redirect.github.com/astral-sh/ruff/pull/27906">#27906</a>)</li>
</ul>
<h3>Contributors</h3>
<ul>
<li><a
href="https://github.com/AlexWaygood"><code>@​AlexWaygood</code></a></li>
<li><a href="https://github.com/sharkdp"><code>@​sharkdp</code></a></li>
<li><a
href="https://github.com/jelle-openai"><code>@​jelle-openai</code></a></li>
<li><a
href="https://github.com/charliermarsh"><code>@​charliermarsh</code></a></li>
<li><a href="https://github.com/ntBre"><code>@​ntBre</code></a></li>
<li><a
href="https://github.com/aarushkandukoori"><code>@​aarushkandukoori</code></a></li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/astral-sh/ruff/commit/9e4938c4a60bed3e87a11ee1e1db1bd23f4d964a"><code>9e4938c</code></a>
Bump 0.16.5 (<a
href="https://redirect.github.com/astral-sh/ruff/issues/28110">#28110</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/aad0e909ef1390f4b2a3ba8aa0a67fb8ea5cbacd"><code>aad0e90</code></a>
Allow rules without codes (<a
href="https://redirect.github.com/astral-sh/ruff/issues/28049">#28049</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/5fdab73c5052350400c36b08c5d7710210343bc4"><code>5fdab73</code></a>
Update preview default rules and categories (<a
href="https://redirect.github.com/astral-sh/ruff/issues/27877">#27877</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/29c8e5b2d0a46eb7dc4ff11c1b0a0dc5ccea52e4"><code>29c8e5b</code></a>
Document rule acceptance guidelines (<a
href="https://redirect.github.com/astral-sh/ruff/issues/27910">#27910</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/50a4d7fd106603a5616b01ac3bef3306252b248f"><code>50a4d7f</code></a>
Document the new category selectors (<a
href="https://redirect.github.com/astral-sh/ruff/issues/27906">#27906</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/ada87950ea188f882f69b7bd6e2213a9696e3ee2"><code>ada8795</code></a>
Introduce category selectors (<a
href="https://redirect.github.com/astral-sh/ruff/issues/27666">#27666</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/d8947238863b61922bfc83f07edcc697c1cc07c0"><code>d894723</code></a>
[ty] Infer lambda parameters through callable type aliases (<a
href="https://redirect.github.com/astral-sh/ruff/issues/28109">#28109</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/2685fdebbcf9938736fed8c45886a629f9c99a06"><code>2685fde</code></a>
[ty] Narrow functional enum members in <code>==</code> and
<code>match</code> (<a
href="https://redirect.github.com/astral-sh/ruff/issues/28103">#28103</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/efcffd2178ce62e9951a53d4c50cadc225a0cfec"><code>efcffd2</code></a>
[ty] Intersection simplifications with subtype-related generic
specialization...</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/eb780488037504e11f145ed778654fd8a825028b"><code>eb78048</code></a>
[ty] Bump ecosystem-analyzer for HTML escaping (<a
href="https://redirect.github.com/astral-sh/ruff/issues/28104">#28104</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/astral-sh/ruff/compare/0.16.4...0.16.5">compare
view</a></li>
</ul>
</details>
<br />


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-09-01 11:13:24 -04:00
Nicholas Velten 69ff0d6a78 fix: trim a credit list in search_author to the first name (#1290)
Fixes #1252 for the case in the second report.

`_pick_search_author` returns `search_author` untouched but trims
`authors[0]` to its first comma-separated name. So the same credit list
searches differently depending on which field carries it:

```
via authors[0]     -> "Blindness Jose Saramago"
via search_author  -> "Blindness Jose Saramago, Giovanni Pontiero, Zohreh Eftekhari"
```

Anna's Archive answers the second one with nothing. That is the query in
@theDoz12's log, and it explains the shape of the report: the bypass
succeeds, the search runs, and the UI still says no releases. Nothing in
the download path is broken, the query simply cannot match.

Measured against live AA on 1.3.14, same book, same source, only the
field carrying the author changed:

| query | releases |
| --- | --- |
| `Blindness Jose Saramago, Giovanni Pontiero, Zohreh Eftekhari` | 0 |
| `Blindness Jose Saramago` | 49 |
| `Blindness` | 50 |

With the patch the second form is produced from either field, and the
same search returns 49.

Three regression tests added, including one that asserts both fields
yield the same query. On `tests/core/test_search_plan.py` the run goes
from 5 failures to 3; the 3 that remain are the language tests, which
fail identically with and without this change on my machine.

Worth saying what this does not cover: the first report in that issue
ends with `Found 2 releases via ISBN` and still shows nothing, so that
one is a different fault further along. I could not reproduce it here.
2026-09-01 11:08:39 -04:00
Nathan H 3937ae119b feat(homepage): Always show controls (#1269)
I found using the main search menu frustrating. Often times, I would
type in what I want, then select the category, only for it to get
erased. And the menu closing over and over was distracting. So this PR
makes the buttons stick around permanently and it removes the search
field text changing with each button press.

Obviously, this is just what I want, but I figured I'd at least put a PR
up for it.

<img width="1017" height="423" alt="image"
src="https://github.com/user-attachments/assets/7b84fd69-d6d3-4749-842f-e04a6e792ccc"
/>
<img width="682" height="418" alt="image"
src="https://github.com/user-attachments/assets/82a251ee-cb4a-488f-b1ba-45fb2bb6714d"
/>
2026-09-01 10:51:49 -04:00
Jorge Lima d7fe28595c fix(bypass): wait for the solved page before reading its source (#1286)
Follow-up to #1276 with a measurement from the instance I reported
there. v1.3.13 solves the challenge again, but on my setup the solve was
being thrown away immediately afterwards:

```
19:26:08 Bypass successful using _bypass_method_cdp_gui_click
19:26:16 Bypass failed (attempt 1/10): TimeoutError: Time ran out while waiting for: {html}
```

`_get()` ends with `return await page.get_page_source()`, which is
`find("html", timeout=1)` in SeleniumBase. One second is enough for a
page that is already sitting on its content, but Anna's Archive answers
a cleared check with a redirect to the real page, so the document is not
there yet. The solve is discarded, the whole attempt restarts, and the
extra requests are what earn the 429 that `note_rate_limited()` then
parks the host for — 120 s, then 300 s.

## Change

`_read_page_source()` waits for the document itself, with a
`BYPASS_PAGE_SOURCE_TIMEOUT` setting (default 20 s, min 1, max 120) in
Direct Download → Cloudflare Bypass, next to the existing bypasser
timeouts.

## Measured on a live instance

I patched the wait in the running container (`find("html", timeout=1)` →
`timeout=20` in the installed seleniumbase, which is the same effect as
this PR) and re-ran the same searches on the same host, k3s behind a
Surfshark WireGuard exit, internal bypasser, v1.3.13:

| | 1 s wait | 20 s wait |
|---|---|---|
| `Time ran out while waiting for: {html}` | one per solve | none |
| 429 backoffs | 2 (120 s, then 300 s) | none |
| Search for a book AA has | 199 s and 200 s, both errored | 61 s, 2
epub releases |

A download after that took 5 s from LibGen, so the search was the whole
cost.

## Tests

Two tests in `tests/bypass/test_bypass_budgets.py`, the file already
covering #1276: a page that needs longer than a second still yields its
HTML, and `BYPASS_PAGE_SOURCE_TIMEOUT` overrides the default.

`uv run pytest tests/ --ignore=tests/e2e`: 2848 passed, 47 skipped. Ruff
check and format clean. The docs table is auto-generated, but running
`scripts/generate_env_docs.py` here rewrote unrelated entries (Newznab,
BOOK_LANGUAGE), so I added only the new entry by hand in the generator's
format rather than commit that churn.

One thing I could not judge from outside: whether 20 s is the right
default for hosts other than AA. It only costs anything when a solve
would otherwise be discarded, but I have measured it on one site.
2026-08-30 19:17:15 -04:00
dependabot[bot] 68c0e83330 build(deps): bump the python-deps group with 2 updates (#1277)
Bumps the python-deps group with 2 updates:
[gunicorn](https://github.com/benoitc/gunicorn) and
[seleniumbase](https://github.com/seleniumbase/SeleniumBase).

Updates `gunicorn` from 26.1.0 to 26.2.0
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/benoitc/gunicorn/releases">gunicorn's
releases</a>.</em></p>
<blockquote>
<h2>gunicorn 26.2.0</h2>
<p>Cleartext HTTP/2 lands, and an HTTP/2 security fix.</p>
<h2>Cleartext HTTP/2 (h2c)</h2>
<p><code>http2_cleartext</code> accepts <code>prior-knowledge</code>,
<code>upgrade</code>, <code>both</code> or <code>off</code> (the
default). Prior knowledge serves a connection that opens with the HTTP/2
preface; <code>upgrade</code> honours an HTTP/1.1 <code>Upgrade:
h2c</code> request. Both work on the
gthread, gevent and asgi workers.</p>
<p>This is for deployments where TLS is terminated by a proxy that
speaks HTTP/2
upstream, so the hop into gunicorn no longer drops to HTTP/1.1. Only
peers in
<code>forwarded_allow_ips</code> are considered; everyone else is served
HTTP/1.x exactly
as if the setting were off. Each mechanism is enabled separately, so
turning one
on does not turn the other on.</p>
<p>Do not expose a cleartext HTTP/2 port to the internet.</p>
<h2>Security</h2>
<p><code>HTTP2Request</code> built its headers straight from the stream,
so nothing the HTTP/1
path enforces applied over HTTP/2: the underscore and
<code>header_map</code> policy,
duplicate <code>Host</code> and <code>Content-Type</code>, control
characters in values, and the
<code>forwarded_allow_ips</code> trust gate. An untrusted client could
set <code>SCRIPT_NAME</code>
and forge <code>HTTP_*</code> entries in the WSGI environ, and decide
<code>wsgi.url_scheme</code>
through <code>:scheme</code>. Both request classes now share one policy
mixin, and the
scheme comes from the transport.</p>
<p>If you serve HTTP/2, this is the reason to upgrade.</p>
<h2>Other HTTP/2 fixes</h2>
<p>WSGI responses were buffered whole before anything was sent; they
stream now.
HEAD, 204 and 304 no longer carry a body. Events read while blocked on a
flow-control window were discarded, losing requests and body data
outright.
<code>sendfile()</code> is refused on HTTP/2 responses rather than
bypassing framing.</p>
<h2>Request bodies dropped on Upgrade requests</h2>
<p>On the ASGI worker with the fast parser, any request carrying an
<code>Upgrade</code>
header reached the application with an empty body, whatever the header's
value
and with HTTP/2 switched off entirely. Fixed in
<code>gunicorn_h1c</code> 0.6.9, which the
<code>fast</code> extra now requires.</p>
<p>Full changelog: <a
href="https://gunicorn.org/news/">https://gunicorn.org/news/</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/benoitc/gunicorn/commit/36f2a3c1b80dfa41d70859d12c5bfbbdc23a3c38"><code>36f2a3c</code></a>
gunicorn 26.2.0</li>
<li><a
href="https://github.com/benoitc/gunicorn/commit/cbba3505f423bfb91af3a87e49ed9d232f39a8fe"><code>cbba350</code></a>
test: cover the h2c edge paths that had none</li>
<li><a
href="https://github.com/benoitc/gunicorn/commit/988541112ebcf3f795c020fc394aa7eed75f9f53"><code>9885411</code></a>
Merge pull request <a
href="https://redirect.github.com/benoitc/gunicorn/issues/3703">#3703</a>
from cormier/fix-inconsistency-in-control-socket-docs</li>
<li><a
href="https://github.com/benoitc/gunicorn/commit/86f0919806a2d4d4cce376cc2088352e7643b139"><code>86f0919</code></a>
Merge pull request <a
href="https://redirect.github.com/benoitc/gunicorn/issues/3704">#3704</a>
from methane/doc-wsgi-h1c</li>
<li><a
href="https://github.com/benoitc/gunicorn/commit/585355122efe736946b977c5605e404ff2d6ddd4"><code>5853551</code></a>
Merge pull request <a
href="https://redirect.github.com/benoitc/gunicorn/issues/3712">#3712</a>
from Rotzbua/patch-1</li>
<li><a
href="https://github.com/benoitc/gunicorn/commit/7bce87e2aa29a4a794eb2b113ff811cad6a80736"><code>7bce87e</code></a>
Merge pull request <a
href="https://redirect.github.com/benoitc/gunicorn/issues/3700">#3700</a>
from benoitc/fix/sponsor-logo-path</li>
<li><a
href="https://github.com/benoitc/gunicorn/commit/972dfb03b110c430712c32a3d92ef6397ff8eff6"><code>972dfb0</code></a>
Merge pull request <a
href="https://redirect.github.com/benoitc/gunicorn/issues/3690">#3690</a>
from melbinjp/docs/contributing-settings-path</li>
<li><a
href="https://github.com/benoitc/gunicorn/commit/7b3f16be8d9cc051538b7f0b58b236b37c9550f8"><code>7b3f16b</code></a>
Merge pull request <a
href="https://redirect.github.com/benoitc/gunicorn/issues/3711">#3711</a>
from benoitc/docs/http2-changelog</li>
<li><a
href="https://github.com/benoitc/gunicorn/commit/5bf237c0c7ef5bcdc63046645a17d6bafd609a34"><code>5bf237c</code></a>
http2: require gunicorn_h1c 0.6.9 and drop the upgrade body
workaround</li>
<li><a
href="https://github.com/benoitc/gunicorn/commit/7cf03385c574228e28c4952fd410ed2df02acc94"><code>7cf0338</code></a>
test: skip the fast-parser cases when gunicorn_h1c is absent</li>
<li>Additional commits viewable in <a
href="https://github.com/benoitc/gunicorn/compare/26.1.0...26.2.0">compare
view</a></li>
</ul>
</details>
<br />

Updates `seleniumbase` from 4.52.2 to 4.52.3
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/seleniumbase/SeleniumBase/releases">seleniumbase's
releases</a>.</em></p>
<blockquote>
<h2>4.52.3 - MCP Server: Patch 1</h2>
<h2>MCP Server: Patch 1</h2>
<ul>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/9bdc1133d096562111d3cc6465c6b0cd5dbfc38d">Fix
the MCP Server on Python versions less than 3.14</a>
--&gt; This resolves <a
href="https://redirect.github.com/seleniumbase/SeleniumBase/issues/4471">seleniumbase/SeleniumBase#4471</a>
--&gt; (Due to this bug, the MCP Server only worked on Python 3.14+)
--&gt; (Caused by a missing line: <code>from __future__ import
annotations</code>)</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/0e14a09f2d2f2e62a85bacc890b1d9d48b9a0c79">Update
logging messages</a></li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/1584e5b1b83f7177c59810817942150d1ed3ecab">Update
the docs for MCP servers</a></li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/213580314cb106bcb14d857289eb494395f491ae">Refresh
Python dependencies</a></li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/405c7c68599108fc99d1cf929e01b17d2c62cd7a">Update
examples</a></li>
</ul>
<h2>What's Changed</h2>
<ul>
<li>MCP Server: Patch 1 by <a
href="https://github.com/mdmintz"><code>@​mdmintz</code></a> in <a
href="https://redirect.github.com/seleniumbase/SeleniumBase/pull/4472">seleniumbase/SeleniumBase#4472</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/seleniumbase/SeleniumBase/compare/v4.52.2...v4.52.3">https://github.com/seleniumbase/SeleniumBase/compare/v4.52.2...v4.52.3</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/9112244cfada5d002f3d08c6dbf2a68d34598c51"><code>9112244</code></a>
Merge pull request <a
href="https://redirect.github.com/seleniumbase/SeleniumBase/issues/4472">#4472</a>
from seleniumbase/mcp-server-patch-1</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/bf1abf63240338b9ee58f4dc6e907e627411c27b"><code>bf1abf6</code></a>
Version 4.52.3</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/405c7c68599108fc99d1cf929e01b17d2c62cd7a"><code>405c7c6</code></a>
Update examples</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/213580314cb106bcb14d857289eb494395f491ae"><code>2135803</code></a>
Refresh Python dependencies</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/1584e5b1b83f7177c59810817942150d1ed3ecab"><code>1584e5b</code></a>
Update the docs for MCP servers</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/0e14a09f2d2f2e62a85bacc890b1d9d48b9a0c79"><code>0e14a09</code></a>
Update logging messages</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/9bdc1133d096562111d3cc6465c6b0cd5dbfc38d"><code>9bdc113</code></a>
Fix the MCP Server on Python versions less than 3.14</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/cbd624a8697c763d7d68f3e92dcd31f4636ae9d8"><code>cbd624a</code></a>
Update the docs</li>
<li>See full diff in <a
href="https://github.com/seleniumbase/SeleniumBase/compare/v4.52.2...v4.52.3">compare
view</a></li>
</ul>
</details>
<br />


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-30 12:18:11 -04:00
dependabot[bot] faaa119884 build(deps): bump python from ce40764 to cae66f2 (#1278)
Bumps python from `ce40764` to `cae66f2`.


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=python&package-manager=docker&previous-version=3.14.7-slim&new-version=3.14.7-slim)](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 this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-30 12:18:03 -04:00
dependabot[bot] be41a92436 build(deps-dev): bump the npm-deps group in /src/frontend with 7 updates (#1279)
Bumps the npm-deps group in /src/frontend with 7 updates:

| Package | From | To |
| --- | --- | --- |
|
[@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node)
| `26.2.0` | `26.3.0` |
|
[@types/react-dom](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react-dom)
| `19.2.4` | `19.2.5` |
|
[@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react)
| `6.0.5` | `6.1.0` |
| [oxfmt](https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt) |
`0.63.0` | `0.65.0` |
| [oxlint](https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint) |
`1.78.0` | `1.80.0` |
| [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) |
`8.2.1` | `8.2.2` |
|
[vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest)
| `4.1.10` | `4.1.11` |

Updates `@types/node` from 26.2.0 to 26.3.0
<details>
<summary>Commits</summary>
<ul>
<li>See full diff in <a
href="https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node">compare
view</a></li>
</ul>
</details>
<br />

Updates `@types/react-dom` from 19.2.4 to 19.2.5
<details>
<summary>Commits</summary>
<ul>
<li>See full diff in <a
href="https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react-dom">compare
view</a></li>
</ul>
</details>
<br />

Updates `@vitejs/plugin-react` from 6.0.5 to 6.1.0
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/vitejs/vite-plugin-react/releases">@​vitejs/plugin-react's
releases</a>.</em></p>
<blockquote>
<h2>plugin-react@6.1.0</h2>
<h3>Add experimental native React Compiler support (<a
href="https://redirect.github.com/vitejs/vite-plugin-react/pull/1419">#1419</a>)</h3>
<p>Add experimental native React Compiler support.</p>
<p>You can use it by installing <code>oxc-transform-react</code> and
enabling it via the <code>compiler</code> option:</p>
<pre lang="sh"><code>npm install -D oxc-transform-react
</code></pre>
<pre lang="js"><code>import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
<p>export default defineConfig({<br />
plugins: [<br />
react({ compiler: true })<br />
]<br />
})<br />
</code></pre></p>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/CHANGELOG.md">@​vitejs/plugin-react's
changelog</a>.</em></p>
<blockquote>
<h2>6.1.0 (2026-08-19)</h2>
<h3>Add experimental native React Compiler support (<a
href="https://redirect.github.com/vitejs/vite-plugin-react/pull/1419">#1419</a>)</h3>
<p>Add experimental native React Compiler support.</p>
<p>You can use it by installing <code>oxc-transform-react</code> and
enabling it via the <code>compiler</code> option:</p>
<pre lang="sh"><code>npm install -D oxc-transform-react
</code></pre>
<pre lang="js"><code>import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
<p>export default defineConfig({<br />
plugins: [<br />
react({ compiler: true })<br />
]<br />
})<br />
</code></pre></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/vitejs/vite-plugin-react/commit/39b31735bf79c2dd380eedaba7ed849256f92a29"><code>39b3173</code></a>
release: plugin-react@6.1.0 (<a
href="https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react/issues/1428">#1428</a>)</li>
<li><a
href="https://github.com/vitejs/vite-plugin-react/commit/f1340b0c760b1c16e1b780eeba46fd933ddd52eb"><code>f1340b0</code></a>
feat(react): add native React Compiler support (<a
href="https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react/issues/1419">#1419</a>)</li>
<li><a
href="https://github.com/vitejs/vite-plugin-react/commit/9ab698eafc38ffa14861db450291ed2f6f557557"><code>9ab698e</code></a>
fix(deps): update all non-major dependencies (<a
href="https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react/issues/1375">#1375</a>)</li>
<li>See full diff in <a
href="https://github.com/vitejs/vite-plugin-react/commits/plugin-react@6.1.0/packages/plugin-react">compare
view</a></li>
</ul>
</details>
<br />

Updates `oxfmt` from 0.63.0 to 0.65.0
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/oxc-project/oxc/commit/97e99b85483776a72928d675cc05b1cfc1130ba0"><code>97e99b8</code></a>
release(apps): oxlint v1.80.0 &amp;&amp; oxfmt v0.65.0 (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt/issues/26045">#26045</a>)</li>
<li><a
href="https://github.com/oxc-project/oxc/commit/0db127cc16d28b97d84bac4ebeb302caf1a78c7e"><code>0db127c</code></a>
release(apps): oxlint v1.79.0 &amp;&amp; oxfmt v0.64.0 (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt/issues/25866">#25866</a>)</li>
<li><a
href="https://github.com/oxc-project/oxc/commit/c07fe7c217774fd404740d34ee91ac03a6b726c2"><code>c07fe7c</code></a>
feat(oxfmt): support <code>experimentalOperatorPosition</code> (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt/issues/25643">#25643</a>)</li>
<li><a
href="https://github.com/oxc-project/oxc/commit/fed6681edaf3b9b45fbcc8fd7f987c86505d0b86"><code>fed6681</code></a>
docs(oxfmt): skip expanding overrides options (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt/issues/25572">#25572</a>)</li>
<li>See full diff in <a
href="https://github.com/oxc-project/oxc/commits/oxfmt_v0.65.0/npm/oxfmt">compare
view</a></li>
</ul>
</details>
<br />

Updates `oxlint` from 1.78.0 to 1.80.0
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/oxc-project/oxc/releases">oxlint's
releases</a>.</em></p>
<blockquote>
<h2>oxlint v1.80.0 &amp; oxfmt v0.65.0</h2>
<h2>Table of Contents</h2>
<ul>
<li><a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/blob/HEAD/#oxlint-v1.80.0">Oxlint
v1.80.0</a></li>
<li><a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/blob/HEAD/#oxfmt-v0.65.0">Oxfmt
v0.65.0</a></li>
</ul>
<h2>Oxlint v1.80.0</h2>
<h3>🚀 Features</h3>
<ul>
<li>70c3e35 linter/typescript/no-confusing-non-null-assertion: Implement
suggestion (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/26012">#26012</a>)
(Mikhail Baev)</li>
</ul>
<h3>🐛 Bug Fixes</h3>
<ul>
<li>17ae11c linter/oxc/double-comparisons: Handle grouped logical
expressions (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/26044">#26044</a>)
(camc314)</li>
<li>8a353a7 linter/eslint/no-control-regex: Refine help message text (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/25996">#25996</a>)
(Rahul Mishra)</li>
<li>8a9bdbd estree: Include decorators in <code>FormalParameterRest
</code> spans (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/26021">#26021</a>)
(camc314)</li>
<li>8d94cd1 linter/eslint/no-useless-rename: Preserve type modifiers (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/26020">#26020</a>)
(Cameron)</li>
<li>2cde1f6 rust: Address nightly deprecations (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/25998">#25998</a>)
(Boshen)</li>
<li>51d36d7 linter/vue: Resolve <code>vue</code> imports via shared
import helpers (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/25903">#25903</a>)
(Connor Shea)</li>
<li>83a68d2 linter/react/no-react-children: Resolve <code>react</code>
imports by symbol (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/25901">#25901</a>)
(Connor Shea)</li>
<li>124e196 linter: Resolve globals by reference, not by name (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/25905">#25905</a>)
(Connor Shea)</li>
<li>a701bcc linter: Remove invalid React compiler doc links (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/25900">#25900</a>)
(Boshen)</li>
</ul>
<h3>📚 Documentation</h3>
<ul>
<li>9b7e153 linter: Set <code>version</code> to 1.79.0 for rules shipped
in 1.79.0 (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/25902">#25902</a>)
(connorshea)</li>
</ul>
<h2>Oxfmt v0.65.0</h2>
<h3>🐛 Bug Fixes</h3>
<ul>
<li>bf37dd5 formatter: Preserve class decorators before export when the
statement is suppressed (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/26034">#26034</a>)
(leaysgur)</li>
</ul>
<h2>oxlint v1.79.0 &amp; oxfmt v0.64.0</h2>
<h2>Table of Contents</h2>
<ul>
<li><a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/blob/HEAD/#oxlint-v1.79.0">Oxlint
v1.79.0</a></li>
<li><a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/blob/HEAD/#oxfmt-v0.64.0">Oxfmt
v0.64.0</a></li>
</ul>
<h2>Oxlint v1.79.0</h2>
<h3>💥 BREAKING CHANGES</h3>
<ul>
<li>8c4552d linter: [<strong>BREAKING</strong>] Split
react/react-compiler into per-category rules (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/25500">#25500</a>)
(Boshen)</li>
</ul>
<p>See <a
href="https://oxc.rs/blog/2026-08-18-react-compiler-support">React
Compiler Support</a> for details.</p>
<h3>🚀 Features</h3>
<ul>
<li>9b7394e linter/typescript/no-empty-object-type: Implement suggestion
(<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/25833">#25833</a>)
(Mikhail Baev)</li>
</ul>
<h3>🐛 Bug Fixes</h3>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/oxc-project/oxc/blob/main/npm/oxlint/CHANGELOG.md">oxlint's
changelog</a>.</em></p>
<blockquote>
<h1>Changelog</h1>
<p>All notable changes to this package will be documented in this
file.</p>
<p>The format is based on <a
href="https://keepachangelog.com/en/1.0.0">Keep a Changelog</a>.</p>
<h2>[1.79.0] - 2026-08-18</h2>
<h3>💥 BREAKING CHANGES</h3>
<ul>
<li>8c4552d linter: [<strong>BREAKING</strong>] Split
react/react-compiler into per-category rules (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/25500">#25500</a>)
(Boshen)</li>
</ul>
<h3>🐛 Bug Fixes</h3>
<ul>
<li>228e8e0 linter: Resolve inactive React compiler rules (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/25830">#25830</a>)
(Boshen)</li>
<li>aa49d86 linter: Allow spread rule options in config types (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/25675">#25675</a>)
(ch3rry)</li>
<li>36f8451 linter/eslint/no-eval: Align indirect default with ESLint
(<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/25656">#25656</a>)
(camc314)</li>
<li>beb724d linter/eslint/no-unused-vars: Report bare underscore
parameters (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/25663">#25663</a>)
(camc314)</li>
<li>4004c10 linter/eslint/no-irregular-whitespace: Check comments by
default (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/25660">#25660</a>)
(camc314)</li>
<li>285820e linter/no-large-snapshots: Precompile and document allowed
snapshot matchers (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/25611">#25611</a>)
(Mikhail Baev)</li>
<li>4df5835 linter: Allow capitalized built-in calls (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/25516">#25516</a>)
(Boshen)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/oxc-project/oxc/commit/97e99b85483776a72928d675cc05b1cfc1130ba0"><code>97e99b8</code></a>
release(apps): oxlint v1.80.0 &amp;&amp; oxfmt v0.65.0 (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/26045">#26045</a>)</li>
<li><a
href="https://github.com/oxc-project/oxc/commit/0db127cc16d28b97d84bac4ebeb302caf1a78c7e"><code>0db127c</code></a>
release(apps): oxlint v1.79.0 &amp;&amp; oxfmt v0.64.0 (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/25866">#25866</a>)</li>
<li><a
href="https://github.com/oxc-project/oxc/commit/228e8e0f85c0e7aeded02c5e27fd810004d3b41a"><code>228e8e0</code></a>
fix(linter): resolve inactive React compiler rules (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/25830">#25830</a>)</li>
<li><a
href="https://github.com/oxc-project/oxc/commit/aa49d860465e6c00b6edfcbb8973d8dc95cc11ca"><code>aa49d86</code></a>
fix(linter): allow spread rule options in config types (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/25675">#25675</a>)</li>
<li><a
href="https://github.com/oxc-project/oxc/commit/892238149b7c4dff808817ec5e27d1e0ecf63b11"><code>8922381</code></a>
refactor(linter): remove inactive react config rule (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/25740">#25740</a>)</li>
<li><a
href="https://github.com/oxc-project/oxc/commit/8c4552dfa6bce0a9f06f41ca13e45e50d842c38c"><code>8c4552d</code></a>
feat(linter)!: split react/react-compiler into per-category rules (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/25500">#25500</a>)</li>
<li><a
href="https://github.com/oxc-project/oxc/commit/36f845168ce854c1c970fea13997e16a18cbe55f"><code>36f8451</code></a>
fix(linter/eslint/no-eval): align indirect default with ESLint (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/25656">#25656</a>)</li>
<li><a
href="https://github.com/oxc-project/oxc/commit/beb724dce2e8b8466d851c04e16c38fa75623c5c"><code>beb724d</code></a>
fix(linter/eslint/no-unused-vars): report bare underscore parameters (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/25663">#25663</a>)</li>
<li><a
href="https://github.com/oxc-project/oxc/commit/4004c101ca349f8e92932f6d056b18bfb4dff9a7"><code>4004c10</code></a>
fix(linter/eslint/no-irregular-whitespace): check comments by default
(<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/25660">#25660</a>)</li>
<li><a
href="https://github.com/oxc-project/oxc/commit/285820eed6c49a45f8de18d3bfed1cc6b5d9da6d"><code>285820e</code></a>
fix(linter/no-large-snapshots): precompile and document allowed snapshot
matc...</li>
<li>Additional commits viewable in <a
href="https://github.com/oxc-project/oxc/commits/oxlint_v1.80.0/npm/oxlint">compare
view</a></li>
</ul>
</details>
<br />

Updates `vite` from 8.2.1 to 8.2.2
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/vitejs/vite/releases">vite's
releases</a>.</em></p>
<blockquote>
<h2>plugin-legacy@8.2.2</h2>
<p>Please refer to <a
href="https://github.com/vitejs/vite/blob/plugin-legacy@8.2.2/packages/plugin-legacy/CHANGELOG.md">CHANGELOG.md</a>
for details.</p>
<h2>v8.2.2</h2>
<p>Please refer to <a
href="https://github.com/vitejs/vite/blob/v8.2.2/packages/vite/CHANGELOG.md">CHANGELOG.md</a>
for details.</p>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md">vite's
changelog</a>.</em></p>
<blockquote>
<h2><!-- raw HTML omitted --><a
href="https://github.com/vitejs/vite/compare/v8.2.1...v8.2.2">8.2.2</a>
(2026-08-20)<!-- raw HTML omitted --></h2>
<h3>Features</h3>
<ul>
<li><strong>deps:</strong> widen <code>@vitejs/devtools</code> peer
range to v0.5.0 (<a
href="https://redirect.github.com/vitejs/vite/issues/23302">#23302</a>)
(<a
href="https://github.com/vitejs/vite/commit/495d9ff5a7d843ca876a9e49799947a5deb704c7">495d9ff</a>)</li>
</ul>
<h3>Bug Fixes</h3>
<ul>
<li><strong>bundled-dev:</strong> handle lazy request error (<a
href="https://redirect.github.com/vitejs/vite/issues/23291">#23291</a>)
(<a
href="https://github.com/vitejs/vite/commit/3ba026dade4af56df08815310d3458fa110f5c5c">3ba026d</a>)</li>
<li><strong>bundled-dev:</strong> hot update through circular imports
instead of reloading (<a
href="https://redirect.github.com/vitejs/vite/issues/23259">#23259</a>)
(<a
href="https://github.com/vitejs/vite/commit/3dbddefaafc091a879b06f9279296f776691e455">3dbddef</a>)</li>
<li><strong>config:</strong> resolve sourcemap paths against sourcemap
location (<a
href="https://redirect.github.com/vitejs/vite/issues/23239">#23239</a>)
(<a
href="https://github.com/vitejs/vite/commit/05a003e6a17a84d75f907ea0f1598bc39b8dce6c">05a003e</a>)</li>
<li><strong>css:</strong> don't pass empty targets to lightningcss (<a
href="https://redirect.github.com/vitejs/vite/issues/23295">#23295</a>)
(<a
href="https://github.com/vitejs/vite/commit/2804636ff608d105928009d274ffba7cfbe55340">2804636</a>)</li>
<li><strong>define:</strong> fix match escaped dots to support
$-prefixed define keys (<a
href="https://redirect.github.com/vitejs/vite/issues/23249">#23249</a>)
(<a
href="https://github.com/vitejs/vite/commit/dcf88bd2ad2b1a8845f9029587cc8c825e382d42">dcf88bd</a>)</li>
<li><strong>deps:</strong> update all non-major dependencies (<a
href="https://redirect.github.com/vitejs/vite/issues/23217">#23217</a>)
(<a
href="https://github.com/vitejs/vite/commit/ba958bddfc9cabe302c6b34269dcf5c9634531e0">ba958bd</a>)</li>
<li><strong>deps:</strong> update rolldown-related dependencies (<a
href="https://redirect.github.com/vitejs/vite/issues/23218">#23218</a>)
(<a
href="https://github.com/vitejs/vite/commit/83ecb2c8059e8ce946a7cc835d4c14ef78aef4fd">83ecb2c</a>)</li>
<li><strong>module-runner:</strong> exclude completed modules from
in-flight cycle detection (fix <a
href="https://redirect.github.com/vitejs/vite/issues/22999">#22999</a>)
(<a
href="https://redirect.github.com/vitejs/vite/issues/23009">#23009</a>)
(<a
href="https://github.com/vitejs/vite/commit/d9b10a98db1c293ee64300bd75d568b44c8ae931">d9b10a9</a>)</li>
<li><strong>optimizer:</strong> close custom extension analysis bundles
(<a
href="https://redirect.github.com/vitejs/vite/issues/23207">#23207</a>)
(<a
href="https://github.com/vitejs/vite/commit/8fb76752836f61224d3095b502fa237b478a06b2">8fb7675</a>)</li>
<li>reduce Windows 8.3-short-name detection false-positives (<a
href="https://redirect.github.com/vitejs/vite/issues/23066">#23066</a>)
(<a
href="https://github.com/vitejs/vite/commit/02cffa9e2d38d5d8f12e4043ee9d0f7abb1471e2">02cffa9</a>)</li>
<li>respect <code>resolve.preserveSymlinks</code> when resolving root
(fix <a
href="https://redirect.github.com/vitejs/vite/issues/23197">#23197</a>)
(<a
href="https://redirect.github.com/vitejs/vite/issues/23198">#23198</a>)
(<a
href="https://github.com/vitejs/vite/commit/8413052731836d4aaf3eb94a0f25788dd35d2888">8413052</a>)</li>
<li><strong>ssr:</strong> rewrite computed key of destructing parameter
(<a
href="https://redirect.github.com/vitejs/vite/issues/23307">#23307</a>)
(<a
href="https://github.com/vitejs/vite/commit/9db0b61d4c9c7caad7ea1d9670b637faf2bb6c93">9db0b61</a>)</li>
<li><strong>vite:</strong> update outdated upstream file links in
license comments (<a
href="https://redirect.github.com/vitejs/vite/issues/23285">#23285</a>)
(<a
href="https://github.com/vitejs/vite/commit/c0f2fc607ee97ee4499337b04826420c00654065">c0f2fc6</a>)</li>
</ul>
<h3>Documentation</h3>
<ul>
<li><strong>build:</strong> note cssTarget precedence (<a
href="https://redirect.github.com/vitejs/vite/issues/23200">#23200</a>)
(<a
href="https://github.com/vitejs/vite/commit/a20a35ec0685e374519864d0f41dd5f6e9ba0271">a20a35e</a>)</li>
</ul>
<h3>Miscellaneous Chores</h3>
<ul>
<li>fix ts errors in build test cases (<a
href="https://redirect.github.com/vitejs/vite/issues/23209">#23209</a>)
(<a
href="https://github.com/vitejs/vite/commit/a0cfcf72f8ef8bf0f2f11d553333b9bb31f1d316">a0cfcf7</a>)</li>
</ul>
<h3>Code Refactoring</h3>
<ul>
<li>use JSON import attributes instead of readFileSync in constants (<a
href="https://redirect.github.com/vitejs/vite/issues/23258">#23258</a>)
(<a
href="https://github.com/vitejs/vite/commit/1d9fa392a43229241f80630236f8552ce8f7cd0f">1d9fa39</a>)</li>
<li>use named regex constants over inline literals (<a
href="https://redirect.github.com/vitejs/vite/issues/22964">#22964</a>)
(<a
href="https://github.com/vitejs/vite/commit/5c1c6c609718303202832f706884192e1f1e9223">5c1c6c6</a>)</li>
</ul>
<h3>Tests</h3>
<ul>
<li><strong>define:</strong> close rolldown bundler after generate (<a
href="https://redirect.github.com/vitejs/vite/issues/23231">#23231</a>)
(<a
href="https://github.com/vitejs/vite/commit/b4d66fee14d970f45b8a6f3d7d6aee73ca9b88ab">b4d66fe</a>)</li>
<li><strong>module-runner:</strong> add TLA circular import case (<a
href="https://redirect.github.com/vitejs/vite/issues/23299">#23299</a>)
(<a
href="https://github.com/vitejs/vite/commit/4a261f242831bef92afd2f1aacfb81eab9dec371">4a261f2</a>)</li>
<li><strong>module-runner:</strong> simplify server-hmr tests (<a
href="https://redirect.github.com/vitejs/vite/issues/23300">#23300</a>)
(<a
href="https://github.com/vitejs/vite/commit/599b44b6600ec426e10cd556908d53b027b0c4fb">599b44b</a>)</li>
<li><strong>ssr:</strong> add destructing assignment case for
moduleRunnerTransform (<a
href="https://redirect.github.com/vitejs/vite/issues/23308">#23308</a>)
(<a
href="https://github.com/vitejs/vite/commit/cb77e2a93bad2a8ece00b4aa0ef507c092582c45">cb77e2a</a>)</li>
</ul>
<h3>Build System</h3>
<ul>
<li>use JSON import attributes instead of readFIleSync in rolldown
configs (<a
href="https://redirect.github.com/vitejs/vite/issues/23251">#23251</a>)
(<a
href="https://github.com/vitejs/vite/commit/d615bcdb23d96c1ca5ce1ee45e21d8d87381106f">d615bcd</a>)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/vitejs/vite/commit/de1111ab0be00879b404e7ed3b2a80e264edddc1"><code>de1111a</code></a>
release: v8.2.2</li>
<li><a
href="https://github.com/vitejs/vite/commit/cb77e2a93bad2a8ece00b4aa0ef507c092582c45"><code>cb77e2a</code></a>
test(ssr): add destructing assignment case for moduleRunnerTransform (<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/23308">#23308</a>)</li>
<li><a
href="https://github.com/vitejs/vite/commit/9db0b61d4c9c7caad7ea1d9670b637faf2bb6c93"><code>9db0b61</code></a>
fix(ssr): rewrite computed key of destructing parameter (<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/23307">#23307</a>)</li>
<li><a
href="https://github.com/vitejs/vite/commit/8413052731836d4aaf3eb94a0f25788dd35d2888"><code>8413052</code></a>
fix: respect <code>resolve.preserveSymlinks</code> when resolving root
(fix <a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/23197">#23197</a>)
(<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/23">#23</a>...</li>
<li><a
href="https://github.com/vitejs/vite/commit/05a003e6a17a84d75f907ea0f1598bc39b8dce6c"><code>05a003e</code></a>
fix(config): resolve sourcemap paths against sourcemap location (<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/23239">#23239</a>)</li>
<li><a
href="https://github.com/vitejs/vite/commit/495d9ff5a7d843ca876a9e49799947a5deb704c7"><code>495d9ff</code></a>
feat(deps): widen <code>@vitejs/devtools</code> peer range to v0.5.0 (<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/23302">#23302</a>)</li>
<li><a
href="https://github.com/vitejs/vite/commit/1d9fa392a43229241f80630236f8552ce8f7cd0f"><code>1d9fa39</code></a>
refactor: use JSON import attributes instead of readFileSync in
constants (<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/2">#2</a>...</li>
<li><a
href="https://github.com/vitejs/vite/commit/2804636ff608d105928009d274ffba7cfbe55340"><code>2804636</code></a>
fix(css): don't pass empty targets to lightningcss (<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/23295">#23295</a>)</li>
<li><a
href="https://github.com/vitejs/vite/commit/599b44b6600ec426e10cd556908d53b027b0c4fb"><code>599b44b</code></a>
test(module-runner): simplify server-hmr tests (<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/23300">#23300</a>)</li>
<li><a
href="https://github.com/vitejs/vite/commit/4a261f242831bef92afd2f1aacfb81eab9dec371"><code>4a261f2</code></a>
test(module-runner): add TLA circular import case (<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/23299">#23299</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/vitejs/vite/commits/v8.2.2/packages/vite">compare
view</a></li>
</ul>
</details>
<br />

Updates `vitest` from 4.1.10 to 4.1.11
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/vitest-dev/vitest/releases">vitest's
releases</a>.</em></p>
<blockquote>
<h2>v4.1.11</h2>
<h3>   🐞 Bug Fixes</h3>
<ul>
<li>Revive global concurrency limit for test lifecycle [backport to v4]
 -  by <a
href="https://github.com/sheremet-va"><code>@​sheremet-va</code></a> and
<a href="https://github.com/hi-ogawa"><code>@​hi-ogawa</code></a> in <a
href="https://redirect.github.com/vitest-dev/vitest/issues/10992">vitest-dev/vitest#10992</a>
<a href="https://github.com/vitest-dev/vitest/commit/5146df80b"><!-- raw
HTML omitted -->(5146d)<!-- raw HTML omitted --></a></li>
<li><strong>browser</strong>:
<ul>
<li>Encode iframeId in tester iframe URL [backport to v4]  -  by <a
href="https://github.com/sheremet-va"><code>@​sheremet-va</code></a>,
<strong>Pduhard</strong> and <strong>Claude Opus 4.8</strong> in <a
href="https://redirect.github.com/vitest-dev/vitest/issues/10955">vitest-dev/vitest#10955</a>
<a href="https://github.com/vitest-dev/vitest/commit/10b2cd201"><!-- raw
HTML omitted -->(10b2c)<!-- raw HTML omitted --></a></li>
<li>Trigger playwright/chromium gc on lower disk availability [backport
to v4]  -  by <a
href="https://github.com/hi-ogawa"><code>@​hi-ogawa</code></a>,
<strong>Hiroshi Ogawa</strong> and <strong>OpenCode</strong> in <a
href="https://redirect.github.com/vitest-dev/vitest/issues/10951">vitest-dev/vitest#10951</a>
<a href="https://github.com/vitest-dev/vitest/commit/9851dbc41"><!-- raw
HTML omitted -->(9851d)<!-- raw HTML omitted --></a></li>
</ul>
</li>
<li><strong>mocker</strong>:
<ul>
<li>Restrict redirect mocks to the fs allowlist [backport to v4]  -  by
<a href="https://github.com/sheremet-va"><code>@​sheremet-va</code></a>
in <a
href="https://redirect.github.com/vitest-dev/vitest/issues/10974">vitest-dev/vitest#10974</a>
<a href="https://github.com/vitest-dev/vitest/commit/fe5a11d3c"><!-- raw
HTML omitted -->(fe5a1)<!-- raw HTML omitted --></a></li>
</ul>
</li>
</ul>
<h5>    <a
href="https://github.com/vitest-dev/vitest/compare/v4.1.10...v4.1.11">View
changes on GitHub</a></h5>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/vitest-dev/vitest/commit/9bd8d464e6328c567c2dbcd8fdd977d57a9425c2"><code>9bd8d46</code></a>
chore: release v4.1.11 (<a
href="https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest/issues/10995">#10995</a>)</li>
<li><a
href="https://github.com/vitest-dev/vitest/commit/9851dbc41c286a30abfb6b29cce65f3e5b7b40a1"><code>9851dbc</code></a>
fix(browser): trigger playwright/chromium gc on lower disk availability
[back...</li>
<li>See full diff in <a
href="https://github.com/vitest-dev/vitest/commits/v4.1.11/packages/vitest">compare
view</a></li>
</ul>
</details>
<br />


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-30 12:17:56 -04:00
dependabot[bot] 7de9319c7a build(deps): bump the gh-actions group with 3 updates (#1280)
Bumps the gh-actions group with 3 updates:
[github/codeql-action/init](https://github.com/github/codeql-action),
[github/codeql-action/autobuild](https://github.com/github/codeql-action)
and
[github/codeql-action/analyze](https://github.com/github/codeql-action).

Updates `github/codeql-action/init` from 4.37.7 to 4.37.8
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/github/codeql-action/releases">github/codeql-action/init's
releases</a>.</em></p>
<blockquote>
<h2>v4.37.8</h2>
<p>No user facing changes.</p>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/github/codeql-action/blob/main/CHANGELOG.md">github/codeql-action/init's
changelog</a>.</em></p>
<blockquote>
<h1>CodeQL Action Changelog</h1>
<p>See the <a
href="https://github.com/github/codeql-action/releases">releases
page</a> for the relevant changes to the CodeQL CLI and language
packs.</p>
<h2>[UNRELEASED]</h2>
<p>No user facing changes.</p>
<h2>4.37.9 - 26 Aug 2026</h2>
<ul>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.26.4">2.26.4</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/4106">#4106</a></li>
</ul>
<h2>4.37.8 - 21 Aug 2026</h2>
<p>No user facing changes.</p>
<h2>4.37.7 - 13 Aug 2026</h2>
<ul>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.26.3">2.26.3</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/4085">#4085</a></li>
</ul>
<h2>4.37.6 - 04 Aug 2026</h2>
<ul>
<li>Changed the default filepath for the new remote file address format
that was introduced in CodeQL Action 4.37.0 / 3.37.0 to
<code>.github/codeql-config.yml</code> to align it with the suggested
path that is used elsewhere. <a
href="https://redirect.github.com/github/codeql-action/pull/4070">#4070</a></li>
</ul>
<h2>4.37.5 - 03 Aug 2026</h2>
<ul>
<li>Fixed a bug where a network error while streaming the download of
the CodeQL bundle could terminate the <code>init</code> Action instead
of falling back to downloading the bundle before extracting it. <a
href="https://redirect.github.com/github/codeql-action/pull/4061">#4061</a></li>
</ul>
<h2>4.37.4 - 29 Jul 2026</h2>
<ul>
<li>This version of the CodeQL Action adds support for the
<code>tools</code> input for the <code>codeql-action/init</code> step to
be specified using a <code>github-codeql-tools</code> <a
href="https://docs.github.com/en/organizations/managing-organization-settings/managing-custom-properties-for-repositories-in-your-organization">repository
property</a>. This feature will gradually be rolled out following the
release of this version. Once rolled out, this allows for the CodeQL CLI
version that is used in GitHub-managed workflows, such as Default Setup,
to be set to a custom value. For example, customers who run into issues
with rate limits when a new CodeQL CLI version is released can set the
value to <code>toolcache</code> to always use the CodeQL CLI version
that is available in the runner toolcache. For Advanced Setup workflows,
the value provided for <code>tools</code> in the workflow definition
always takes precedence unless the value of the repository property
starts with <code>!</code>. <a
href="https://redirect.github.com/github/codeql-action/pull/4037">#4037</a></li>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.26.2">2.26.2</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/4051">#4051</a></li>
</ul>
<h2>4.37.3 - 22 Jul 2026</h2>
<p>No user facing changes.</p>
<h2>4.37.2 - 21 Jul 2026</h2>
<ul>
<li>The new address format for the <code>config-file</code> input that
was introduced in CodeQL Action 4.37.0 is now enabled by default. In
addition to the format described there, the <code>remote=</code> prefix
can now be used to explicitly indicate that the input refers to a remote
file. All previous input formats continue to be accepted as well. <a
href="https://redirect.github.com/github/codeql-action/pull/4023">#4023</a></li>
<li>The CodeQL Action can now make use of <a
href="https://docs.github.com/en/code-security/how-tos/secure-at-scale/configure-organization-security/manage-usage-and-access/giving-org-access-private-registries">configured
private registries</a> in Default Setup to retrieve CodeQL configuration
files from remote repositories that require authentication. This will
allow customers to store their CodeQL configuration in a single
repository that can then be referenced by Default Setup workflows in
other repositories. We expect to roll this and other, related changes
out to everyone in July. <a
href="https://redirect.github.com/github/codeql-action/pull/4007">#4007</a></li>
</ul>
<h2>4.37.1 - 16 Jul 2026</h2>
<ul>
<li><em>Upcoming breaking change</em>: Add a deprecation warning for
customers using CodeQL version 2.20.6 and earlier. These versions of
CodeQL were discontinued on 1 July 2026 alongside GitHub Enterprise
Server 3.16, and will be unsupported by the next minor release of the
CodeQL Action. <a
href="https://redirect.github.com/github/codeql-action/pull/3956">#3956</a></li>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.26.1">2.26.1</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/4019">#4019</a></li>
</ul>
<h2>4.37.0 - 08 Jul 2026</h2>
<ul>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.26.0">2.26.0</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/3995">#3995</a></li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/github/codeql-action/commit/db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28"><code>db488dd</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4102">#4102</a>
from github/update-v4.37.8-9ee088e13</li>
<li><a
href="https://github.com/github/codeql-action/commit/1845f5ba8b4057590f49ee8e246c95ef2ba4b53f"><code>1845f5b</code></a>
Update changelog for v4.37.8</li>
<li><a
href="https://github.com/github/codeql-action/commit/9ee088e13615f8d1eaef4766f9dde95d3356a8f6"><code>9ee088e</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4080">#4080</a>
from github/henrymercer/studious-giggle</li>
<li><a
href="https://github.com/github/codeql-action/commit/1aef003397c876c0ab5bd118e1b1f34c175622e9"><code>1aef003</code></a>
Address review feedback on overlay disk flags</li>
<li><a
href="https://github.com/github/codeql-action/commit/508b83bc415e8df76ce8ea08c0cf42c2529ebc63"><code>508b83b</code></a>
Merge main into overlay minimum disk feature branch</li>
<li><a
href="https://github.com/github/codeql-action/commit/d97b3428e8eebbb1810cf454d6397886d136b4ba"><code>d97b342</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4098">#4098</a>
from github/mbg/permission-error-as-configuration-error</li>
<li><a
href="https://github.com/github/codeql-action/commit/47fa6222231b12097f83215dd7a6b4a0915841fd"><code>47fa622</code></a>
Make <code>EACCES</code> a <code>ConfigurationError</code></li>
<li><a
href="https://github.com/github/codeql-action/commit/45693cc6882bb175b58a06818c91876e201037c7"><code>45693cc</code></a>
Refactor <code>ENOSPC</code> check into
<code>isDiskConfigurationError</code> function</li>
<li><a
href="https://github.com/github/codeql-action/commit/c2fd8f54d19fa46c94ed79cb92e6dd6606d61762"><code>c2fd8f5</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4081">#4081</a>
from github/mario-campos/version-cache-to-disk</li>
<li><a
href="https://github.com/github/codeql-action/commit/c56f48e9bd458a387eb68a68534459e503e56b17"><code>c56f48e</code></a>
Log unexpected conditions during caching CLI output</li>
<li>Additional commits viewable in <a
href="https://github.com/github/codeql-action/compare/ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd...db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28">compare
view</a></li>
</ul>
</details>
<br />

Updates `github/codeql-action/autobuild` from 4.37.7 to 4.37.8
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/github/codeql-action/releases">github/codeql-action/autobuild's
releases</a>.</em></p>
<blockquote>
<h2>v4.37.8</h2>
<p>No user facing changes.</p>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/github/codeql-action/blob/main/CHANGELOG.md">github/codeql-action/autobuild's
changelog</a>.</em></p>
<blockquote>
<h1>CodeQL Action Changelog</h1>
<p>See the <a
href="https://github.com/github/codeql-action/releases">releases
page</a> for the relevant changes to the CodeQL CLI and language
packs.</p>
<h2>[UNRELEASED]</h2>
<p>No user facing changes.</p>
<h2>4.37.9 - 26 Aug 2026</h2>
<ul>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.26.4">2.26.4</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/4106">#4106</a></li>
</ul>
<h2>4.37.8 - 21 Aug 2026</h2>
<p>No user facing changes.</p>
<h2>4.37.7 - 13 Aug 2026</h2>
<ul>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.26.3">2.26.3</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/4085">#4085</a></li>
</ul>
<h2>4.37.6 - 04 Aug 2026</h2>
<ul>
<li>Changed the default filepath for the new remote file address format
that was introduced in CodeQL Action 4.37.0 / 3.37.0 to
<code>.github/codeql-config.yml</code> to align it with the suggested
path that is used elsewhere. <a
href="https://redirect.github.com/github/codeql-action/pull/4070">#4070</a></li>
</ul>
<h2>4.37.5 - 03 Aug 2026</h2>
<ul>
<li>Fixed a bug where a network error while streaming the download of
the CodeQL bundle could terminate the <code>init</code> Action instead
of falling back to downloading the bundle before extracting it. <a
href="https://redirect.github.com/github/codeql-action/pull/4061">#4061</a></li>
</ul>
<h2>4.37.4 - 29 Jul 2026</h2>
<ul>
<li>This version of the CodeQL Action adds support for the
<code>tools</code> input for the <code>codeql-action/init</code> step to
be specified using a <code>github-codeql-tools</code> <a
href="https://docs.github.com/en/organizations/managing-organization-settings/managing-custom-properties-for-repositories-in-your-organization">repository
property</a>. This feature will gradually be rolled out following the
release of this version. Once rolled out, this allows for the CodeQL CLI
version that is used in GitHub-managed workflows, such as Default Setup,
to be set to a custom value. For example, customers who run into issues
with rate limits when a new CodeQL CLI version is released can set the
value to <code>toolcache</code> to always use the CodeQL CLI version
that is available in the runner toolcache. For Advanced Setup workflows,
the value provided for <code>tools</code> in the workflow definition
always takes precedence unless the value of the repository property
starts with <code>!</code>. <a
href="https://redirect.github.com/github/codeql-action/pull/4037">#4037</a></li>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.26.2">2.26.2</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/4051">#4051</a></li>
</ul>
<h2>4.37.3 - 22 Jul 2026</h2>
<p>No user facing changes.</p>
<h2>4.37.2 - 21 Jul 2026</h2>
<ul>
<li>The new address format for the <code>config-file</code> input that
was introduced in CodeQL Action 4.37.0 is now enabled by default. In
addition to the format described there, the <code>remote=</code> prefix
can now be used to explicitly indicate that the input refers to a remote
file. All previous input formats continue to be accepted as well. <a
href="https://redirect.github.com/github/codeql-action/pull/4023">#4023</a></li>
<li>The CodeQL Action can now make use of <a
href="https://docs.github.com/en/code-security/how-tos/secure-at-scale/configure-organization-security/manage-usage-and-access/giving-org-access-private-registries">configured
private registries</a> in Default Setup to retrieve CodeQL configuration
files from remote repositories that require authentication. This will
allow customers to store their CodeQL configuration in a single
repository that can then be referenced by Default Setup workflows in
other repositories. We expect to roll this and other, related changes
out to everyone in July. <a
href="https://redirect.github.com/github/codeql-action/pull/4007">#4007</a></li>
</ul>
<h2>4.37.1 - 16 Jul 2026</h2>
<ul>
<li><em>Upcoming breaking change</em>: Add a deprecation warning for
customers using CodeQL version 2.20.6 and earlier. These versions of
CodeQL were discontinued on 1 July 2026 alongside GitHub Enterprise
Server 3.16, and will be unsupported by the next minor release of the
CodeQL Action. <a
href="https://redirect.github.com/github/codeql-action/pull/3956">#3956</a></li>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.26.1">2.26.1</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/4019">#4019</a></li>
</ul>
<h2>4.37.0 - 08 Jul 2026</h2>
<ul>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.26.0">2.26.0</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/3995">#3995</a></li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/github/codeql-action/commit/db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28"><code>db488dd</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4102">#4102</a>
from github/update-v4.37.8-9ee088e13</li>
<li><a
href="https://github.com/github/codeql-action/commit/1845f5ba8b4057590f49ee8e246c95ef2ba4b53f"><code>1845f5b</code></a>
Update changelog for v4.37.8</li>
<li><a
href="https://github.com/github/codeql-action/commit/9ee088e13615f8d1eaef4766f9dde95d3356a8f6"><code>9ee088e</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4080">#4080</a>
from github/henrymercer/studious-giggle</li>
<li><a
href="https://github.com/github/codeql-action/commit/1aef003397c876c0ab5bd118e1b1f34c175622e9"><code>1aef003</code></a>
Address review feedback on overlay disk flags</li>
<li><a
href="https://github.com/github/codeql-action/commit/508b83bc415e8df76ce8ea08c0cf42c2529ebc63"><code>508b83b</code></a>
Merge main into overlay minimum disk feature branch</li>
<li><a
href="https://github.com/github/codeql-action/commit/d97b3428e8eebbb1810cf454d6397886d136b4ba"><code>d97b342</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4098">#4098</a>
from github/mbg/permission-error-as-configuration-error</li>
<li><a
href="https://github.com/github/codeql-action/commit/47fa6222231b12097f83215dd7a6b4a0915841fd"><code>47fa622</code></a>
Make <code>EACCES</code> a <code>ConfigurationError</code></li>
<li><a
href="https://github.com/github/codeql-action/commit/45693cc6882bb175b58a06818c91876e201037c7"><code>45693cc</code></a>
Refactor <code>ENOSPC</code> check into
<code>isDiskConfigurationError</code> function</li>
<li><a
href="https://github.com/github/codeql-action/commit/c2fd8f54d19fa46c94ed79cb92e6dd6606d61762"><code>c2fd8f5</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4081">#4081</a>
from github/mario-campos/version-cache-to-disk</li>
<li><a
href="https://github.com/github/codeql-action/commit/c56f48e9bd458a387eb68a68534459e503e56b17"><code>c56f48e</code></a>
Log unexpected conditions during caching CLI output</li>
<li>Additional commits viewable in <a
href="https://github.com/github/codeql-action/compare/ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd...db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28">compare
view</a></li>
</ul>
</details>
<br />

Updates `github/codeql-action/analyze` from 4.37.7 to 4.37.8
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/github/codeql-action/releases">github/codeql-action/analyze's
releases</a>.</em></p>
<blockquote>
<h2>v4.37.8</h2>
<p>No user facing changes.</p>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/github/codeql-action/blob/main/CHANGELOG.md">github/codeql-action/analyze's
changelog</a>.</em></p>
<blockquote>
<h1>CodeQL Action Changelog</h1>
<p>See the <a
href="https://github.com/github/codeql-action/releases">releases
page</a> for the relevant changes to the CodeQL CLI and language
packs.</p>
<h2>[UNRELEASED]</h2>
<p>No user facing changes.</p>
<h2>4.37.9 - 26 Aug 2026</h2>
<ul>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.26.4">2.26.4</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/4106">#4106</a></li>
</ul>
<h2>4.37.8 - 21 Aug 2026</h2>
<p>No user facing changes.</p>
<h2>4.37.7 - 13 Aug 2026</h2>
<ul>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.26.3">2.26.3</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/4085">#4085</a></li>
</ul>
<h2>4.37.6 - 04 Aug 2026</h2>
<ul>
<li>Changed the default filepath for the new remote file address format
that was introduced in CodeQL Action 4.37.0 / 3.37.0 to
<code>.github/codeql-config.yml</code> to align it with the suggested
path that is used elsewhere. <a
href="https://redirect.github.com/github/codeql-action/pull/4070">#4070</a></li>
</ul>
<h2>4.37.5 - 03 Aug 2026</h2>
<ul>
<li>Fixed a bug where a network error while streaming the download of
the CodeQL bundle could terminate the <code>init</code> Action instead
of falling back to downloading the bundle before extracting it. <a
href="https://redirect.github.com/github/codeql-action/pull/4061">#4061</a></li>
</ul>
<h2>4.37.4 - 29 Jul 2026</h2>
<ul>
<li>This version of the CodeQL Action adds support for the
<code>tools</code> input for the <code>codeql-action/init</code> step to
be specified using a <code>github-codeql-tools</code> <a
href="https://docs.github.com/en/organizations/managing-organization-settings/managing-custom-properties-for-repositories-in-your-organization">repository
property</a>. This feature will gradually be rolled out following the
release of this version. Once rolled out, this allows for the CodeQL CLI
version that is used in GitHub-managed workflows, such as Default Setup,
to be set to a custom value. For example, customers who run into issues
with rate limits when a new CodeQL CLI version is released can set the
value to <code>toolcache</code> to always use the CodeQL CLI version
that is available in the runner toolcache. For Advanced Setup workflows,
the value provided for <code>tools</code> in the workflow definition
always takes precedence unless the value of the repository property
starts with <code>!</code>. <a
href="https://redirect.github.com/github/codeql-action/pull/4037">#4037</a></li>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.26.2">2.26.2</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/4051">#4051</a></li>
</ul>
<h2>4.37.3 - 22 Jul 2026</h2>
<p>No user facing changes.</p>
<h2>4.37.2 - 21 Jul 2026</h2>
<ul>
<li>The new address format for the <code>config-file</code> input that
was introduced in CodeQL Action 4.37.0 is now enabled by default. In
addition to the format described there, the <code>remote=</code> prefix
can now be used to explicitly indicate that the input refers to a remote
file. All previous input formats continue to be accepted as well. <a
href="https://redirect.github.com/github/codeql-action/pull/4023">#4023</a></li>
<li>The CodeQL Action can now make use of <a
href="https://docs.github.com/en/code-security/how-tos/secure-at-scale/configure-organization-security/manage-usage-and-access/giving-org-access-private-registries">configured
private registries</a> in Default Setup to retrieve CodeQL configuration
files from remote repositories that require authentication. This will
allow customers to store their CodeQL configuration in a single
repository that can then be referenced by Default Setup workflows in
other repositories. We expect to roll this and other, related changes
out to everyone in July. <a
href="https://redirect.github.com/github/codeql-action/pull/4007">#4007</a></li>
</ul>
<h2>4.37.1 - 16 Jul 2026</h2>
<ul>
<li><em>Upcoming breaking change</em>: Add a deprecation warning for
customers using CodeQL version 2.20.6 and earlier. These versions of
CodeQL were discontinued on 1 July 2026 alongside GitHub Enterprise
Server 3.16, and will be unsupported by the next minor release of the
CodeQL Action. <a
href="https://redirect.github.com/github/codeql-action/pull/3956">#3956</a></li>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.26.1">2.26.1</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/4019">#4019</a></li>
</ul>
<h2>4.37.0 - 08 Jul 2026</h2>
<ul>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.26.0">2.26.0</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/3995">#3995</a></li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/github/codeql-action/commit/db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28"><code>db488dd</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4102">#4102</a>
from github/update-v4.37.8-9ee088e13</li>
<li><a
href="https://github.com/github/codeql-action/commit/1845f5ba8b4057590f49ee8e246c95ef2ba4b53f"><code>1845f5b</code></a>
Update changelog for v4.37.8</li>
<li><a
href="https://github.com/github/codeql-action/commit/9ee088e13615f8d1eaef4766f9dde95d3356a8f6"><code>9ee088e</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4080">#4080</a>
from github/henrymercer/studious-giggle</li>
<li><a
href="https://github.com/github/codeql-action/commit/1aef003397c876c0ab5bd118e1b1f34c175622e9"><code>1aef003</code></a>
Address review feedback on overlay disk flags</li>
<li><a
href="https://github.com/github/codeql-action/commit/508b83bc415e8df76ce8ea08c0cf42c2529ebc63"><code>508b83b</code></a>
Merge main into overlay minimum disk feature branch</li>
<li><a
href="https://github.com/github/codeql-action/commit/d97b3428e8eebbb1810cf454d6397886d136b4ba"><code>d97b342</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4098">#4098</a>
from github/mbg/permission-error-as-configuration-error</li>
<li><a
href="https://github.com/github/codeql-action/commit/47fa6222231b12097f83215dd7a6b4a0915841fd"><code>47fa622</code></a>
Make <code>EACCES</code> a <code>ConfigurationError</code></li>
<li><a
href="https://github.com/github/codeql-action/commit/45693cc6882bb175b58a06818c91876e201037c7"><code>45693cc</code></a>
Refactor <code>ENOSPC</code> check into
<code>isDiskConfigurationError</code> function</li>
<li><a
href="https://github.com/github/codeql-action/commit/c2fd8f54d19fa46c94ed79cb92e6dd6606d61762"><code>c2fd8f5</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4081">#4081</a>
from github/mario-campos/version-cache-to-disk</li>
<li><a
href="https://github.com/github/codeql-action/commit/c56f48e9bd458a387eb68a68534459e503e56b17"><code>c56f48e</code></a>
Log unexpected conditions during caching CLI output</li>
<li>Additional commits viewable in <a
href="https://github.com/github/codeql-action/compare/ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd...db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28">compare
view</a></li>
</ul>
</details>
<br />


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-30 12:17:50 -04:00
60 changed files with 3511 additions and 1141 deletions
+3 -3
View File
@@ -25,14 +25,14 @@ jobs:
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Initialize CodeQL
uses: github/codeql-action/init@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v3
uses: github/codeql-action/init@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v3
with:
languages: ${{ matrix.language }}
- name: Autobuild
uses: github/codeql-action/autobuild@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v3
uses: github/codeql-action/autobuild@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v3
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v3
uses: github/codeql-action/analyze@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v3
with:
category: "/language:${{ matrix.language }}"
+1 -1
View File
@@ -31,7 +31,7 @@ RUN npm run build
FROM ghcr.io/astral-sh/uv:0.12.5@sha256:e85be844203885286c60ffad8a858d48afb6c5a5c237ca0e67f12e74b8f174b1 AS uv
# Use python-slim as the base image
FROM python:3.14.7-slim@sha256:ce40764625a4ff50df3548277632e7f96c4e77fe75fa848aae9885476e7df5a4 AS base
FROM python:3.14.7-slim@sha256:cae66f2ef0ec51a9891263eeee7f987dacf0a9879e8aa9353d5606e0530619a5 AS base
# Add build argument for version
ARG BUILD_VERSION
+11
View File
@@ -2344,6 +2344,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_PAGE_SOURCE_TIMEOUT` | How long to wait for a solved page to produce its content before the bypass is retried. Raise it if solves succeed but searches still fail. | number | `20` |
| `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>
@@ -2400,6 +2401,16 @@ Timeout for external bypasser requests in milliseconds.
- **Requires restart:** Yes
- **Constraints:** min: 10000, max: 300000
#### `BYPASS_PAGE_SOURCE_TIMEOUT`
**Page Read Timeout (seconds)**
How long to wait for a solved page to produce its content before the bypass is retried. Raise it if solves succeed but searches still fail.
- **Type:** number
- **Default:** `20`
- **Constraints:** min: 1, max: 120
#### `BYPASS_BROWSER_IDLE_TIMEOUT`
**Bypasser Idle Timeout (seconds)**
+2 -2
View File
@@ -32,7 +32,7 @@ dependencies = [
browser = [
"pyvirtualdisplay",
"pyautogui",
"seleniumbase==4.52.2",
"seleniumbase==4.53.5",
"python-xlib",
]
@@ -43,7 +43,7 @@ dev = [
"pytest",
"pytest-cov",
"pytest-xdist>=3.8.0",
"ruff==0.16.4",
"ruff==0.16.5",
"vulture>=2.14",
]
+11
View File
@@ -3,3 +3,14 @@
class BypassCancelledError(Exception):
"""Raised when a bypass operation is cancelled."""
class ChallengeNotSolvedError(Exception):
"""Raised when a bypasser ran but the site still answered with a challenge.
Distinct from a bypasser that is broken or unreachable, which is what every
"the bypass failed" message used to say. A solver can do its job perfectly and
still be handed something it cannot clear - DDoS-Guard's manual CAPTCHA page is
the case from #1292 - and telling the user to go check that FlareSolverr is
reachable sends them to fix a service that is working.
"""
+61 -4
View File
@@ -6,7 +6,8 @@ from typing import TYPE_CHECKING, Any
import requests
from shelfmark.bypass import BypassCancelledError
from shelfmark.bypass import BypassCancelledError, ChallengeNotSolvedError
from shelfmark.bypass.challenge import challenge_marker
from shelfmark.bypass.cookie_store import store_extracted_cookies
from shelfmark.core.config import config
from shelfmark.core.logger import setup_logger
@@ -91,7 +92,13 @@ def _store_solution_clearance(target_url: str, solution: Mapping[str, Any]) -> N
def _fetch_via_bypasser(target_url: str) -> str | None:
"""Make a single request to the external bypasser service. Returns HTML or None."""
"""Make a single request to the external bypasser service. Returns HTML or None.
Raises:
ChallengeNotSolvedError: the service answered with a page that is still a
challenge, whatever verdict it reported on itself.
"""
raw_bypasser_url = _coerce_config_str(
config.get("EXT_BYPASSER_URL", "http://flaresolverr:8191"),
"http://flaresolverr:8191",
@@ -143,6 +150,32 @@ def _fetch_via_bypasser(target_url: str) -> str | None:
logger.warning("External bypasser returned empty response for '%s'", target_url)
return None
# "Challenge solved!" is the solver's verdict on its own work, and #1289 showed
# it can be reported alongside a page the caller then rejects. Say what actually
# came back, so a later report does not have to infer it from downstream errors.
marker = challenge_marker(html)
logger.debug(
"External bypasser page for '%s': %d bytes, challenge_marker=%r",
target_url,
len(html),
marker,
)
if marker:
# The solver's verdict is not evidence; the page is. Returning this one as a
# success is what made #1292 unrecoverable: the retry-and-rotate loop that
# could still have saved the search - the next mirror is a different
# DDoS-Guard host, in its own state - was never entered, and the challenge
# page's own __ddg cookies were filed as this host's clearance and replayed
# on every later request.
logger.warning(
"External bypasser reported success but returned a challenge page for "
"'%s' (%d bytes, marker=%r) - the solve did not clear the protection",
target_url,
len(html),
marker,
)
raise ChallengeNotSolvedError(marker)
try:
_store_solution_clearance(target_url, solution)
except AttributeError, KeyError, TypeError, ValueError:
@@ -192,16 +225,33 @@ def get_bypassed_page(
selector: network.AAMirrorSelector | None = None,
cancel_flag: Event | None = None,
) -> str | None:
"""Fetch HTML via external bypasser with retries and mirror rotation."""
"""Fetch HTML via external bypasser with retries and mirror rotation.
Raises:
ChallengeNotSolvedError: every attempt came back still carrying a challenge.
Reported apart from returning None because the two ask the user for
opposite things: None means go and check the bypasser, this means the
bypasser is fine and the host is the one refusing.
BypassCancelledError: the caller's cancel flag was set.
"""
from shelfmark.download import network as network_module
sel = selector or network_module.AAMirrorSelector()
unsolved_marker: str | None = None
for attempt in range(1, MAX_RETRY + 1):
_check_cancelled(cancel_flag, "by user")
attempt_url = sel.rewrite(url)
result = _fetch_via_bypasser(attempt_url)
try:
result = _fetch_via_bypasser(attempt_url)
except ChallengeNotSolvedError as e:
# Worth the remaining attempts rather than an immediate give-up: the retry
# rotates onto the next mirror, and that is a different DDoS-Guard host with
# its own idea of whether this caller needs a CAPTCHA.
unsolved_marker = str(e) or unsolved_marker
result = None
if result:
return result
@@ -222,4 +272,11 @@ def get_bypassed_page(
if action in ("mirror", "dns") and new_base:
logger.info("Rotated %s for retry", action)
if unsolved_marker:
msg = (
"The bypasser ran, but the site kept answering with a protection challenge "
f"(marker={unsolved_marker!r}). That is usually a manual CAPTCHA, which no "
"bypasser can answer - the bypasser itself is working. Try again shortly."
)
raise ChallengeNotSolvedError(msg)
return None
+26 -14
View File
@@ -82,6 +82,9 @@ _HELPER_RESULT_POLL_SECONDS = 0.05
# what it is doing and exit before its session is killed instead.
_HELPER_SHUTDOWN_GRACE_SECONDS = 15.0
_HELPER_IDLE_TIMEOUT_DEFAULT = 180.0
# How long to wait for a solved page to produce its document before the attempt is
# abandoned. SeleniumBase's own get_page_source() allows one second; see _read_page_source.
_PAGE_SOURCE_TIMEOUT_DEFAULT = 20.0
_PARENT_WATCHDOG_INTERVAL_SECONDS = 5.0
# How much of ffmpeg's stderr to quote when reporting that it died.
_FFMPEG_ERROR_TAIL_CHARS = 500
@@ -522,18 +525,6 @@ async def _bypass_method_humanlike(page: Any) -> bool:
return False
async def _bypass_method_cdp_solve(page: Any) -> bool:
"""CDP Mode with solve_captcha() - auto-detects challenge type."""
try:
logger.debug("Attempting bypass: CDP solve_captcha")
await page.solve_captcha()
await asyncio.sleep(_RNG.uniform(3, 5))
return await _is_bypassed(page)
except _CDP_OPERATION_ERRORS as e:
logger.debug("CDP solve_captcha failed: %s", e)
return False
CDP_CLICK_SELECTORS = [
"#turnstile-widget div", # Cloudflare Turnstile
"#cf-turnstile div", # Alternative CF Turnstile
@@ -613,8 +604,13 @@ async def _bypass_method_cdp_gui_click(page: Any) -> bool:
return False
# Ordered cheapest-first, and deliberately without a bare `solve_captcha()` entry:
# _bypass_method_cdp_gui_click opens by doing exactly that and returns the moment it
# works, so a separate method ahead of it could only ever repeat the half that had
# already failed - one wasted round trip plus the backoff before the next attempt, on
# every solve that gets this far. Measured at ~5.5s of the ~26s each solve cost, and
# 0/19 successes for the standalone method against DDoS-Guard. See issue #1285.
BYPASS_METHODS = [
_bypass_method_cdp_solve,
_bypass_method_cdp_gui_click,
_bypass_method_cdp_click,
_bypass_method_humanlike,
@@ -819,6 +815,22 @@ def _build_host_resolver_rules() -> list[str]:
DRIVER_RESET_ERRORS = {"ProtocolException", "RuntimeError", "TimeoutError"}
async def _read_page_source(page: Any) -> str:
"""Read a solved page's HTML, waiting for the document to arrive.
`get_page_source()` waits one second for the `html` element. A page released from a
challenge is often still navigating to the real content, so the read times out even
though the solve succeeded: the whole attempt is retried, and the repeated requests
are what earn a 429 from a host that was about to serve us.
"""
timeout = _coerce_non_negative_float(
app_config.get("BYPASS_PAGE_SOURCE_TIMEOUT", _PAGE_SOURCE_TIMEOUT_DEFAULT),
_PAGE_SOURCE_TIMEOUT_DEFAULT,
)
element = await page.find("html", timeout=timeout)
return await element.get_html_async()
async def _get(url: str, driver: Any, cancel_flag: Event | None = None) -> str:
"""Fetch URL with Cloudflare bypass using a CDP browser."""
_check_cancellation(cancel_flag, "Bypass cancelled before starting")
@@ -842,7 +854,7 @@ async def _get(url: str, driver: Any, cancel_flag: Event | None = None) -> str:
logger.debug("Starting bypass process...")
if await _bypass(page, cancel_flag=cancel_flag):
await _extract_cookies_from_cdp(driver, page, url)
return await page.get_page_source()
return await _read_page_source(page)
logger.warning("Bypass completed but page still shows protection")
try:
+12
View File
@@ -1683,6 +1683,18 @@ def cloudflare_bypass_settings() -> list[SettingsField]:
requires_restart=True,
show_when={"field": "USING_EXTERNAL_BYPASSER", "value": True},
),
NumberField(
key="BYPASS_PAGE_SOURCE_TIMEOUT",
label="Page Read Timeout (seconds)",
description=(
"How long to wait for a solved page to produce its content before the "
"bypass is retried. Raise it if solves succeed but searches still fail."
),
default=20,
min_value=1,
max_value=120,
show_when={"field": "USING_EXTERNAL_BYPASSER", "value": False},
),
NumberField(
key="BYPASS_BROWSER_IDLE_TIMEOUT",
label="Bypasser Idle Timeout (seconds)",
+45 -10
View File
@@ -112,18 +112,53 @@ def _normalize_languages(languages: list[str] | None, user_id: int | None) -> li
return _to_language_codes(languages, source="the search request")
def _pick_search_author(book: BookMetadata) -> str:
def first_author(value: str) -> str:
"""The first name in a possibly comma-joined author string.
Both ends of the app hand us every contributor in one string. The frontend joins
`authors` with ", " for display (`bookTransformers.ts`) and that display string comes
straight back as the `author` request parameter, while several providers set
`search_author` from the same joined text. Searching a release source for
"Blindness Jose Saramago, Giovanni Pontiero, ..." - the author plus two translators -
matches nothing, and the user is told the book has no releases at all.
A "Last, First" author collapses to the surname, which is still a usable search term
and is what the authors[] fallback has always done with the same input. See #1252.
"""
first, _, _ = value.partition(",")
return first.strip()
def pick_search_author(book: BookMetadata) -> str:
"""The one author a release query should carry, from whichever field holds one.
Every release source that builds its own query wants exactly this, so it lives here
rather than being re-derived per source - the two branches below drifted apart once
already (#1252) and the IRC source carried a third copy of the same preference.
#1290 fixed the same report by merging the two branches and trimming whichever one
won; this keeps that outcome ("Blindness Jose Saramago" from either field, measured
there at 0 releases before and 49 after) and adds the empty-narrowing fallback, so a
credit list that merely starts with a blank entry does not fall out to title-only.
"""
# Narrowing can come back empty - the joined string starts with a comma because the
# first contributor was blank, and `authors.join(', ')` does not drop the empty entry.
# Falling through to authors[] then still finds a usable name; returning "" would
# search by title alone and lose the author we were holding all along.
if book.search_author:
return book.search_author
narrowed = first_author(book.search_author)
if narrowed:
return narrowed
if not book.authors:
return ""
# A bare string here would otherwise be iterated one character at a time; the IRC
# source guarded against exactly that before it shared this helper.
authors = book.authors if isinstance(book.authors, list) else [book.authors or ""]
for author in authors:
narrowed = first_author(author or "")
if narrowed:
return narrowed
first = book.authors[0]
if "," in first:
first = first.split(",")[0].strip()
return first
return ""
def _pick_search_title(book: BookMetadata) -> str:
@@ -150,7 +185,7 @@ def build_release_search_plan(
if manual_query:
resolved_manual_query = manual_query.strip()[:MANUAL_QUERY_MAX_LEN] or None
author = _pick_search_author(book)
author = pick_search_author(book)
base_title = _pick_search_title(book)
if resolved_manual_query:
+49 -2
View File
@@ -5,12 +5,12 @@ import time
from http import HTTPStatus
from io import BytesIO
from typing import TYPE_CHECKING, NoReturn
from urllib.parse import urljoin, urlparse
from urllib.parse import parse_qsl, urlencode, urljoin, urlparse, urlunparse
import requests
from tqdm import tqdm
from shelfmark.bypass import BypassCancelledError, cookie_store
from shelfmark.bypass import BypassCancelledError, ChallengeNotSolvedError, cookie_store
from shelfmark.bypass.challenge import challenge_marker
from shelfmark.core import search_deadline
from shelfmark.core.config import config as app_config
@@ -29,6 +29,10 @@ logger = setup_logger(__name__)
_RNG = random.SystemRandom()
_MAX_REDIRECTS = 5
# DDoS-Guard's re-check probe. Its 302 to `?check=1` is one hop of a handshake rather
# than a page: the parameter asserts the caller already holds the cookies that hop
# issued.
_DDG_CHECK_PARAM = "check"
# Z-Library answers the first hit with a 503 whose only real payload is a Set-Cookie; echoing
# that cookie back returns the 302 to the real page. Two attempts cover the handshake without
# letting a server that keeps re-issuing cookies hold us in the loop.
@@ -48,6 +52,7 @@ _BYPASS_GRACE_SLACK_SECONDS = 30.0
_BYPASSER_ERRORS = (
AttributeError,
BypassCancelledError,
ChallengeNotSolvedError,
KeyError,
OSError,
RuntimeError,
@@ -252,6 +257,33 @@ def _response_challenge_marker(response: requests.Response) -> str | None:
return None
def _solvable_url(url: str) -> str:
"""The URL a solver should open, given one we may be mid-handshake on.
The manual AA redirect follower in `html_get_page` walks DDoS-Guard's handshake by
reassigning `current_url`, so by the time a 403, a 503 challenge or a redirect loop
hands that URL to a bypasser it is often the `?check=1` probe rather than the page
we actually wanted. A solver opens it in a fresh browser holding none of the cookies
the probe exists to collect, so DDoS-Guard cannot verify it automatically and answers
with the manual CAPTCHA page that nothing can solve - the failure in #1292, where
FlareSolverr reported "Challenge solved!" over a 4.7 KB DDOS-GUARD interstitial.
Handing over the pre-probe URL instead lets the solver's browser run the whole
handshake itself, which is what a real browser does and what the solver is for.
Scoped to the hosts whose redirects we follow manually: everywhere else `check` is
an ordinary query parameter and none of our business.
"""
if not network.should_rotate_dns_for_url(url):
return url
parsed = urlparse(url)
params = parse_qsl(parsed.query, keep_blank_values=True)
kept = [(key, value) for key, value in params if key != _DDG_CHECK_PARAM]
if len(kept) == len(params):
return url
return urlunparse(parsed._replace(query=urlencode(kept)))
def _fatal_mirror_reason(e: Exception) -> str | None:
"""Return why ``e`` proves the mirror is unusable, or None if it may recover.
@@ -371,6 +403,9 @@ def html_get_page(
retry-loop branch above with `continue`, and with MAX_RETRY=1 there is no
later attempt for that branch to run on either.
"""
# Every handoff reaches the solver through here, so this is the one place the
# mid-handshake `?check=1` URL has to be unwound. See _solvable_url.
bypass_url = _solvable_url(bypass_url)
# Never start a minutes-long browser solve on a budget that has already run out:
# nothing downstream would get to report the real reason before the caller's
# deadline (or its reverse proxy) cut the request off.
@@ -405,6 +440,18 @@ def html_get_page(
except _STATUS_CALLBACK_ERRORS:
logger.debug("Rate-limit status callback failed", exc_info=True)
return _fail(str(e), bypass_url)
except ChallengeNotSolvedError as e:
# Not a bypasser malfunction: it ran, and the host answered with something it
# cannot clear - DDoS-Guard's manual CAPTCHA, typically. Must precede the
# generic handler below, whose "the protection bypasser failed" is what sent
# #1292 off to fix a FlareSolverr that was working perfectly.
logger.info("Bypass ran but did not clear the protection: %s", e)
if status_callback:
try:
status_callback("error", str(e))
except _STATUS_CALLBACK_ERRORS:
logger.debug("Unsolved-challenge status callback failed", exc_info=True)
return _fail(str(e), bypass_url)
except _BYPASSER_ERRORS as e:
logger.warning("Bypasser error: %s: %s", type(e).__name__, e)
# Surface the real reason. Without this the caller only sees an empty
+11 -1
View File
@@ -1180,6 +1180,12 @@ def api_config() -> Response | tuple[Response, int]:
[],
user_id=db_user_id,
),
# The client must not give up before this budget does. `/api/releases`
# answers a spent budget with a message naming the real cause (a protection
# challenge nobody could solve); a browser that aborted first replaces it
# with a generic network/proxy error and RELEASE_SEARCH_TIMEOUT becomes a
# setting the user can raise with no visible effect. See issue #1285.
"release_search_timeout": search_deadline.budget_seconds(),
"settings_enabled": _is_config_dir_writable(),
"onboarding_complete": _get_onboarding_complete(),
# Default sort orders
@@ -2947,6 +2953,10 @@ def api_releases() -> Response | tuple[Response, int]:
elif provider == "manual":
resolved_title = title_param or manual_query or "Manual Search"
resolved_author = author_param or ""
# The release modal sends `authors.join(', ')` as `author`, so the commas here
# are joins between contributors, not part of one name. This split is the only
# place that knows that, so `search_author` comes from it rather than from the
# joined text - see issue #1252.
authors = [a.strip() for a in resolved_author.split(",") if a.strip()]
book = BookMetadata(
@@ -2955,7 +2965,7 @@ def api_releases() -> Response | tuple[Response, int]:
provider_display_name="Manual Search",
title=resolved_title,
search_title=resolved_title,
search_author=resolved_author or None,
search_author=authors[0] if authors else None,
authors=authors,
)
else:
+5 -1
View File
@@ -519,6 +519,10 @@ def browse_record_to_book_metadata(
"""Convert a source-native browse record into generic book metadata."""
resolved_title = title_override or str(record.title or "").strip() or "Unknown title"
resolved_author = author_override or str(record.author or "").strip()
# `author_override` is the frontend's display string, `authors.join(', ')` - every
# contributor, translators included. The split below is the only place that knows the
# commas were joins rather than part of a name, so `search_author` is taken from it
# rather than from the joined text. See issue #1252.
authors = [part.strip() for part in resolved_author.split(",") if part.strip()]
publish_year = None
@@ -535,7 +539,7 @@ def browse_record_to_book_metadata(
provider_display_name=get_source_display_name(record.source),
title=resolved_title,
search_title=resolved_title,
search_author=resolved_author or None,
search_author=authors[0] if authors else None,
authors=authors,
cover_url=record.preview,
description=record.description,
+179 -20
View File
@@ -6,6 +6,8 @@ import re
import threading
import time
import unicodedata
from contextlib import contextmanager
from contextvars import ContextVar
from dataclasses import replace
from http import HTTPStatus
from pathlib import Path
@@ -16,6 +18,7 @@ import requests
from bs4 import BeautifulSoup, Tag
from bs4.element import NavigableString
from shelfmark.bypass.challenge import MAX_CHALLENGE_HTML_CHARS, challenge_marker
from shelfmark.config.env import DEBUG_SKIP_SOURCES, TMP_DIR
from shelfmark.core import search_deadline
from shelfmark.core.config import config
@@ -43,7 +46,7 @@ from shelfmark.release_sources import (
)
if TYPE_CHECKING:
from collections.abc import Callable, Iterable
from collections.abc import Callable, Iterable, Iterator
from pathlib import Path
from threading import Event
@@ -112,6 +115,16 @@ def _html_response_text(response: str | tuple[str, str]) -> str:
return response
def _html_response_url(response: str | tuple[str, str]) -> str | None:
"""The URL that actually answered, when the downloader was asked to report it.
None for the plain-string shape, so a caller can fall back to what it requested.
"""
if isinstance(response, tuple):
return response[1] or None
return None
def _attr_to_str(value: object) -> str | None:
"""Convert a BeautifulSoup attribute value to a plain string."""
if isinstance(value, str):
@@ -556,13 +569,6 @@ _AA_PAGE_MARKERS = (
"/fast_download",
"/slow_download",
)
_CHALLENGE_MARKERS = (
"ddos-guard",
"just a moment",
"cloudflare",
"checking your browser",
"cf-browser-verification",
)
def _looks_like_aa_page(html: str) -> bool:
@@ -572,12 +578,120 @@ def _looks_like_aa_page(html: str) -> bool:
def _looks_like_challenge_page(html: str) -> bool:
"""Whether ``html`` is a protection interstitial rather than the site behind it."""
lowered = html.lower()
return any(marker in lowered for marker in _CHALLENGE_MARKERS)
"""Whether ``html`` is a protection interstitial rather than the site behind it.
Delegates to the shared detector rather than substring-matching here. A bare
"ddos-guard"/"cloudflare" scan flags the protected site's *own* pages: DDoS-Guard
links its endpoints on everything it fronts, and AA ships a `DDOS-GUARD` comment in
the inline JS on every page it serves. That misread every real AA response that was
not a results table as an unsolved challenge, and sent users off to fix a bypasser
that had just succeeded - see #1289/#1292. `challenge_marker` caps its scan at
64 KB, which is what separates a few-KB interstitial from the page behind it.
"""
return challenge_marker(html) is not None
# Pages already fetched during the search in flight, keyed by URL. Scoped to one
# DirectDownload.search() so nothing is carried between requests.
_search_page_cache: ContextVar[dict[str, tuple[str, Tag | None]] | None] = ContextVar(
"aa_search_page_cache", default=None
)
@contextmanager
def _search_page_reuse() -> Iterator[None]:
"""Fetch each distinct AA search URL at most once per search.
One search asks AA for the same URL more than once. The language-filter retry in
`search()` re-runs every title variant, and when DIRECT_DOWNLOAD_LANGUAGE_FROM_PATH
is on the requested language is applied locally instead of as `&lang=`, so both
passes build a byte-identical URL - the retry differs only in the filtering it does
to the response it already had. A repeat is not a cheap round trip either: AA is
behind DDoS-Guard, so each one is a fresh browser solve, tens of seconds that buy
nothing. See issue #1285.
"""
token = _search_page_cache.set({})
try:
yield
finally:
_search_page_cache.reset(token)
def _is_reusable_answer(result: tuple[str, Tag | None]) -> bool:
"""Whether a fetched page is an answer, rather than a giving-up worth retrying.
`_fetch_search_table_uncached` exists to rotate past mirrors that are not actually AA,
and when it runs out of them it *returns* instead of raising: a page with no results
table and no marker. Storing that would hand the language-filter retry - the pass this
cache exists for - a mirror set that may have recovered in between (DNS rotation, a
mirror coming back), turning a transient outage into "this book has no releases". A
real "No files found." is an answer and is worth keeping.
"""
html, tbody = result
return tbody is not None or "No files found." in html or _looks_like_aa_page(html)
# How much of an unreadable search page to quote in the debug log. Enough to carry the
# <head> - title, injected challenge scripts - without pasting a 180 KB page into a log
# file that ships inside the debug bundle.
_PAGE_FINGERPRINT_CHARS = 700
_TITLE_RE = re.compile(r"<title[^>]*>(.*?)</title>", re.IGNORECASE | re.DOTALL)
def _log_untabled_search_page(url: str, html: str) -> None:
"""Record why a search page with no results table is about to be classified.
#1289 cost a full investigation because the log said only "unsolved protection
challenge" while FlareSolverr said "Challenge solved!", and the debug bundle carries
no response bodies - there was no way to tell a real AA page from an interstitial
after the fact. These are the facts that would have settled it in one line: the size
(the 64 KB cap is what separates the two), which markers matched, and the head of
the document.
Diagnostics must never be the reason a search fails, so this swallows its own errors.
"""
try:
title_match = _TITLE_RE.search(html[: _PAGE_FINGERPRINT_CHARS * 4])
title = " ".join(title_match.group(1).split())[:120] if title_match else "<none>"
lowered = html.lower()
aa_markers = [marker for marker in _AA_PAGE_MARKERS if marker in lowered]
logger.info(
"Search page has no results table: %s (bytes=%d, title=%r, aa_markers=%s, "
"challenge_marker=%r, over_challenge_size_cap=%s)",
url,
len(html),
title,
aa_markers or "none",
challenge_marker(html),
len(html) > MAX_CHALLENGE_HTML_CHARS,
)
logger.debug(
"Untabled search page head (%d of %d bytes): %s",
min(len(html), _PAGE_FINGERPRINT_CHARS),
len(html),
html[:_PAGE_FINGERPRINT_CHARS],
)
except Exception:
logger.debug("Could not fingerprint the untabled search page", exc_info=True)
def _fetch_search_table(url: str, selector: network.AAMirrorSelector) -> tuple[str, Tag | None]:
"""Fetch the AA search page, reusing one already fetched during this search."""
cache = _search_page_cache.get()
if cache is not None and url in cache:
logger.debug("Reusing search page already fetched for this search: %s", url)
return cache[url]
result = _fetch_search_table_uncached(url, selector)
if cache is not None and _is_reusable_answer(result):
cache[url] = result
return result
def _fetch_search_table_uncached(
url: str, selector: network.AAMirrorSelector
) -> tuple[str, Tag | None]:
"""Fetch the AA search page, retrying past mirrors that are not actually AA.
A parked or seized domain answers 200 with a page that has no results table and no
@@ -592,10 +706,21 @@ def _fetch_search_table(url: str, selector: network.AAMirrorSelector) -> tuple[s
if search_deadline.expired():
raise SearchUnavailableError(search_deadline.deadline_message())
# include_response_url is what makes the diagnostics below name the mirror that
# actually answered. html_get_page rotates mirrors and follows redirects on its
# own, so `attempt_url` is only where this iteration started: #1298's bundle
# reported the untabled page against annas-archive.gl when the body had come
# from .pk, which is precisely the triage cost #1289 added the line to remove.
response = downloader.html_get_page(
attempt_url, selector=selector, allow_bypasser_fallback=True
attempt_url,
selector=selector,
allow_bypasser_fallback=True,
include_response_url=True,
)
if not response:
html = _html_response_text(response)
# Checked on the body, not on `response`: with include_response_url the give-up
# shape is the tuple ("", url), and a tuple is truthy.
if not html:
# Network/mirror exhaustion path bubbles up so API can notify clients.
# html_get_page records the concrete give-up reason on the selector; fall
# back to the generic line only if nothing was recorded.
@@ -604,7 +729,7 @@ def _fetch_search_table(url: str, selector: network.AAMirrorSelector) -> tuple[s
)
raise SearchUnavailableError(f"Unable to reach download source. {detail}")
html = _html_response_text(response)
answered_url = _html_response_url(response) or attempt_url
soup = BeautifulSoup(html, "html.parser")
table = soup.find("table")
if isinstance(table, Tag):
@@ -615,20 +740,38 @@ def _fetch_search_table(url: str, selector: network.AAMirrorSelector) -> tuple[s
if "No files found." in html:
# A real, genuinely empty answer from a healthy mirror.
return html, None
# A search page with no table is the one shape we cannot read off the response
# alone, and the response body is not in the debug bundle. Fingerprint it here
# so the next report says which branch fired and why, rather than costing
# another round of guesswork - see #1289.
_log_untabled_search_page(answered_url, html)
if _looks_like_aa_page(html):
# A real AA response in a shape the caller should report as drift. Checked
# ahead of the challenge branch: AA's own pages carry the protection's
# markers, so an interstitial is only the better explanation once the page
# has nothing of AA's about it. A genuine interstitial has no AA markers.
return html, None
if _looks_like_challenge_page(html):
# The bypass did not actually clear the protection - the interstitial is
# what came back. Rotating is pointless (every mirror shares the same
# protection) and reporting it as an empty result is worse: the user is
# told their query found nothing when the search never ran.
#
# The wording no longer blames the bypasser outright. In #1292 it was
# reachable and working, and the page it was handed was DDoS-Guard's manual
# CAPTCHA - so "check that the bypasser is working" was the one piece of
# advice guaranteed to waste the reporter's time. Name the marker instead
# and let the two causes be told apart.
msg = (
"Anna's Archive answered with an unsolved protection challenge. "
"Check that the bypasser is reachable and working."
"Anna's Archive answered with a protection challenge that was not "
f"cleared (marker={challenge_marker(html)!r}). If the bypasser reports "
"solving it, the host is serving a manual CAPTCHA that no bypasser can "
"answer - try again shortly. Otherwise check that the bypasser is "
"reachable and working."
)
raise SearchUnavailableError(msg)
if _looks_like_aa_page(html):
# A real AA response in a shape the caller should report as drift.
# Not the mirror's fault.
return html, None
new_base, action = selector.next_mirror_or_rotate_dns(
fatal=True, reason="responded without an Anna's Archive page"
@@ -1915,6 +2058,22 @@ class DirectDownloadSource(ReleaseSource):
) -> list[Release]:
"""Search for releases using the book's metadata.
The whole fan-out runs under one page cache, so a URL built twice by different
passes is fetched once. See `_search_page_reuse`.
"""
with _search_page_reuse():
return self._search(book, plan, expand_search=expand_search, content_type=content_type)
def _search(
self,
book: BookMetadata,
plan: ReleaseSearchPlan,
*,
expand_search: bool = False,
content_type: str = "ebook",
) -> list[Release]:
"""Search for releases using the book's metadata.
Priority: ISBN search first (most precise), then title+author fallback.
For non-English languages, uses localized titles from book.titles_by_language.
+8 -5
View File
@@ -15,6 +15,7 @@ if TYPE_CHECKING:
from shelfmark.api.websocket import ws_manager
from shelfmark.core.config import config
from shelfmark.core.logger import setup_logger
from shelfmark.core.search_plan import pick_search_author
from shelfmark.core.utils import is_audiobook
from shelfmark.release_sources import (
ColumnColorHint,
@@ -394,11 +395,13 @@ class IRCReleaseSource(ReleaseSource):
if book.search_title or book.title:
parts.append(book.search_title or book.title)
if book.search_author:
parts.append(book.search_author)
elif book.authors:
# Use first author
author = book.authors[0] if isinstance(book.authors, list) else book.authors
# Only ever the first author: both metadata fields can arrive holding every
# contributor joined with ", ", and an IRC query carrying an author plus two
# translators matches nothing. The choice between them - and the narrowing - is
# `pick_search_author`, shared with the search plan so this cannot drift from it
# again. See issue #1252.
author = pick_search_author(book)
if author:
parts.append(author)
return " ".join(parts)
+27 -17
View File
@@ -41,6 +41,8 @@ from shelfmark.release_sources.prowlarr.api import (
)
from shelfmark.release_sources.prowlarr.cache import cache_release
from shelfmark.release_sources.prowlarr.utils import (
AUTHOR_UNKNOWN,
author_affinity,
build_source_id,
coerce_float_like,
coerce_int_like,
@@ -1008,10 +1010,17 @@ class ProwlarrSource(ReleaseSource):
if time.monotonic() > deadline:
_raise_timeout_error(f"Prowlarr search timed out after {int(search_budget)}s")
def search_indexers(
query: str, cats: list[int] | None, *, enriched_query: str | None = None
) -> _IndexerSearchOutcome:
"""Search indexers with given categories via Torznab/Newznab."""
def search_indexers(query: str, cats: list[int] | None) -> _IndexerSearchOutcome:
"""Search indexers with given categories via Torznab/Newznab.
Every indexer gets the same title-only query. Enriched indexers used
to be sent "{title} {author}", but an indexer that ANDs its search
terms (MyAnonamouse) returns nothing whenever the metadata provider
spells the author differently to the tracker - "Timothy Ferriss" vs
"Tim Ferriss" - and the UI reports the book as missing (#1293). The
author still decides ordering below, where a spelling difference
costs a release its position rather than its existence.
"""
outcome = _IndexerSearchOutcome(results=[])
target_indexer_ids = self._get_search_indexer_ids(client, indexer_ids, cats)
if not target_indexer_ids:
@@ -1019,16 +1028,11 @@ class ProwlarrSource(ReleaseSource):
for indexer_id in target_indexer_ids:
_check_timeout()
indexer_query = (
enriched_query
if indexer_id in enriched_indexer_ids_set and enriched_query
else query
)
outcome.attempted += 1
try:
raw = client.torznab_search(
indexer_id=indexer_id,
query=indexer_query,
query=query,
categories=cats,
search_type="book",
)
@@ -1053,14 +1057,11 @@ class ProwlarrSource(ReleaseSource):
for idx, variant in enumerate(variants, start=1):
_check_timeout()
query = variant.title
enriched_query = variant.query # title + author
if len(variants) > 1:
logger.debug("Prowlarr query %s/%s: '%s'", idx, len(variants), query)
outcome = search_indexers(
query=query, cats=categories, enriched_query=enriched_query
)
outcome = search_indexers(query=query, cats=categories)
# Auto-expand: if no results with categories and auto-expand enabled, retry without.
# Only when every indexer actually answered: a failed search says nothing about
@@ -1077,9 +1078,7 @@ class ProwlarrSource(ReleaseSource):
"Prowlarr: no results for query '%s' with category filter, auto-expanding search",
query,
)
expanded = search_indexers(
query=query, cats=None, enriched_query=enriched_query
)
expanded = search_indexers(query=query, cats=None)
outcome.results = expanded.results
outcome.attempted += expanded.attempted
outcome.failed += expanded.failed
@@ -1117,6 +1116,10 @@ class ProwlarrSource(ReleaseSource):
results: list[Release] = []
enriched_source_ids: set[str] = set()
affinity_by_source_id: dict[str, int] = {}
# A manual query is the user's own words; ranking it against the
# metadata author would second-guess what they typed.
wanted_author = "" if plan.manual_query else plan.author
for raw_result in all_results:
result_with_seed_settings = _apply_indexer_seed_settings(
@@ -1136,13 +1139,20 @@ class ProwlarrSource(ReleaseSource):
if idx_id_int is not None and idx_id_int in indexer_priority:
release.extra["indexer_priority"] = indexer_priority[idx_id_int]
results.append(release)
affinity_by_source_id[release.source_id] = author_affinity(
wanted_author, release.extra.get("author")
)
if is_enriched:
enriched_source_ids.add(release.source_id)
# Indexer priority first: it is an explicit user preference. Author
# agreement then orders what one indexer returned, so the editions that
# match the requested author lead and the rest stay reachable below.
results.sort(
key=lambda r: (
_release_indexer_rank(r, indexer_priority),
affinity_by_source_id.get(r.source_id, AUTHOR_UNKNOWN),
0 if r.source_id in enriched_source_ids else 1,
)
)
@@ -14,6 +14,20 @@ if TYPE_CHECKING:
_INTEGER_LIKE_PATTERN = re.compile(r"^[+-]?\d+$")
_FLOAT_LIKE_PATTERN = re.compile(r"^[+-]?(?:\d+(?:\.\d*)?|\.\d+)$")
_AUTHOR_TOKEN_PATTERN = re.compile(r"\w+", re.UNICODE)
_AUTHOR_NOISE_TOKENS = frozenset(
{"jr", "sr", "ii", "iii", "iv", "phd", "md", "dr", "mr", "mrs", "ms", "et", "al", "and", "the"}
)
# Ordering tiers for author agreement between the requested book and what an
# indexer reported. Lower sorts first.
AUTHOR_MATCH = 0
AUTHOR_UNKNOWN = 1
AUTHOR_MISMATCH = 2
# A mononym ("Homer") can only ever agree on one token; a longer name needs a
# given name and a surname to agree before it counts as the same person.
_AUTHOR_TOKENS_REQUIRED = 2
def coerce_int_like(value: object) -> int | None:
@@ -32,6 +46,49 @@ def coerce_int_like(value: object) -> int | None:
return int(normalized)
def _author_tokens(value: object) -> list[str]:
"""Split an author string into comparable lowercase name tokens."""
if not isinstance(value, str):
return []
tokens = [token.lower() for token in _AUTHOR_TOKEN_PATTERN.findall(value)]
return [token for token in tokens if token not in _AUTHOR_NOISE_TOKENS]
def _author_tokens_compatible(wanted: str, offered: str) -> bool:
"""Treat an abbreviated given name as the name it abbreviates."""
return wanted == offered or wanted.startswith(offered) or offered.startswith(wanted)
def author_affinity(wanted: object, offered: object) -> int:
"""Rank how far an indexer's author field is from the requested author.
Shelfmark ranks on this rather than filtering on it, so a wrong verdict only
costs a release its position in the list, never its visibility. That is what
makes the loose token comparison safe: "Tim"/"Timothy" and "T."/"Timothy"
agree, while a transliteration ("Dostoevsky"/"Dostoyevsky") is merely sorted
last instead of being hidden.
Three-way on purpose: an indexer that reports no author at all must not sort
below one that reports a wrong author, so "no metadata" ranks between
agreement and disagreement rather than counting as either.
"""
wanted_tokens = _author_tokens(wanted)
offered_tokens = _author_tokens(offered)
if not wanted_tokens or not offered_tokens:
return AUTHOR_UNKNOWN
matched = sum(
1
for wanted_token in wanted_tokens
if any(
_author_tokens_compatible(wanted_token, offered_token)
for offered_token in offered_tokens
)
)
required = min(_AUTHOR_TOKENS_REQUIRED, len(wanted_tokens))
return AUTHOR_MATCH if matched >= required else AUTHOR_MISMATCH
def build_source_id(result: dict) -> str:
"""Build the Release.source_id for a raw Prowlarr result.
+9
View File
@@ -35,6 +35,15 @@
"typescript/no-misused-promises": "error",
"typescript/no-non-null-assertion": "error",
"typescript/only-throw-error": "error",
// React Compiler advisories, enforced everywhere with no per-file exemptions.
// The violations inherited from the oxlint 1.70 -> 1.80 bump are all resolved:
// three by widening a dependency to the object the compiler infers, and seven
// by an `oxlint-disable-next-line` that says, at the callsite, why the flagged
// dependency is load-bearing - five are re-run triggers that are never read,
// and two are values the callback genuinely uses.
"react/preserve-manual-memoization": "error",
"react/exhaustive-effect-dependencies": "error",
"react/memo-dependencies": "error",
"react/no-danger": "error",
"react/no-clone-element": "error",
"react/no-react-children": "error",
+318 -287
View File
File diff suppressed because it is too large Load Diff
+7 -7
View File
@@ -24,17 +24,17 @@
"socket.io-client": "^4.7.5"
},
"devDependencies": {
"@types/node": "^26.2.0",
"@types/node": "^26.3.0",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
"@vitejs/plugin-react": "^6.0.5",
"@types/react-dom": "^19.2.5",
"@vitejs/plugin-react": "^6.1.0",
"knip": "^6.32.2",
"oxfmt": "^0.63.0",
"oxlint": "^1.78.0",
"oxfmt": "^0.65.0",
"oxlint": "^1.80.0",
"oxlint-tsgolint": "^7.0.2001",
"tailwindcss": "^4.2.2",
"typescript": "^7.0.2",
"vite": "^8.2.1",
"vitest": "^4.1.10"
"vite": "^8.2.2",
"vitest": "^4.1.11"
}
}
+107 -100
View File
@@ -32,6 +32,7 @@ import {
import { useActivity } from './hooks/useActivity';
import { useAuth } from './hooks/useAuth';
import { useDownloadTracking } from './hooks/useDownloadTracking';
import { useLatestCallback } from './hooks/useLatestCallback';
import { useMediaQuery } from './hooks/useMediaQuery';
import { useMountEffect } from './hooks/useMountEffect';
import { useRealtimeStatus } from './hooks/useRealtimeStatus';
@@ -73,6 +74,7 @@ import type {
ActingAsUserSelection,
MetadataProviderSummary,
MetadataSearchConfig,
MetadataSearchField,
QueuedDownloadResult,
QueryTargetOption,
SearchMode,
@@ -492,8 +494,6 @@ function App() {
});
// When a book is removed from the Hardcover list currently being browsed, remove it from results
const searchFieldValuesRef = useRef(searchFieldValues);
searchFieldValuesRef.current = searchFieldValues;
useBookTargetDeselectSync({
activeListValue: searchFieldValues.hardcover_list,
setBooks,
@@ -605,24 +605,6 @@ function App() {
};
}, [effectiveActingAsUser, pendingOnBehalfDownload]);
// Wire up logout callback to clear search state
const handleLogoutWithCleanup = useCallback(async () => {
await handleLogout();
resetSearchResultsState();
setActiveQueryTarget('general');
setPendingRequestPayload(null);
setPendingRequestExtraPayloads([]);
setActingAsUser(null);
setAdminUsers([]);
setAdminUsersError(null);
setHasLoadedAdminUsers(false);
setPendingOnBehalfDownload(null);
setFulfillingRequest(null);
resetActivity();
setSettingsOpen(false);
setSelfSettingsOpen(false);
}, [handleLogout, resetActivity, resetSearchResultsState]);
// Combined mode state (ebook + audiobook in one transaction)
const [combinedState, setCombinedState] = useState<CombinedSelectionState | null>(null);
@@ -655,20 +637,6 @@ function App() {
setDownloadsSidebarOpen(true);
prefetchActivityHistory();
}, [downloadsSidebarOpen, prefetchActivityHistory]);
const handleSettingsClick = useCallback(() => {
if (config?.settings_enabled) {
if (authIsAdmin) {
void primeUsersCache();
void primeSettingsCache();
setSettingsOpen(true);
} else {
setSelfSettingsOpen(true);
}
return;
}
setConfigBannerOpen(true);
}, [authIsAdmin, config?.settings_enabled]);
const headerRef = useCallback((el: HTMLDivElement | null) => {
if (headerObserverRef.current) {
headerObserverRef.current.disconnect();
@@ -685,6 +653,39 @@ function App() {
const [settingsOpen, setSettingsOpen] = useState(false);
const [selfSettingsOpen, setSelfSettingsOpen] = useState(false);
const [configBannerOpen, setConfigBannerOpen] = useState(false);
// Wire up logout callback to clear search state
const handleLogoutWithCleanup = useCallback(async () => {
await handleLogout();
resetSearchResultsState();
setActiveQueryTarget('general');
setPendingRequestPayload(null);
setPendingRequestExtraPayloads([]);
setActingAsUser(null);
setAdminUsers([]);
setAdminUsersError(null);
setHasLoadedAdminUsers(false);
setPendingOnBehalfDownload(null);
setFulfillingRequest(null);
resetActivity();
setSettingsOpen(false);
setSelfSettingsOpen(false);
}, [handleLogout, resetActivity, resetSearchResultsState]);
const handleSettingsClick = useCallback(() => {
if (config?.settings_enabled) {
if (authIsAdmin) {
void primeUsersCache();
void primeSettingsCache();
setSettingsOpen(true);
} else {
setSelfSettingsOpen(true);
}
return;
}
setConfigBannerOpen(true);
}, [authIsAdmin, config?.settings_enabled]);
const [onboardingOpen, setOnboardingOpen] = useState(false);
useShowOnboardingDebug({
setOnboardingOpen,
@@ -1075,48 +1076,43 @@ function App() {
// When downloading a book while browsing a Hardcover list the user owns,
// automatically remove it from that list (fire-and-forget).
const searchFieldLabelsRef = useRef(searchFieldLabels);
searchFieldLabelsRef.current = searchFieldLabels;
const metadataConfigRef = useRef(activeMetadataConfig);
metadataConfigRef.current = activeMetadataConfig;
// Stable identity for the download handlers below, while still reading the current
// search field values, labels and metadata config. Not an Effect Event: the callers
// are download handlers, not Effects. See useLatestCallback.
const removeBookFromActiveList = useLatestCallback((book: Book) => {
if (config?.hardcover_auto_remove_on_download === false) return;
if (!bookSupportsTargets(book)) return;
const activeList = searchFieldValues.hardcover_list;
if (!activeList) return;
const target = String(activeList);
const provider = book.provider;
const bookId = book.provider_id;
if (!provider || !bookId) return;
const removeBookFromActiveList = useCallback(
(book: Book) => {
if (config?.hardcover_auto_remove_on_download === false) return;
if (!bookSupportsTargets(book)) return;
const activeList = searchFieldValuesRef.current.hardcover_list;
if (!activeList) return;
const target = String(activeList);
const provider = book.provider;
const bookId = book.provider_id;
if (!provider || !bookId) return;
// Only auto-remove from lists the user owns (Reading Status / My Lists)
const listField = activeMetadataConfig?.search_fields.find(
(f) => f.key === 'hardcover_list' && f.type === 'DynamicSelectSearchField',
);
if (listField && listField.type === 'DynamicSelectSearchField') {
const group = getDynamicOptionGroup(listField.options_endpoint, target);
if (group && group !== 'Reading Status' && group !== 'My Lists') return;
}
// Only auto-remove from lists the user owns (Reading Status / My Lists)
const listField = metadataConfigRef.current?.search_fields.find(
(f) => f.key === 'hardcover_list' && f.type === 'DynamicSelectSearchField',
);
if (listField && listField.type === 'DynamicSelectSearchField') {
const group = getDynamicOptionGroup(listField.options_endpoint, target);
if (group && group !== 'Reading Status' && group !== 'My Lists') return;
}
void setBookTargetState(provider, bookId, target, false)
.then((result) => {
if (result.changed) {
emitBookTargetChange({
provider,
bookId,
target,
selected: false,
});
const listName = searchFieldLabelsRef.current['hardcover_list'];
showToast(`Removed from ${listName || 'list'}`, 'info');
}
})
.catch(() => undefined);
},
[config?.hardcover_auto_remove_on_download, showToast],
);
void setBookTargetState(provider, bookId, target, false)
.then((result) => {
if (result.changed) {
emitBookTargetChange({
provider,
bookId,
target,
selected: false,
});
const listName = searchFieldLabels['hardcover_list'];
showToast(`Removed from ${listName || 'list'}`, 'info');
}
})
.catch(() => undefined);
});
const executeBookDownload = useCallback(
async (book: Book, onBehalfOfUserId?: number): Promise<void> => {
@@ -1909,14 +1905,27 @@ function App() {
effectiveSearchMode === 'universal' &&
(universalDefaultMode === 'download' || universalDefaultMode === 'request_release');
// Keep the last known search fields so queryTargets doesn't collapse to
// [general] while the metadata config briefly reloads on content type switch.
// Held in state rather than a ref written during render: a ref read back in the same
// pass is what `react/refs` forbids, and this is the adjust-state-during-render shape
// React documents for exactly this - carry the previous value until a new one arrives.
const [stableSearchFields, setStableSearchFields] = useState<MetadataSearchField[]>(
() => activeMetadataConfig?.search_fields ?? [],
);
const incomingSearchFields = activeMetadataConfig?.search_fields;
if (incomingSearchFields && incomingSearchFields !== stableSearchFields) {
setStableSearchFields(incomingSearchFields);
}
const queryTargets = useMemo<QueryTargetOption[]>(
() =>
buildQueryTargets({
searchMode: effectiveSearchMode,
metadataSearchFields: activeMetadataConfig?.search_fields ?? [],
metadataSearchFields: stableSearchFields,
manualSearchAllowed,
}),
[effectiveSearchMode, activeMetadataConfig?.search_fields, manualSearchAllowed],
[effectiveSearchMode, stableSearchFields, manualSearchAllowed],
);
const effectiveActiveQueryTarget = useMemo(() => {
if (queryTargets.some((target) => target.key === activeQueryTarget)) {
@@ -1945,27 +1954,27 @@ function App() {
? (queryTargets.find((target) => target.field?.key === seriesBrowseCapability.field_key) ??
null)
: null,
[queryTargets, seriesBrowseCapability?.field_key],
// `seriesBrowseCapability` whole: the body reads `.field_key` off it unguarded
// inside the ternary, so that object is the dependency the compiler infers.
[queryTargets, seriesBrowseCapability],
);
const activeQueryValue = useMemo(() => {
if (
!activeQueryOption ||
activeQueryOption.source === 'general' ||
activeQueryOption.source === 'manual'
activeQueryOption.source === 'manual' ||
activeQueryOption.source === 'direct-field'
) {
return searchInput;
}
if (activeQueryOption.source === 'direct-field') {
if (activeQueryOption.key === 'isbn') return advancedFilters.isbn;
if (activeQueryOption.key === 'author') return advancedFilters.author;
if (activeQueryOption.key === 'title') return advancedFilters.title;
if (!activeQueryOption.field) {
return '';
}
if (!activeQueryOption.field) {
return '';
if (activeQueryOption.field.type === 'TextSearchField') {
return searchInput;
}
if (activeQueryOption.field.type === 'CheckboxSearchField') {
@@ -1975,7 +1984,7 @@ function App() {
}
return searchFieldValues[activeQueryOption.field.key] ?? '';
}, [activeQueryOption, searchInput, advancedFilters, searchFieldValues]);
}, [activeQueryOption, searchInput, searchFieldValues]);
const activeQueryValueLabel = useMemo(() => {
if (!activeQueryOption?.field) {
@@ -2023,29 +2032,25 @@ function App() {
if (
!activeQueryOption ||
activeQueryOption.source === 'general' ||
activeQueryOption.source === 'manual'
activeQueryOption.source === 'manual' ||
activeQueryOption.source === 'direct-field'
) {
setSearchInput(typeof value === 'string' ? value : String(value ?? ''));
return;
}
if (activeQueryOption.source === 'direct-field') {
const nextValue = typeof value === 'string' ? value : String(value ?? '');
if (activeQueryOption.key === 'isbn') {
updateAdvancedFilters({ isbn: nextValue });
} else if (activeQueryOption.key === 'author') {
updateAdvancedFilters({ author: nextValue });
} else if (activeQueryOption.key === 'title') {
updateAdvancedFilters({ title: nextValue });
}
return;
}
if (activeQueryOption.field) {
if (activeQueryOption.field.type === 'TextSearchField') {
setSearchInput(typeof value === 'string' ? value : String(value ?? ''));
if (label !== undefined) {
updateSearchFieldValue(activeQueryOption.field.key, value, label);
}
return;
}
updateSearchFieldValue(activeQueryOption.field.key, value, label);
}
},
[activeQueryOption, setSearchInput, updateAdvancedFilters, updateSearchFieldValue],
[activeQueryOption, setSearchInput, updateSearchFieldValue],
);
const handleSearchModeChange = useCallback(
@@ -2243,7 +2248,9 @@ function App() {
return book.provider === activeMetadataConfig.provider;
},
[activeMetadataConfig?.provider, seriesBrowseCapability?.sort, seriesBrowseTarget?.field],
// `activeMetadataConfig` whole: the body reads `.provider` off it unguarded on
// the last line, so that object is the dependency the compiler infers.
[activeMetadataConfig, seriesBrowseCapability?.sort, seriesBrowseTarget?.field],
);
const handleManualSearch = useCallback(() => {
+20 -8
View File
@@ -34,6 +34,7 @@ import {
import { getNestedValue, toComparableText, toStringValue } from '../utils/objectHelpers';
import { toBookPlanPayload } from '../utils/packReview';
import { getReleaseFormats } from '../utils/releaseFormats';
import { INITIAL_ENTER_ANIMATION, nextEnterAnimation } from '../utils/releaseModalEnterAnimation';
import { buildReleaseDownloadPayload, type ReleaseDownloadOptions } from '../utils/releasePayload';
import {
getBookTitleCandidates,
@@ -889,6 +890,11 @@ const ReleaseModalSession = ({
} finally {
setIsRequestingBook(false);
}
// Kept against the advisory: the body really does read both. `handleClose` is aliased
// from the `onClose` prop, which is why the compiler names the source instead, and
// dropping `contentType` would let this close over a stale one and request the wrong
// format. Correctness first; the cost is an extra callback identity.
// oxlint-disable-next-line react/memo-dependencies
}, [book, onRequestBook, isRequestingBook, contentType, handleClose]);
const tabRefs = useRef<Record<string, HTMLButtonElement | null>>({});
@@ -1135,7 +1141,9 @@ const ReleaseModalSession = ({
const narratorField = book.display_fields.find((f) => f.icon === 'microphone');
return { starField, ratingsField, usersField, pagesField, lengthField, narratorField };
}, [book?.display_fields]);
// `book`, not `book?.display_fields`: the body reads `book.display_fields`
// unguarded after the early return, which is the dependency the compiler infers.
}, [book]);
const getReleaseActionMode = useCallback(
(release: Release): RequestPolicyMode => {
@@ -1286,6 +1294,9 @@ const ReleaseModalSession = ({
setPackSubmitting(false);
}
},
// Same as handleRequestBook above: the body reads `onDownload`, `contentType` and
// `handleClose`, so they stay in the list whatever the advisory infers.
// oxlint-disable-next-line react/memo-dependencies
[book, packReview, onDownload, contentType, handleClose],
);
@@ -2450,7 +2461,7 @@ const ReleaseModalSession = ({
export const ReleaseModal = ({ book, onClose, ...rest }: ReleaseModalProps) => {
const [isClosing, setIsClosing] = useState(false);
const previousSessionKeyRef = useRef<string | null>(null);
const [enterAnimation, setEnterAnimation] = useState(INITIAL_ENTER_ANIMATION);
const handleClose = useCallback(() => {
setIsClosing(true);
@@ -2474,12 +2485,13 @@ export const ReleaseModal = ({ book, onClose, ...rest }: ReleaseModalProps) => {
].join('|')
: null;
const animateEnter =
!rest.combinedMode ||
previousSessionKeyRef.current === null ||
previousSessionKeyRef.current === sessionKey;
previousSessionKeyRef.current = sessionKey;
// Decided once per session key and held for that session's lifetime, so a
// re-render mid-session cannot restart the enter animation.
const nextAnimation = nextEnterAnimation(enterAnimation, sessionKey, rest.combinedMode != null);
if (nextAnimation !== enterAnimation) {
setEnterAnimation(nextAnimation);
}
const animateEnter = nextAnimation.animate;
if (!book && !isClosing) return null;
if (!book || !sessionKey) return null;
+257 -296
View File
@@ -3,8 +3,8 @@ import { forwardRef, useImperativeHandle, useMemo, useRef, useState } from 'reac
import { useSearchMode } from '../contexts/SearchModeContext';
import { useSearchBarAutocomplete } from '../hooks/searchBar/useSearchBarAutocomplete';
import { useSearchBarHoverTimeout } from '../hooks/searchBar/useSearchBarHoverTimeout';
import { useDismiss } from '../hooks/useDismiss';
import { useLatestCallback } from '../hooks/useLatestCallback';
import type { DynamicFieldOption } from '../services/api';
import type { ContentType, MetadataSearchField, QueryTargetOption, SortOption } from '../types';
import { SearchBarAutocompleteSession } from './SearchBarAutocompleteSession';
@@ -51,6 +51,8 @@ const EMPTY_SORT_OPTIONS: SortOption[] = [];
const EMPTY_AUTOCOMPLETE_OPTIONS: DynamicFieldOption[] = [];
const EMPTY_QUERY_TARGETS: QueryTargetOption[] = [];
const SEARCH_CONTROLS_PANEL_ID = 'search-bar-controls-panel';
const BookIcon = () => (
<svg
className="h-5 w-5 shrink-0"
@@ -195,8 +197,9 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(
const { searchMode } = useSearchMode();
const inputRef = useRef<HTMLInputElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
const onSubmitRef = useRef(onSubmit);
onSubmitRef.current = onSubmit;
// Deferred submits below run from a timeout, not an Effect, so this is a latest-value
// callback rather than an Effect Event. See useLatestCallback.
const submitLatest = useLatestCallback(() => onSubmit());
const selectorRef = useRef<HTMLDivElement>(null);
const hasSearchQuery = hasActiveValue(value);
const [isSelectorOpen, setIsSelectorOpen] = useState(false);
@@ -205,7 +208,7 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(
const selectTriggerRef = useRef<HTMLButtonElement>(null);
const selectPanelRef = useRef<HTMLDivElement>(null);
const autocompletePanelRef = useRef<HTMLDivElement>(null);
const { hoverTimeoutRef: selectorHoverTimeout, clearHoverTimeout } = useSearchBarHoverTimeout();
const controlsPanelRef = useRef<HTMLDivElement>(null);
const hasMultipleContentTypes = !allowedContentTypes || allowedContentTypes.length !== 1;
const showContentTypeSelector =
@@ -221,7 +224,7 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(
() => queryTargets.find((target) => target.key === activeQueryTarget) ?? queryTargets[0],
[queryTargets, activeQueryTarget],
);
const showActiveTargetLabel = queryTargets.length > 0 && activeTarget.source !== 'general';
const showActiveTargetLabel = queryTargets.length > 0 && activeTarget?.source !== 'general';
// Manual search browses release sources directly, one media type at a time — the
// combined ("both") flow doesn't apply. Present a plain, switchable Books/Audiobooks
@@ -230,8 +233,11 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(
const combinedSelectionActive = combinedMode && !isManualTarget;
const combinedSelectorLocked = combinedModeLocked && !isManualTarget;
const combinedToggleAvailable = !!onCombinedModeChange && !isManualTarget;
const combinedLineColor = combinedSelectionActive
? 'bg-emerald-500'
: 'bg-(--border-muted) group-hover:bg-zinc-400 dark:group-hover:bg-zinc-500';
useDismiss(isSelectorOpen, [selectorRef], () => setIsSelectorOpen(false));
useDismiss(isSelectorOpen, [selectorRef, controlsPanelRef], () => setIsSelectorOpen(false));
useDismiss(isSelectOpen, [selectPanelRef, selectTriggerRef], () => setIsSelectOpen(false));
useDismiss(isAutocompleteOpen, [autocompletePanelRef, inputRef], () =>
setIsAutocompleteOpen(false),
@@ -333,18 +339,15 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(
const handleContentTypeSelect = (type: ContentType) => {
onContentTypeChange?.(type);
onCombinedModeChange?.(false);
setIsSelectorOpen(false);
};
const handleCombinedModeSelect = () => {
if (combinedMode) {
// Toggle off — revert to ebook-only
onCombinedModeChange?.(false);
} else {
onContentTypeChange?.('ebook');
onCombinedModeChange?.(true);
}
setIsSelectorOpen(false);
};
const handleQueryTargetSelect = (targetKey: string) => {
@@ -357,7 +360,6 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(
setIsSelectOpen(shouldOpenSelect);
setIsAutocompleteOpen(false);
resetAutocomplete();
setIsSelectorOpen(false);
};
const effectivePlaceholder = getDefaultPlaceholder(
@@ -412,7 +414,6 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(
setAutocompleteDraftValue(nextValue);
setIsAutocompleteOpen(nextValue.trim().length >= autocompleteMinQueryLength);
setIsSelectOpen(false);
setIsSelectorOpen(false);
onChange(nextValue);
return;
}
@@ -424,7 +425,6 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(
textInputValue.trim().length >= autocompleteMinQueryLength
) {
setIsAutocompleteOpen(true);
setIsSelectorOpen(false);
}
}}
onKeyDown={handleKeyDown}
@@ -490,7 +490,6 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(
onClick={() => {
if (!disabled && !isDynamicLoading) {
setIsSelectOpen((prev) => !prev);
setIsSelectorOpen(false);
setIsAutocompleteOpen(false);
}
}}
@@ -601,25 +600,7 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(
}}
>
{showQueryTargetSelector && (
<div
className="relative flex shrink-0 self-stretch"
ref={selectorRef}
onPointerEnter={(e) => {
if (e.pointerType !== 'mouse') return;
clearHoverTimeout();
setIsSelectorOpen(true);
setIsSelectOpen(false);
setIsAutocompleteOpen(false);
}}
onPointerLeave={(e) => {
if (e.pointerType !== 'mouse') return;
clearHoverTimeout();
selectorHoverTimeout.current = setTimeout(() => {
setIsSelectorOpen(false);
selectorHoverTimeout.current = null;
}, 150);
}}
>
<div className="relative flex shrink-0 self-stretch" ref={selectorRef}>
<button
type="button"
onClick={() => {
@@ -627,11 +608,11 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(
setIsSelectOpen(false);
setIsAutocompleteOpen(false);
}}
className="hover-action flex items-center gap-1.5 rounded-l-full pr-2 pl-5 transition-colors"
className="hover-action flex cursor-pointer items-center gap-1.5 rounded-l-full pr-2 pl-5 transition-colors"
style={{ color: 'var(--text)' }}
aria-label={`Searching ${selectorContentTypeLabel} by ${activeTarget?.label ?? 'general'}. Click to change.`}
aria-expanded={isSelectorOpen}
aria-haspopup="dialog"
aria-controls={SEARCH_CONTROLS_PANEL_ID}
>
{selectorIcon}
{showActiveTargetLabel && (
@@ -654,270 +635,10 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(
/>
</svg>
</button>
<div
className="absolute top-1/2 right-0 h-6 w-px -translate-y-1/2"
style={{ background: 'var(--border-muted)' }}
/>
{isSelectorOpen && (
<div
className="animate-fade-in-down absolute top-full left-0 z-50 mt-2 w-[min(20rem,calc(100vw-2rem))] overflow-hidden rounded-2xl border shadow-2xl"
style={{
background: 'var(--bg)',
borderColor: 'var(--border-muted)',
}}
role="dialog"
aria-label="Search context"
>
<div className="max-h-[min(24rem,calc(100vh-8rem))] overflow-y-auto p-3">
{showContentTypeSelector && (
<div
className={`border-b ${combinedToggleAvailable ? 'pb-0' : 'pb-3'}`}
style={{ borderColor: 'var(--border-muted)' }}
>
<div className="flex items-center justify-between px-1 pb-2">
<span className="text-xs font-medium tracking-wide uppercase opacity-60">
Content
</span>
{onAdvancedToggle && (
<button
type="button"
onClick={() => {
setIsSelectorOpen(false);
onAdvancedToggle();
}}
className={`-mt-1.5 -mr-1 -mb-0.5 flex items-center gap-1.5 rounded-xl px-4 py-2.5 text-xs font-medium transition-colors ${
isAdvancedActive ? 'bg-emerald-600 text-white' : 'hover-surface'
}`}
style={
isAdvancedActive
? { borderColor: 'rgb(16 185 129 / 0.7)' }
: { color: 'var(--text-muted)' }
}
>
<svg
className="h-3.5 w-3.5"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth="1.5"
stroke="currentColor"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M10.5 6h9.75M10.5 6a1.5 1.5 0 1 1-3 0m3 0a1.5 1.5 0 1 0-3 0M3.75 6H7.5m3 12h9.75m-9.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-3.75 0H7.5m9-6h3.75m-3.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-9.75 0h9.75"
/>
</svg>
Options
</button>
)}
</div>
<div className="grid grid-cols-2 gap-2">
<button
type="button"
onClick={() => handleContentTypeSelect('ebook')}
className={`flex items-center gap-2 rounded-xl border px-3 py-2 text-sm font-medium transition-colors ${
contentType === 'ebook' || combinedSelectionActive
? 'bg-emerald-600 text-white'
: 'hover-surface'
}`}
style={
contentType === 'ebook' || combinedSelectionActive
? { borderColor: 'rgb(16 185 129 / 0.7)' }
: { color: 'var(--text)', borderColor: 'var(--border-muted)' }
}
>
{contentType === 'ebook' || combinedSelectionActive ? (
<CheckIcon />
) : (
<BookIcon />
)}
<span>Books</span>
</button>
<button
type="button"
onClick={() => handleContentTypeSelect('audiobook')}
className={`flex items-center gap-2 rounded-xl border px-3 py-2 text-sm font-medium transition-colors ${
contentType === 'audiobook' || combinedSelectionActive
? 'bg-emerald-600 text-white'
: 'hover-surface'
}`}
style={
contentType === 'audiobook' || combinedSelectionActive
? { borderColor: 'rgb(16 185 129 / 0.7)' }
: { color: 'var(--text)', borderColor: 'var(--border-muted)' }
}
>
{contentType === 'audiobook' || combinedSelectionActive ? (
<CheckIcon />
) : (
<AudiobookIcon />
)}
<span>Audiobooks</span>
</button>
</div>
{combinedToggleAvailable &&
(() => {
const lineColor = combinedSelectionActive
? 'bg-emerald-500'
: 'bg-(--border-muted) group-hover:bg-zinc-400 dark:group-hover:bg-zinc-500';
return (
<Tooltip
content="Combined search"
position="bottom"
triggerClassName="w-full"
>
<button
type="button"
onClick={handleCombinedModeSelect}
className="group w-full"
aria-label="Combined search"
>
{/* Bracket connector: vertical drops + horizontal bar with icon */}
<div className="relative flex h-7 items-end">
{/* Left vertical */}
<div
className={`absolute top-1.5 bottom-[11px] left-[25%] w-px transition-colors ${lineColor}`}
/>
{/* Right vertical */}
<div
className={`absolute top-1.5 right-[25%] bottom-[11px] w-px transition-colors ${lineColor}`}
/>
{/* Horizontal bar – left segment */}
<div
className={`absolute bottom-[11px] left-[25%] h-px transition-colors ${lineColor}`}
style={{ width: 'calc(25% - 16px)' }}
/>
{/* Horizontal bar – right segment */}
<div
className={`absolute right-[25%] bottom-[11px] h-px transition-colors ${lineColor}`}
style={{ width: 'calc(25% - 16px)' }}
/>
{/* Chain icon centered at bottom */}
<div
className={`relative z-10 mx-auto rounded-full p-1 transition-colors ${
combinedSelectionActive
? 'bg-emerald-600 text-white'
: 'bg-(--bg) text-zinc-400 group-hover:bg-zinc-200 group-hover:text-zinc-600 dark:text-zinc-500 dark:group-hover:bg-zinc-700 dark:group-hover:text-zinc-300'
}`}
>
<svg
className="h-3.5 w-3.5"
fill="none"
viewBox="0 0 24 24"
strokeWidth="2"
stroke="currentColor"
aria-hidden="true"
>
{combinedSelectorLocked ? (
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M16.5 10.5V6.75a4.5 4.5 0 1 0-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 0 0 2.25-2.25v-6.75a2.25 2.25 0 0 0-2.25-2.25H6.75a2.25 2.25 0 0 0-2.25 2.25v6.75a2.25 2.25 0 0 0 2.25 2.25Z"
/>
) : (
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244"
/>
)}
</svg>
</div>
</div>
</button>
</Tooltip>
);
})()}
</div>
)}
<div className={showContentTypeSelector ? 'pt-2' : ''}>
<div className="flex items-center justify-between px-1 pb-1.5">
<span className="text-xs font-medium tracking-wide uppercase opacity-60">
Search By
</span>
{!showContentTypeSelector && onAdvancedToggle && (
<button
type="button"
onClick={() => {
setIsSelectorOpen(false);
onAdvancedToggle();
}}
className={`-mt-1.5 -mr-1 -mb-0.5 flex items-center gap-1.5 rounded-xl px-4 py-2.5 text-xs font-medium transition-colors ${
isAdvancedActive
? `${searchMode === 'direct' ? 'bg-sky-700' : 'bg-emerald-600'} text-white`
: 'hover-surface'
}`}
style={
isAdvancedActive
? {
borderColor:
searchMode === 'direct'
? 'rgb(3 105 161 / 0.7)'
: 'rgb(16 185 129 / 0.7)',
}
: { color: 'var(--text-muted)' }
}
>
<svg
className="h-3.5 w-3.5"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth="1.5"
stroke="currentColor"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M10.5 6h9.75M10.5 6a1.5 1.5 0 1 1-3 0m3 0a1.5 1.5 0 1 0-3 0M3.75 6H7.5m3 12h9.75m-9.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-3.75 0H7.5m9-6h3.75m-3.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-9.75 0h9.75"
/>
</svg>
Options
</button>
)}
</div>
<div className="grid grid-cols-2 gap-2">
{queryTargets.map((target) => {
const isActive = target.key === activeTarget?.key;
return (
<button
type="button"
key={target.key}
onClick={() => handleQueryTargetSelect(target.key)}
title={target.description || target.label}
aria-label={target.label}
className={`flex min-w-0 items-center gap-2 rounded-xl border px-3 py-2 text-sm font-medium transition-colors ${
isActive
? `${searchMode === 'direct' ? 'bg-sky-700' : 'bg-emerald-600'} text-white`
: 'hover-surface'
}`}
style={
isActive
? {
borderColor:
searchMode === 'direct'
? 'rgb(3 105 161 / 0.7)'
: 'rgb(16 185 129 / 0.7)',
}
: { color: 'var(--text)', borderColor: 'var(--border-muted)' }
}
>
{isActive && <CheckIcon />}
<span className="block truncate">{target.label}</span>
</button>
);
})}
</div>
</div>
</div>
</div>
)}
</div>
)}
@@ -1011,7 +732,7 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(
onClick={() => {
onChange(option.value, option.label);
setIsSelectOpen(false);
setTimeout(() => onSubmitRef.current(), 0);
setTimeout(() => submitLatest(), 0);
}}
className={`flex w-full items-center gap-3 px-5 py-2.5 text-left text-sm transition-colors ${
isSelected ? '' : 'hover-surface'
@@ -1081,7 +802,7 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(
setAutocompleteSelection(option.value, option.label);
onChange(option.value, option.label);
setIsAutocompleteOpen(false);
setTimeout(() => onSubmitRef.current(), 0);
setTimeout(() => submitLatest(), 0);
}}
className="hover-surface w-full px-5 py-3 text-left text-sm transition-colors"
style={{ color: 'var(--text)' }}
@@ -1099,6 +820,246 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(
</div>
)}
</div>
{showQueryTargetSelector && isSelectorOpen && (
<div
id={SEARCH_CONTROLS_PANEL_ID}
className="animate-fade-in-down flex flex-wrap items-start gap-x-8 gap-y-2 px-1 pt-2"
ref={controlsPanelRef}
>
{showContentTypeSelector && (
<div className="shrink-0">
<div className="flex items-center justify-between pb-1.5">
<span className="text-xs font-medium tracking-wide uppercase opacity-60">
Content
</span>
{onAdvancedToggle && (
<button
type="button"
onClick={onAdvancedToggle}
className={`-mt-1.5 -mr-1 -mb-0.5 flex cursor-pointer items-center gap-1.5 rounded-xl px-4 py-2.5 text-xs font-medium transition-colors ${
isAdvancedActive ? 'bg-emerald-600 text-white' : 'hover-surface'
}`}
style={
isAdvancedActive
? { borderColor: 'rgb(16 185 129 / 0.7)' }
: { color: 'var(--text-muted)' }
}
>
<svg
className="h-3.5 w-3.5"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth="1.5"
stroke="currentColor"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M10.5 6h9.75M10.5 6a1.5 1.5 0 1 1-3 0m3 0a1.5 1.5 0 1 0-3 0M3.75 6H7.5m3 12h9.75m-9.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-3.75 0H7.5m9-6h3.75m-3.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-9.75 0h9.75"
/>
</svg>
Options
</button>
)}
</div>
<div className="grid w-fit grid-cols-2 gap-2">
<button
type="button"
onClick={() => handleContentTypeSelect('ebook')}
className={`flex cursor-pointer items-center gap-2 rounded-xl border px-3 py-2 text-sm font-medium transition-colors ${
contentType === 'ebook' || combinedSelectionActive
? 'bg-emerald-600 text-white'
: 'hover-surface'
}`}
style={
contentType === 'ebook' || combinedSelectionActive
? { borderColor: 'rgb(16 185 129 / 0.7)' }
: { color: 'var(--text)', borderColor: 'var(--border-muted)' }
}
>
<span className="flex w-4 justify-center">
{contentType === 'ebook' || combinedSelectionActive ? (
<CheckIcon />
) : (
<BookIcon />
)}
</span>
<span>Books</span>
</button>
<button
type="button"
onClick={() => handleContentTypeSelect('audiobook')}
className={`flex cursor-pointer items-center gap-2 rounded-xl border px-3 py-2 text-sm font-medium transition-colors ${
contentType === 'audiobook' || combinedSelectionActive
? 'bg-emerald-600 text-white'
: 'hover-surface'
}`}
style={
contentType === 'audiobook' || combinedSelectionActive
? { borderColor: 'rgb(16 185 129 / 0.7)' }
: { color: 'var(--text)', borderColor: 'var(--border-muted)' }
}
>
<span className="flex w-4 justify-center">
{contentType === 'audiobook' || combinedSelectionActive ? (
<CheckIcon />
) : (
<AudiobookIcon />
)}
</span>
<span>Audiobooks</span>
</button>
{combinedToggleAvailable && (
<div className="col-span-2">
<Tooltip
content="Combined search"
position="bottom"
triggerClassName="w-full"
>
<button
type="button"
onClick={handleCombinedModeSelect}
className="group w-full cursor-pointer"
aria-label="Combined search"
>
<div className="relative flex h-7 items-end">
<div
className={`absolute top-1.5 bottom-[11px] left-[25%] w-px transition-colors ${combinedLineColor}`}
/>
<div
className={`absolute top-1.5 right-[25%] bottom-[11px] w-px transition-colors ${combinedLineColor}`}
/>
<div
className={`absolute bottom-[11px] left-[25%] h-px transition-colors ${combinedLineColor}`}
style={{ width: 'calc(25% - 16px)' }}
/>
<div
className={`absolute right-[25%] bottom-[11px] h-px transition-colors ${combinedLineColor}`}
style={{ width: 'calc(25% - 16px)' }}
/>
<div
className={`relative z-10 mx-auto rounded-full p-1 transition-colors ${
combinedSelectionActive
? 'bg-emerald-600 text-white'
: 'bg-(--bg) text-zinc-400 group-hover:bg-zinc-200 group-hover:text-zinc-600 dark:text-zinc-500 dark:group-hover:bg-zinc-700 dark:group-hover:text-zinc-300'
}`}
>
<svg
className="h-3.5 w-3.5"
fill="none"
viewBox="0 0 24 24"
strokeWidth="2"
stroke="currentColor"
aria-hidden="true"
>
{combinedSelectorLocked ? (
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M16.5 10.5V6.75a4.5 4.5 0 1 0-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 0 0 2.25-2.25v-6.75a2.25 2.25 0 0 0-2.25-2.25H6.75a2.25 2.25 0 0 0-2.25 2.25v6.75a2.25 2.25 0 0 0 2.25 2.25Z"
/>
) : (
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244"
/>
)}
</svg>
</div>
</div>
</button>
</Tooltip>
</div>
)}
</div>
</div>
)}
{queryTargets.length > 1 && (
<div className="shrink-0">
<div className="flex items-center justify-between pb-1.5">
<span className="text-xs font-medium tracking-wide uppercase opacity-60">
Search By
</span>
{!showContentTypeSelector && onAdvancedToggle && (
<button
type="button"
onClick={onAdvancedToggle}
className={`-mt-1.5 -mr-1 -mb-0.5 flex cursor-pointer items-center gap-1.5 rounded-xl px-4 py-2.5 text-xs font-medium transition-colors ${
isAdvancedActive
? `${searchMode === 'direct' ? 'bg-sky-700' : 'bg-emerald-600'} text-white`
: 'hover-surface'
}`}
style={
isAdvancedActive
? {
borderColor:
searchMode === 'direct'
? 'rgb(3 105 161 / 0.7)'
: 'rgb(16 185 129 / 0.7)',
}
: { color: 'var(--text-muted)' }
}
>
<svg
className="h-3.5 w-3.5"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth="1.5"
stroke="currentColor"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M10.5 6h9.75M10.5 6a1.5 1.5 0 1 1-3 0m3 0a1.5 1.5 0 1 0-3 0M3.75 6H7.5m3 12h9.75m-9.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-3.75 0H7.5m9-6h3.75m-3.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-9.75 0h9.75"
/>
</svg>
Options
</button>
)}
</div>
<div className="flex flex-wrap gap-2">
{queryTargets.map((target) => {
const isActive = target.key === activeTarget?.key;
return (
<button
type="button"
key={target.key}
onClick={() => handleQueryTargetSelect(target.key)}
title={target.description || target.label}
aria-label={target.label}
className={`flex min-w-0 cursor-pointer items-center gap-2 rounded-xl border px-3 py-2 text-sm font-medium transition-colors ${
isActive
? `${searchMode === 'direct' ? 'bg-sky-700' : 'bg-emerald-600'} text-white`
: 'hover-surface'
}`}
style={
isActive
? {
borderColor:
searchMode === 'direct'
? 'rgb(3 105 161 / 0.7)'
: 'rgb(16 185 129 / 0.7)',
}
: { color: 'var(--text)', borderColor: 'var(--border-muted)' }
}
>
{isActive && <CheckIcon />}
<span className="block truncate">{target.label}</span>
</button>
);
})}
</div>
</div>
)}
</div>
)}
</>
);
},
@@ -562,6 +562,10 @@ export const ActivityCard = ({
}
return () => observer.disconnect();
// None of these are read here - they are all re-measure triggers. The title's overflow
// depends on its text and on the width it is laid out in, and opening either panel
// reflows the card. Drop them and the tooltip-on-truncation goes stale.
// oxlint-disable-next-line react/exhaustive-effect-dependencies
}, [item.title, item.author, isRequestDetailsOpen, isRequestRejectOpen]);
const reviewRecord = item.requestRecord;
@@ -347,6 +347,10 @@ function SettingsContentPanel({
if (scrollRef.current) {
scrollRef.current.scrollTop = 0;
}
// `tab.name` is never read here - it is the trigger, and the whole point: the scroll
// position resets *because* the tab changed. Removing it strands the new tab at the
// previous one's offset.
// oxlint-disable-next-line react/exhaustive-effect-dependencies
}, [embedded, tab.name]);
const updateCustomFieldUiState = useCallback((fieldKey: string, key: string, value: unknown) => {
@@ -407,6 +411,9 @@ function SettingsContentPanel({
if (scrollRef.current) {
scrollRef.current.scrollTop = 0;
}
// `activeTakeOverFieldKey` is never read here - it is the trigger. Entering or leaving
// a subpage takeover is exactly when the scroll must reset.
// oxlint-disable-next-line react/exhaustive-effect-dependencies
}, [embedded, activeTakeOverFieldKey]);
const visibleFields = useMemo(() => {
@@ -1,5 +1,6 @@
import { useCallback, useLayoutEffect, useRef } from 'react';
import { useLatestCallback } from '../../../hooks/useLatestCallback';
import { useMountEffect } from '../../../hooks/useMountEffect';
import type { AdminUser } from '../../../services/api';
import { testAdminUserNotificationPreferences } from '../../../services/api';
@@ -169,12 +170,12 @@ export const UsersManagementField = ({
}
}, [backToList, onRefreshOverrideSummary, onSettingsSaved, onUiStateChange, saveEditedUser]);
const handleSaveUserOverridesRef = useRef(handleSaveUserOverrides);
handleSaveUserOverridesRef.current = handleSaveUserOverrides;
const triggerSaveUserOverrides = useCallback(async () => {
await handleSaveUserOverridesRef.current();
}, []);
// Stored in parent UI state, so it must keep a stable identity while still invoking the
// latest handler. Not an Effect Event: those must not be handed to another component.
// See useLatestCallback.
const triggerSaveUserOverrides = useLatestCallback(async () => {
await handleSaveUserOverrides();
});
const handleOpenOverrides = () => {
if (editingUser) {
@@ -44,162 +44,172 @@ const getOptionsIdentity = (options: MultiSelectFieldConfig['options']): string
const getSelectionIdentity = (values: string[]): string =>
values.toSorted((left, right) => left.localeCompare(right)).join('\u0001');
export const MultiSelectField = ({
interface MultiSelectVariantProps {
field: MultiSelectFieldConfig;
selected: string[];
onChange: (value: string[]) => void;
isDisabled: boolean;
}
// Dropdown variant - use DropdownList with checkboxes
const MultiSelectDropdownField = ({
field,
value: fieldValue,
selected,
onChange,
disabled,
}: MultiSelectFieldProps) => {
const selected = fieldValue ?? EMPTY_SELECTION;
// disabled prop is already computed by SettingsContent.getDisabledState()
const isDisabled = disabled ?? false;
isDisabled,
}: MultiSelectVariantProps) => {
const optionValues = field.options.map((opt) => opt.value);
const optionSet = new Set(optionValues);
const hasAllOption = optionSet.has(ALL_OPTION_VALUE);
const orderedOptions = hasAllOption
? [
...field.options.filter((opt) => opt.value === ALL_OPTION_VALUE),
...field.options.filter((opt) => opt.value !== ALL_OPTION_VALUE),
]
: field.options;
const nonAllValues = orderedOptions
.map((opt) => opt.value)
.filter((optValue) => optValue !== ALL_OPTION_VALUE);
// Dropdown variant - use DropdownList with checkboxes
if (field.variant === 'dropdown') {
const optionValues = field.options.map((opt) => opt.value);
const optionSet = new Set(optionValues);
const hasAllOption = optionSet.has(ALL_OPTION_VALUE);
const orderedOptions = hasAllOption
? [
...field.options.filter((opt) => opt.value === ALL_OPTION_VALUE),
...field.options.filter((opt) => opt.value !== ALL_OPTION_VALUE),
]
: field.options;
const nonAllValues = orderedOptions
.map((opt) => opt.value)
.filter((optValue) => optValue !== ALL_OPTION_VALUE);
const normalizeValues = (values: string[]): string[] => {
const deduped = new Set(
values
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0 && optionSet.has(entry)),
);
return orderedOptions.map((opt) => opt.value).filter((optValue) => deduped.has(optValue));
};
const normalizeValues = (values: string[]): string[] => {
const deduped = new Set(
values
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0 && optionSet.has(entry)),
);
return orderedOptions.map((opt) => opt.value).filter((optValue) => deduped.has(optValue));
};
const selectedExplicit = normalizeValues(selected);
const allSelected =
hasAllOption &&
(selectedExplicit.includes(ALL_OPTION_VALUE) ||
(nonAllValues.length > 0 &&
nonAllValues.every((optValue) => selectedExplicit.includes(optValue))));
const selectedExplicit = normalizeValues(selected);
const allSelected =
hasAllOption &&
(selectedExplicit.includes(ALL_OPTION_VALUE) ||
(nonAllValues.length > 0 &&
nonAllValues.every((optValue) => selectedExplicit.includes(optValue))));
// Build parent -> children map for cascading selection
const parentChildMap = new Map<string, string[]>();
orderedOptions.forEach((opt) => {
if (opt.childOf) {
const children = parentChildMap.get(opt.childOf) || [];
children.push(opt.value);
parentChildMap.set(opt.childOf, children);
}
});
// Build parent -> children map for cascading selection
const parentChildMap = new Map<string, string[]>();
orderedOptions.forEach((opt) => {
if (opt.childOf) {
const children = parentChildMap.get(opt.childOf) || [];
children.push(opt.value);
parentChildMap.set(opt.childOf, children);
// Check which children are implicitly selected via parent
const selectedForCascade = allSelected
? selectedExplicit.filter((optValue) => optValue !== ALL_OPTION_VALUE)
: selectedExplicit;
const implicitlySelected = new Set<string>();
selectedForCascade.forEach((val) => {
const children = parentChildMap.get(val);
if (children) {
children.forEach((child) => implicitlySelected.add(child));
}
});
// Build options with disabled state for implicitly selected children
const dropdownOptions = orderedOptions.map((opt) => ({
value: opt.value,
label: opt.label,
disabled: !allSelected && implicitlySelected.has(opt.value),
}));
// For display purposes:
// - if "all" is active, check every option
// - otherwise show explicit + implicit parent/child selections
const displayValue = allSelected
? [ALL_OPTION_VALUE, ...nonAllValues]
: normalizeValues([...selectedExplicit, ...Array.from(implicitlySelected)]);
const handleDropdownChange = (newValue: string | string[]) => {
const nextValues = normalizeValues(Array.isArray(newValue) ? newValue : [newValue]);
if (hasAllOption) {
const includesAll = nextValues.includes(ALL_OPTION_VALUE);
// When currently "all" is active:
// - unticking "all" clears everything
// - unticking a specific option converts to explicit subset
if (allSelected && !includesAll && nextValues.length === nonAllValues.length) {
onChange([]);
return;
}
});
// Check which children are implicitly selected via parent
const selectedForCascade = allSelected
? selectedExplicit.filter((optValue) => optValue !== ALL_OPTION_VALUE)
: selectedExplicit;
const implicitlySelected = new Set<string>();
selectedForCascade.forEach((val) => {
const children = parentChildMap.get(val);
if (children) {
children.forEach((child) => implicitlySelected.add(child));
}
});
// Build options with disabled state for implicitly selected children
const dropdownOptions = orderedOptions.map((opt) => ({
value: opt.value,
label: opt.label,
disabled: !allSelected && implicitlySelected.has(opt.value),
}));
// For display purposes:
// - if "all" is active, check every option
// - otherwise show explicit + implicit parent/child selections
const displayValue = allSelected
? [ALL_OPTION_VALUE, ...nonAllValues]
: normalizeValues([...selectedExplicit, ...Array.from(implicitlySelected)]);
const handleDropdownChange = (newValue: string | string[]) => {
const nextValues = normalizeValues(Array.isArray(newValue) ? newValue : [newValue]);
if (hasAllOption) {
const includesAll = nextValues.includes(ALL_OPTION_VALUE);
// When currently "all" is active:
// - unticking "all" clears everything
// - unticking a specific option converts to explicit subset
if (allSelected && !includesAll && nextValues.length === nonAllValues.length) {
onChange([]);
return;
}
if (allSelected && includesAll && nextValues.length < optionValues.length) {
onChange(nextValues.filter((entry) => entry !== ALL_OPTION_VALUE));
return;
}
if (includesAll) {
onChange([ALL_OPTION_VALUE]);
return;
}
// If user selects every specific option individually, collapse to "all".
if (
nonAllValues.length > 0 &&
nonAllValues.every((optValue) => nextValues.includes(optValue))
) {
onChange([ALL_OPTION_VALUE]);
return;
}
if (allSelected && includesAll && nextValues.length < optionValues.length) {
onChange(nextValues.filter((entry) => entry !== ALL_OPTION_VALUE));
return;
}
// Filter out implicitly selected values - only store explicit selections.
const explicitOnly = nextValues.filter((entry) => !implicitlySelected.has(entry));
onChange(explicitOnly);
};
if (includesAll) {
onChange([ALL_OPTION_VALUE]);
return;
}
// Custom summary formatter - only count explicit selections
const summaryFormatter = () => {
if (allSelected) {
return orderedOptions.find((opt) => opt.value === ALL_OPTION_VALUE)?.label || 'All';
// If user selects every specific option individually, collapse to "all".
if (
nonAllValues.length > 0 &&
nonAllValues.every((optValue) => nextValues.includes(optValue))
) {
onChange([ALL_OPTION_VALUE]);
return;
}
if (selectedExplicit.length === 0) {
return <span className="opacity-60">{field.placeholder || 'Select categories...'}</span>;
}
const selectedLabels = selectedExplicit
.map((v) => orderedOptions.find((o) => o.value === v)?.label)
.filter(Boolean);
if (selectedLabels.length === 1) {
return selectedLabels[0];
}
const [first, second, ...rest] = selectedLabels;
const suffix = rest.length > 0 ? ` +${rest.length}` : '';
return `${first}, ${second ?? ''}${suffix}`.trim();
};
if (isDisabled) {
return (
<div className="w-full cursor-not-allowed rounded-lg border border-(--border-muted) bg-(--bg-soft) px-3 py-2 text-sm opacity-60">
{summaryFormatter()}
</div>
);
}
// Filter out implicitly selected values - only store explicit selections.
const explicitOnly = nextValues.filter((entry) => !implicitlySelected.has(entry));
onChange(explicitOnly);
};
// Custom summary formatter - only count explicit selections
const summaryFormatter = () => {
if (allSelected) {
return orderedOptions.find((opt) => opt.value === ALL_OPTION_VALUE)?.label || 'All';
}
if (selectedExplicit.length === 0) {
return <span className="opacity-60">{field.placeholder || 'Select categories...'}</span>;
}
const selectedLabels = selectedExplicit
.map((v) => orderedOptions.find((o) => o.value === v)?.label)
.filter(Boolean);
if (selectedLabels.length === 1) {
return selectedLabels[0];
}
const [first, second, ...rest] = selectedLabels;
const suffix = rest.length > 0 ? ` +${rest.length}` : '';
return `${first}, ${second ?? ''}${suffix}`.trim();
};
if (isDisabled) {
return (
<DropdownList
options={dropdownOptions}
value={displayValue}
onChange={handleDropdownChange}
multiple
showCheckboxes
keepOpenOnSelect
placeholder={field.placeholder || 'Select categories...'}
widthClassName="w-full"
summaryFormatter={summaryFormatter}
/>
<div className="w-full cursor-not-allowed rounded-lg border border-(--border-muted) bg-(--bg-soft) px-3 py-2 text-sm opacity-60">
{summaryFormatter()}
</div>
);
}
return (
<DropdownList
options={dropdownOptions}
value={displayValue}
onChange={handleDropdownChange}
multiple
showCheckboxes
keepOpenOnSelect
placeholder={field.placeholder || 'Select categories...'}
widthClassName="w-full"
summaryFormatter={summaryFormatter}
/>
);
};
// Pill variant - inline toggle buttons that collapse past a threshold
const MultiSelectPillsField = ({
field,
selected,
onChange,
isDisabled,
}: MultiSelectVariantProps) => {
const [isExpanded, setIsExpanded] = useState(false);
// Initialize based on option count to avoid flash of expanded content
const [needsCollapse, setNeedsCollapse] = useState(
@@ -353,3 +363,35 @@ export const MultiSelectField = ({
</div>
);
};
export const MultiSelectField = ({
field,
value: fieldValue,
onChange,
disabled,
}: MultiSelectFieldProps) => {
const selected = fieldValue ?? EMPTY_SELECTION;
// disabled prop is already computed by SettingsContent.getDisabledState()
const isDisabled = disabled ?? false;
// Each variant is its own component so neither calls hooks conditionally.
if (field.variant === 'dropdown') {
return (
<MultiSelectDropdownField
field={field}
selected={selected}
onChange={onChange}
isDisabled={isDisabled}
/>
);
}
return (
<MultiSelectPillsField
field={field}
selected={selected}
onChange={onChange}
isDisabled={isDisabled}
/>
);
};
@@ -139,6 +139,10 @@ export function Tooltip({
}
if (deltaX !== 0 || deltaY !== 0) {
// Genuine measure-and-adjust: the tooltip must be laid out before we know
// whether it overflows the viewport. The loop converges in one pass because
// the corrected position yields deltaX/deltaY of 0 on the next run.
// oxlint-disable-next-line react/set-state-in-effect
setCoords((current) => {
if (!current) {
return current;
@@ -1,8 +1,8 @@
import { useRef } from 'react';
import { useEffectEvent } from 'react';
import type { Dispatch, SetStateAction } from 'react';
import type { Book } from '../../types';
import { onBookTargetChange } from '../../utils/bookTargetEvents';
import { onBookTargetChange, type BookTargetChangeEvent } from '../../utils/bookTargetEvents';
import { useMountEffect } from '../useMountEffect';
interface UseBookTargetDeselectSyncOptions {
@@ -14,15 +14,14 @@ export const useBookTargetDeselectSync = ({
activeListValue,
setBooks,
}: UseBookTargetDeselectSyncOptions): void => {
const activeListValueRef = useRef(activeListValue);
activeListValueRef.current = activeListValue;
useMountEffect(() => {
return onBookTargetChange((event) => {
if (event.selected) return;
const currentValue = activeListValueRef.current;
if (!currentValue || String(currentValue) !== event.target) return;
setBooks((prev) => prev.filter((book) => book.provider_id !== event.bookId));
});
const handleTargetChange = useEffectEvent((event: BookTargetChangeEvent) => {
if (event.selected) return;
if (!activeListValue || String(activeListValue) !== event.target) return;
setBooks((prev) => prev.filter((book) => book.provider_id !== event.bookId));
});
// Wrapped rather than handed over directly: an Effect Event must not be given to
// something that stores it, and `onBookTargetChange` puts its argument in a
// module-level listener set. Same shape as useDismiss.
useMountEffect(() => onBookTargetChange((event) => handleTargetChange(event)));
};
@@ -1,11 +1,17 @@
import { useCallback, useRef, useState } from 'react';
import { useCallback, useState } from 'react';
import type { Dispatch, SetStateAction } from 'react';
import type { ContentType } from '../../types';
import { useDependencyEffect } from '../useMountEffect';
const CONTENT_TYPE_STORAGE_KEY = 'preferred-content-type';
const readInitialPreference = (): { contentType: ContentType; combinedMode: boolean } => {
interface ContentTypePreference {
contentType: ContentType;
combinedMode: boolean;
}
const readInitialPreference = (): ContentTypePreference => {
try {
const saved = localStorage.getItem(CONTENT_TYPE_STORAGE_KEY);
if (saved === 'combined') {
@@ -26,53 +32,32 @@ export const useContentTypePreferences = (): {
combinedMode: boolean;
setCombinedMode: Dispatch<SetStateAction<boolean>>;
} => {
const initialPreference = readInitialPreference();
const [contentType, setContentTypeState] = useState<ContentType>(
() => initialPreference.contentType,
);
const [combinedMode, setCombinedModeState] = useState<boolean>(
() => initialPreference.combinedMode,
);
const contentTypeRef = useRef(contentType);
const combinedModeRef = useRef(combinedMode);
contentTypeRef.current = contentType;
combinedModeRef.current = combinedMode;
// Both values live in one state object so each setter can derive the other
// from a pure updater instead of mirroring it into a ref during render.
const [preference, setPreference] = useState<ContentTypePreference>(readInitialPreference);
const { contentType, combinedMode } = preference;
const persistPreference = useCallback(
(nextContentType: ContentType, nextCombinedMode: boolean) => {
try {
localStorage.setItem(
CONTENT_TYPE_STORAGE_KEY,
nextCombinedMode ? 'combined' : nextContentType,
);
} catch {
// localStorage may be unavailable in private browsing
}
},
[],
);
const setContentType: Dispatch<SetStateAction<ContentType>> = useCallback((value) => {
setPreference((current) => ({
...current,
contentType: typeof value === 'function' ? value(current.contentType) : value,
}));
}, []);
const setContentType: Dispatch<SetStateAction<ContentType>> = useCallback(
(value) => {
setContentTypeState((current) => {
const nextContentType = typeof value === 'function' ? value(current) : value;
persistPreference(nextContentType, combinedModeRef.current);
return nextContentType;
});
},
[persistPreference],
);
const setCombinedMode: Dispatch<SetStateAction<boolean>> = useCallback((value) => {
setPreference((current) => ({
...current,
combinedMode: typeof value === 'function' ? value(current.combinedMode) : value,
}));
}, []);
const setCombinedMode: Dispatch<SetStateAction<boolean>> = useCallback(
(value) => {
setCombinedModeState((current) => {
const nextCombinedMode = typeof value === 'function' ? value(current) : value;
persistPreference(contentTypeRef.current, nextCombinedMode);
return nextCombinedMode;
});
},
[persistPreference],
);
useDependencyEffect(() => {
try {
localStorage.setItem(CONTENT_TYPE_STORAGE_KEY, combinedMode ? 'combined' : contentType);
} catch {
// localStorage may be unavailable in private browsing
}
}, [contentType, combinedMode]);
return {
contentType,
@@ -44,6 +44,10 @@ export function useDescriptionOverflow({
return () => {
observer.disconnect();
};
// `descriptionKey` is never read here - it is the trigger. When the modal swaps to a
// different release the text changes under the same element, and the overflow has to
// be measured again; drop it and the clamp keeps the previous release's answer.
// oxlint-disable-next-line react/exhaustive-effect-dependencies
}, [descriptionExpanded, descriptionKey, descriptionRef]);
return descriptionOverflows;
@@ -164,7 +164,6 @@ export function useReleaseSearchSession(
const lastStatusTimeRef = useRef(0);
const pendingStatusRef = useRef<SearchStatusData | null>(null);
const statusTimeoutRef = useRef<NodeJS.Timeout | null>(null);
activeTabRef.current = activeTab;
const allTabs = useMemo(() => {
return buildReleaseTabs(
@@ -363,7 +362,6 @@ export function useReleaseSearchSession(
indexerFilterInitializedRef.current = new Set<string>();
const nextInitialActiveTab = preferredDefaultReleaseSource || '';
initialActiveTabRef.current = nextInitialActiveTab;
activeTabRef.current = nextInitialActiveTab;
pendingStatusRef.current = null;
lastStatusTimeRef.current = 0;
if (statusTimeoutRef.current) {
@@ -381,6 +379,7 @@ export function useReleaseSearchSession(
? nextInitialActiveTab
: (tabs[0]?.name ?? '');
activeTabRef.current = nextActiveTab;
setActiveTabState(nextActiveTab);
setReleasesBySource({});
setLoadingBySource({});
@@ -458,6 +457,7 @@ export function useReleaseSearchSession(
const setActiveTab = useCallback(
(tabName: string) => {
activeTabRef.current = tabName;
setActiveTabState(tabName);
if (!tabName) {
@@ -1,25 +0,0 @@
import { useCallback, useRef } from 'react';
import { useMountEffect } from '@/hooks/useMountEffect';
export const useSearchBarHoverTimeout = () => {
const hoverTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const clearHoverTimeout = useCallback(() => {
if (hoverTimeoutRef.current) {
clearTimeout(hoverTimeoutRef.current);
hoverTimeoutRef.current = null;
}
}, []);
useMountEffect(() => {
return () => {
clearHoverTimeout();
};
});
return {
hoverTimeoutRef,
clearHoverTimeout,
};
};
+20 -10
View File
@@ -5,24 +5,30 @@ interface TabIndicatorStyle {
width: number;
}
// One shared instance, so the no-active-tab path below can set it repeatedly and React
// bails out on reference equality instead of re-rendering on every resize event.
const HIDDEN_INDICATOR: TabIndicatorStyle = { left: 0, width: 0 };
export function useTabIndicator(
tabRefs: MutableRefObject<Record<string, HTMLButtonElement | null>>,
activeTab: string,
tabsDependency: unknown,
): TabIndicatorStyle {
const [tabIndicatorStyle, setTabIndicatorStyle] = useState({
left: 0,
width: 0,
});
const [tabIndicatorStyle, setTabIndicatorStyle] = useState<TabIndicatorStyle>(HIDDEN_INDICATOR);
useLayoutEffect(() => {
const activeButton = tabRefs.current[activeTab];
if (!activeButton) {
setTabIndicatorStyle({ left: 0, width: 0 });
return undefined;
}
// Single measurement path, so a resize that removes the active tab also
// resets the indicator instead of leaving it stranded.
const updateIndicator = () => {
const activeButton = tabRefs.current[activeTab];
if (!activeButton) {
// The shared constant, not a fresh literal: this path now runs on every resize
// event, and a new object would never be Object.is-equal to the current state,
// so React would re-render on every frame of a window drag for an unchanged value.
setTabIndicatorStyle(HIDDEN_INDICATOR);
return;
}
const containerRect = activeButton.parentElement?.getBoundingClientRect();
const buttonRect = activeButton.getBoundingClientRect();
if (!containerRect) {
@@ -41,6 +47,10 @@ export function useTabIndicator(
return () => {
window.removeEventListener('resize', updateIndicator);
};
// `tabsDependency` is never read here - it exists only to re-run the measurement when
// the tab set changes (callers pass `allTabs` / `showRequestsTab`). The buttons move
// when tabs are added or removed, so without it the indicator sits under the old one.
// oxlint-disable-next-line react/exhaustive-effect-dependencies
}, [activeTab, tabRefs, tabsDependency]);
return tabIndicatorStyle;
+20 -24
View File
@@ -1,47 +1,43 @@
import { useEffect, useEffectEvent, useRef, type RefObject } from 'react';
import { useEffect, useEffectEvent, type RefObject } from 'react';
export const useDismiss = (
isOpen: boolean,
refs: RefObject<HTMLElement | null>[],
onClose: () => void,
) => {
const handleClose = useEffectEvent(() => {
const handlePointerDown = useEffectEvent((event: MouseEvent) => {
const target = event.target;
if (!(target instanceof Node)) {
return;
}
if (refs.some((ref) => ref.current?.contains(target))) {
return;
}
onClose();
});
const refsRef = useRef(refs);
refsRef.current = refs;
const handleEscape = useEffectEvent((event: KeyboardEvent) => {
if (event.key === 'Escape') {
onClose();
}
});
useEffect(() => {
if (!isOpen) {
return undefined;
}
const handleClickOutside = (event: MouseEvent) => {
const target = event.target;
if (!(target instanceof Node)) {
return;
}
if (refsRef.current.some((ref) => ref.current?.contains(target))) {
return;
}
handleClose();
};
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
handleClose();
}
};
const handleClickOutside = (event: MouseEvent) => handlePointerDown(event);
const handleKeyDown = (event: KeyboardEvent) => handleEscape(event);
document.addEventListener('mousedown', handleClickOutside);
document.addEventListener('keydown', handleEscape);
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('keydown', handleEscape);
document.removeEventListener('keydown', handleKeyDown);
};
}, [isOpen]);
};
@@ -0,0 +1,31 @@
import { useCallback, useLayoutEffect, useRef } from 'react';
/**
* A callback with a stable identity that always runs the latest render's implementation.
*
* `useEffectEvent` is React's answer to this shape, but its contract is narrower than it
* looks: an Effect Event may only be called from inside an Effect, and must not be handed
* to another component, stored in state, or registered with something that outlives the
* Effect. Handlers that run from a DOM event, from an async continuation, or from a parent
* holding the function in its own UI state are all outside that contract - React documents
* the behaviour there as undefined, and the React Compiler advisories oxlint reports
* ("existing memoization could not be preserved") are the same fact from the other side.
*
* So this is the supported shape for those callers. The ref is published in a layout
* effect - after commit, before paint - rather than assigned during render, so a render
* React later throws away cannot leak its closure into a handler, and no event can
* observe the gap.
*
* Use `useEffectEvent` when the caller really is an Effect; use this everywhere else.
*/
export function useLatestCallback<Args extends unknown[], Result>(
callback: (...args: Args) => Result,
): (...args: Args) => Result {
const callbackRef = useRef(callback);
useLayoutEffect(() => {
callbackRef.current = callback;
});
return useCallback((...args: Args) => callbackRef.current(...args), []);
}
+5 -7
View File
@@ -1,16 +1,14 @@
import { useEffect, useRef, type DependencyList, type EffectCallback } from 'react';
import { useEffect, useEffectEvent, type DependencyList, type EffectCallback } from 'react';
export function useMountEffect(effect: EffectCallback): void {
const effectRef = useRef(effect);
effectRef.current = effect;
const runEffect = useEffectEvent(effect);
useEffect(() => effectRef.current(), []);
useEffect(() => runEffect(), []);
}
export function useDependencyEffect(effect: EffectCallback, deps: DependencyList): void {
const effectRef = useRef(effect);
effectRef.current = effect;
const runEffect = useEffectEvent(effect);
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => effectRef.current(), deps);
useEffect(() => runEffect(), deps);
}
+2 -6
View File
@@ -5,6 +5,7 @@ import { DEFAULT_SUPPORTED_FORMATS } from '../data/languages';
import { searchBooks, searchMetadata, AuthenticationError } from '../services/api';
import type { Book, AppConfig, AdvancedFilterState, ContentType, SearchMode } from '../types';
import { LANGUAGE_OPTION_DEFAULT } from '../utils/languageFilters';
import { describeSearchFailure } from '../utils/searchFailureMessage';
const DEFAULT_FORMAT_SELECTION = DEFAULT_SUPPORTED_FORMATS;
@@ -263,12 +264,7 @@ export function useSearch(options: UseSearchOptions): UseSearchReturn {
handleSearchError(error, 'Search failed');
} else {
console.error('Search failed:', error);
const message = error instanceof Error ? error.message : 'Search failed';
const friendly =
message.includes('Network restricted') || message.includes('Unable to reach')
? message
: 'Unable to reach download source. Network may be restricted or mirrors blocked.';
showToast(friendly, 'error');
showToast(describeSearchFailure(error), 'error');
}
} finally {
setIsSearching(false);
+6 -14
View File
@@ -1,4 +1,4 @@
import { useState, useCallback, useRef } from 'react';
import { useState, useCallback } from 'react';
import { getSettings, updateSettings, executeSettingsAction } from '../services/api';
import type {
@@ -23,6 +23,7 @@ import {
setThemePreference,
THEME_FIELD,
} from '../utils/themePreference';
import { useLatestCallback } from './useLatestCallback';
import { useMountEffect } from './useMountEffect';
interface FetchSettingsOptions {
@@ -137,13 +138,9 @@ export function useSettings(): UseSettingsReturn {
() => initialState?.originalValues ?? {},
);
const [isSaving, setIsSaving] = useState(false);
const valuesRef = useRef<SettingsValues>({});
const originalValuesRef = useRef<SettingsValues>({});
valuesRef.current = values;
originalValuesRef.current = originalValues;
const applySettingsResponse = useCallback(
// Stable identity, latest `values`/`originalValues`. Not an Effect Event: it is called
// from async fetch and save continuations, not from an Effect. See useLatestCallback.
const applySettingsResponse = useLatestCallback(
(response: SettingsResponse, options: { preserveDirtyValues?: boolean } = {}) => {
const { preserveDirtyValues = false } = options;
cachedSettingsResponse = response;
@@ -156,11 +153,7 @@ export function useSettings(): UseSettingsReturn {
setError(null);
const nextValues = preserveDirtyValues
? mergeFetchedSettingsWithDirtyValues(
hydratedState.values,
valuesRef.current,
originalValuesRef.current,
)
? mergeFetchedSettingsWithDirtyValues(hydratedState.values, values, originalValues)
: hydratedState.values;
setValues(nextValues);
@@ -170,7 +163,6 @@ export function useSettings(): UseSettingsReturn {
setSelectedTab((current) => current ?? hydratedState.selectedTab);
}
},
[],
);
const fetchSettings = useCallback(
+52 -9
View File
@@ -84,6 +84,9 @@ type ApiResponseErrorShape = Error & {
code?: string;
requiredMode?: string;
payload?: Record<string, unknown>;
// Set only when the server explained itself, so callers can tell a real explanation
// apart from the `503 SERVICE UNAVAILABLE` placeholder built from the status line.
serverMessage?: string;
};
class ApiResponseError extends Error {
@@ -91,6 +94,7 @@ class ApiResponseError extends Error {
code?: string;
requiredMode?: string;
payload?: Record<string, unknown>;
serverMessage?: string;
constructor(
message: string,
@@ -99,6 +103,7 @@ class ApiResponseError extends Error {
code?: string;
requiredMode?: string;
payload?: Record<string, unknown>;
serverMessage?: string;
},
) {
super(message);
@@ -107,6 +112,7 @@ class ApiResponseError extends Error {
this.code = params.code;
this.requiredMode = params.requiredMode;
this.payload = params.payload;
this.serverMessage = params.serverMessage;
}
}
@@ -114,6 +120,13 @@ export const isApiResponseError = (error: unknown): error is ApiResponseErrorSha
return error instanceof ApiResponseError;
};
// The client gave up before the server answered. Distinguishable so callers can report
// the wait rather than guessing at a cause: a search that hits this has told us nothing
// about the network or the mirrors, and saying it did is what issue #1285 was about.
export const isTimeoutError = (error: unknown): error is Error => {
return error instanceof TimeoutError;
};
const mapApiErrorToActionResult = (error: unknown): ActionResult | null => {
if (!isApiResponseError(error) || !error.payload) {
return null;
@@ -149,7 +162,31 @@ const DEFAULT_TIMEOUT_MS = 30000;
// Release searches can be long-running: a source behind Cloudflare/DDoS-Guard has
// to spin up the bypasser and solve the challenge before any results come back,
// which routinely takes well over the default timeout.
const SEARCH_TIMEOUT_MS = 180000;
//
// The server bounds them itself (RELEASE_SEARCH_TIMEOUT, reported by /api/config) and
// answers a spent budget with a message naming the real cause. This client abort is only
// the backstop for a server that never answers at all, so it has to fire *after* the
// server's own deadline - a fixed 180s here beat the 300s default, so the accurate
// message was never reachable and raising the setting did nothing. See issue #1285.
//
// The margin has to cover what the server still has to do *after* its budget trips, not
// just the budget itself. The deadline is cooperative: it is handed to the bypasser as a
// cancel flag, and internal_bypasser._CDP_UNWIND_GRACE_SECONDS allows 15s on its own for a
// cancelled solve to close its browser - before the handler has serialized releases, built
// the column config and put bytes on the wire. A 15s margin is entirely spent by that
// unwind, so give it room for the unwind plus the response.
const SEARCH_TIMEOUT_MARGIN_MS = 45000;
const FALLBACK_SEARCH_TIMEOUT_MS = 300000; // search_deadline.DEFAULT_SEARCH_BUDGET_SECONDS
let searchTimeoutMs = FALLBACK_SEARCH_TIMEOUT_MS + SEARCH_TIMEOUT_MARGIN_MS;
// Exported for tests; callers get this applied automatically via getConfig().
export const setSearchTimeoutFromConfig = (budgetSeconds: unknown): void => {
if (typeof budgetSeconds === 'number' && Number.isFinite(budgetSeconds) && budgetSeconds > 0) {
searchTimeoutMs = budgetSeconds * 1000 + SEARCH_TIMEOUT_MARGIN_MS;
}
};
export const getSearchTimeoutMs = (): number => searchTimeoutMs;
// Utility function for JSON fetch with credentials and timeout
async function fetchJSON<T>(
@@ -183,12 +220,15 @@ async function fetchJSON<T>(
if (isRecord(parsed) && !Array.isArray(parsed)) {
errorData = parsed;
}
// Prefer user-friendly 'message' field, fall back to 'error'
if (typeof errorData?.message === 'string') {
errorMessage = errorData.message;
hasServerMessage = true;
} else if (typeof errorData?.error === 'string') {
errorMessage = errorData.error;
// Prefer user-friendly 'message' field, fall back to 'error'. Both must carry
// actual text: an empty string is not the server explaining itself, and treating
// it as one suppresses the placeholder below and shows the user a blank toast.
const explanation = [errorData?.message, errorData?.error].find(
(candidate): candidate is string =>
typeof candidate === 'string' && candidate.trim() !== '',
);
if (explanation !== undefined) {
errorMessage = explanation;
hasServerMessage = true;
}
} catch (e) {
@@ -213,6 +253,7 @@ async function fetchJSON<T>(
throw new ApiResponseError(errorMessage, {
status: res.status,
serverMessage: hasServerMessage ? errorMessage : undefined,
code: typeof errorData?.code === 'string' ? errorData.code : undefined,
requiredMode:
typeof errorData?.required_mode === 'string' ? errorData.required_mode : undefined,
@@ -243,7 +284,7 @@ export const searchBooks = async (query: string): Promise<Book[]> => {
const response = await fetchJSON<ReleasesResponse>(
`${API_BASE}/releases?source=direct_download&${query}`,
{},
SEARCH_TIMEOUT_MS,
searchTimeoutMs,
);
return response.releases.map(transformReleaseToDirectBook);
};
@@ -586,7 +627,9 @@ export const retryDownload = async (id: string): Promise<void> => {
};
export const getConfig = async (): Promise<AppConfig> => {
return fetchJSON<AppConfig>(API.config);
const config = await fetchJSON<AppConfig>(API.config);
setSearchTimeoutFromConfig(config.release_search_timeout);
return config;
};
interface ActivityDismissedItem {
@@ -0,0 +1,62 @@
import { describe, it, expect } from 'vitest';
import {
INITIAL_ENTER_ANIMATION,
nextEnterAnimation,
type EnterAnimationState,
} from '../utils/releaseModalEnterAnimation';
describe('nextEnterAnimation', () => {
it('animates the first session', () => {
const next = nextEnterAnimation(INITIAL_ENTER_ANIMATION, 'book-1', false);
expect(next).toEqual({ key: 'book-1', animate: true });
});
it('animates the first session in combined mode too', () => {
const next = nextEnterAnimation(INITIAL_ENTER_ANIMATION, 'book-1', true);
expect(next).toEqual({ key: 'book-1', animate: true });
});
it('does not animate a step transition between combined-mode sessions', () => {
const current: EnterAnimationState = { key: 'book-1', animate: true };
expect(nextEnterAnimation(current, 'book-2', true)).toEqual({
key: 'book-2',
animate: false,
});
});
it('animates a session swap outside combined mode', () => {
const current: EnterAnimationState = { key: 'book-1', animate: true };
expect(nextEnterAnimation(current, 'book-2', false)).toEqual({
key: 'book-2',
animate: true,
});
});
it('holds the decision across re-renders of the same session', () => {
// Regression: the decision used to flip back to `true` on the next render,
// replaying the enter animation mid-session.
const stepped = nextEnterAnimation({ key: 'book-1', animate: true }, 'book-2', true);
expect(stepped.animate).toBe(false);
let state = stepped;
for (let i = 0; i < 5; i++) {
state = nextEnterAnimation(state, 'book-2', true);
expect(state.animate).toBe(false);
}
});
it('returns the same reference when the session is unchanged', () => {
const current: EnterAnimationState = { key: 'book-1', animate: false };
expect(nextEnterAnimation(current, 'book-1', true)).toBe(current);
});
it('animates again after the modal closes and reopens', () => {
const open = nextEnterAnimation(INITIAL_ENTER_ANIMATION, 'book-1', true);
const closed = nextEnterAnimation(open, null, true);
expect(closed.key).toBeNull();
const reopened = nextEnterAnimation(closed, 'book-1', true);
expect(reopened.animate).toBe(true);
});
});
@@ -0,0 +1,77 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { searchBooks } from '../services/api';
import {
describeSearchFailure,
CLIENT_TIMEOUT_MESSAGE,
UNREACHABLE_SOURCE_MESSAGE,
} from '../utils/searchFailureMessage';
/**
* What a failed direct-mode search tells the user.
*
* Every non-auth failure used to be relabelled "Unable to reach download source. Network
* may be restricted or mirrors blocked.", which threw away the server's explanation and
* blamed the user's network for a protection challenge. See issue #1285.
*/
const jsonResponse = (body: unknown, status: number): Response =>
new Response(JSON.stringify(body), {
status,
statusText: 'SERVICE UNAVAILABLE',
headers: { 'Content-Type': 'application/json' },
});
/** Drive a real searchBooks() failure so the error is the one the hook actually sees. */
const failedSearch = async (respond: () => Promise<Response>): Promise<unknown> => {
vi.stubGlobal('fetch', vi.fn(respond));
return searchBooks('q=dune').catch((error: unknown) => error);
};
describe('describeSearchFailure', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it('shows the sentence the server sent', async () => {
const sentence = 'The release search ran out of time (300s).';
const error = await failedSearch(() => Promise.resolve(jsonResponse({ error: sentence }, 503)));
expect(describeSearchFailure(error)).toBe(sentence);
});
it('names the wait when the client gave up first', async () => {
// The client's abort is the backstop for a server that never answered. It tells us
// nothing about mirrors or the network, and the old chain reported it as if it did.
const abort = Object.assign(new Error('The operation was aborted.'), { name: 'AbortError' });
const error = await failedSearch(() => Promise.reject(abort));
expect(describeSearchFailure(error)).toBe(CLIENT_TIMEOUT_MESSAGE);
expect(describeSearchFailure(error)).not.toBe(UNREACHABLE_SOURCE_MESSAGE);
expect(describeSearchFailure(error)).not.toContain('mirrors');
});
it('falls back to the mirrors line only when nothing explained itself', async () => {
const error = await failedSearch(() => Promise.resolve(jsonResponse({}, 503)));
expect(describeSearchFailure(error)).toBe(UNREACHABLE_SOURCE_MESSAGE);
});
it('keeps a reachability message that already says the right thing', () => {
const error = new Error('Unable to reach download source. Every mirror was quarantined.');
expect(describeSearchFailure(error)).toBe(error.message);
});
it('never produces an empty sentence from a blank server message', async () => {
// `{"message": ""}` is not the server explaining itself. Treating it as one used to
// reach showToast('') and render an empty error toast.
const error = await failedSearch(() => Promise.resolve(jsonResponse({ message: '' }, 503)));
expect(describeSearchFailure(error)).toBe(UNREACHABLE_SOURCE_MESSAGE);
});
it('handles a non-Error rejection without inventing detail', () => {
expect(describeSearchFailure('something odd')).toBe(UNREACHABLE_SOURCE_MESSAGE);
});
});
@@ -0,0 +1,197 @@
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
import {
getConfig,
getSearchTimeoutMs,
setSearchTimeoutFromConfig,
searchBooks,
isApiResponseError,
} from '../services/api';
/**
* The client's abort must fire *after* the server's own search deadline.
*
* `/api/releases` bounds itself with RELEASE_SEARCH_TIMEOUT and answers a spent budget
* with a message naming the real cause. The client aborted at a fixed 180s against a
* 300s default, so it always won the race and replaced that message with "Request timed
* out. Check your network connection or proxy configuration." Raising the setting had no
* visible effect either, the 180s being baked into the hashed bundle. See issue #1285.
*/
// Must stay ahead of what the server still has to do after its budget trips: the deadline
// is cooperative, and internal_bypasser._CDP_UNWIND_GRACE_SECONDS alone allows 15s for a
// cancelled solve to close its browser before the response is even built.
const MARGIN_MS = 45_000;
const SERVER_UNWIND_GRACE_MS = 15_000;
const jsonResponse = (body: unknown, status = 200): Response =>
new Response(JSON.stringify(body), {
status,
statusText: status === 200 ? 'OK' : 'SERVICE UNAVAILABLE',
headers: { 'Content-Type': 'application/json' },
});
const configBody = (releaseSearchTimeout: number): Record<string, unknown> => ({
release_search_timeout: releaseSearchTimeout,
});
describe('release search timeout', () => {
beforeEach(() => {
setSearchTimeoutFromConfig(300);
});
afterEach(() => {
vi.unstubAllGlobals();
setSearchTimeoutFromConfig(300);
});
it('defaults behind the server default rather than ahead of it', () => {
expect(getSearchTimeoutMs()).toBe(300 * 1000 + MARGIN_MS);
});
it('follows the budget the server reports', async () => {
vi.stubGlobal(
'fetch',
vi.fn(() => Promise.resolve(jsonResponse(configBody(900)))),
);
await getConfig();
expect(getSearchTimeoutMs()).toBe(900 * 1000 + MARGIN_MS);
});
it('still outlasts the server when the budget is lowered', async () => {
vi.stubGlobal(
'fetch',
vi.fn(() => Promise.resolve(jsonResponse(configBody(30)))),
);
await getConfig();
// Exact, not a lower bound: `> 30_000` is also satisfied by the 345_000 left over
// from the previous budget, so a setter that silently stopped applying the config
// would pass it.
expect(getSearchTimeoutMs()).toBe(30 * 1000 + MARGIN_MS);
});
it('leaves the server room to unwind a cancelled solve and answer', async () => {
vi.stubGlobal(
'fetch',
vi.fn(() => Promise.resolve(jsonResponse(configBody(300)))),
);
await getConfig();
// The failure this exists for is a budget spent mid-solve. The server then has to
// close a browser before it can serialize anything, so a margin merely equal to that
// unwind is entirely spent by it and the client aborts first all over again.
expect(getSearchTimeoutMs() - 300 * 1000).toBeGreaterThan(SERVER_UNWIND_GRACE_MS);
});
it('ignores a missing or nonsensical budget instead of disabling the backstop', () => {
const before = getSearchTimeoutMs();
setSearchTimeoutFromConfig(undefined);
setSearchTimeoutFromConfig(0);
setSearchTimeoutFromConfig(-1);
setSearchTimeoutFromConfig('600');
setSearchTimeoutFromConfig(Number.NaN);
expect(getSearchTimeoutMs()).toBe(before);
});
it('applies the derived timeout to the direct_download search', async () => {
vi.stubGlobal(
'fetch',
vi.fn(() => Promise.resolve(jsonResponse(configBody(600)))),
);
await getConfig();
const seen: Array<AbortSignal | undefined> = [];
vi.stubGlobal(
'fetch',
vi.fn((_url: string, init: RequestInit) => {
seen.push(init.signal ?? undefined);
return Promise.resolve(jsonResponse({ releases: [] }));
}),
);
await searchBooks('q=dune');
// The request carries an abort signal, and it is not yet aborted: the point is that
// the clock it runs on is the server's, not a constant.
expect(seen).toHaveLength(1);
expect(seen[0]?.aborted).toBe(false);
expect(getSearchTimeoutMs()).toBe(600 * 1000 + MARGIN_MS);
});
});
describe('server-provided failure messages', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it('carries the server sentence through instead of a status placeholder', async () => {
const sentence =
'The release search ran out of time (300s). Anna’s Archive is behind a ' +
'protection challenge the bypasser could not solve in that window.';
vi.stubGlobal(
'fetch',
vi.fn(() => Promise.resolve(jsonResponse({ error: sentence }, 503))),
);
const error = await searchBooks('q=dune').catch((e: unknown) => e);
expect(isApiResponseError(error)).toBe(true);
if (isApiResponseError(error)) {
expect(error.serverMessage).toBe(sentence);
}
});
it('leaves serverMessage unset when the server explained nothing', async () => {
vi.stubGlobal(
'fetch',
vi.fn(() => Promise.resolve(jsonResponse({}, 503))),
);
const error = await searchBooks('q=dune').catch((e: unknown) => e);
expect(isApiResponseError(error)).toBe(true);
if (isApiResponseError(error)) {
expect(error.serverMessage).toBeUndefined();
// Without this the UI would show a bare "503 SERVICE UNAVAILABLE".
expect(error.message).toContain('Server unavailable');
}
});
it('treats a blank message as no explanation at all', async () => {
// An empty string is not the server explaining itself. Taking it as one suppresses
// the placeholder below *and* survives a `??` fallback, leaving an empty toast.
vi.stubGlobal(
'fetch',
vi.fn(() => Promise.resolve(jsonResponse({ message: ' ' }, 503))),
);
const error = await searchBooks('q=dune').catch((e: unknown) => e);
expect(isApiResponseError(error)).toBe(true);
if (isApiResponseError(error)) {
expect(error.serverMessage).toBeUndefined();
expect(error.message).toContain('Server unavailable');
}
});
it('falls back to `error` when `message` is blank', async () => {
vi.stubGlobal(
'fetch',
vi.fn(() => Promise.resolve(jsonResponse({ message: '', error: 'the real reason' }, 503))),
);
const error = await searchBooks('q=dune').catch((e: unknown) => e);
expect(isApiResponseError(error)).toBe(true);
if (isApiResponseError(error)) {
expect(error.serverMessage).toBe('the real reason');
}
});
});
+1
View File
@@ -285,6 +285,7 @@ export interface AppConfig {
auto_open_downloads_sidebar: boolean; // Auto-open sidebar when download is queued
hardcover_auto_remove_on_download: boolean; // Auto-remove from active Hardcover list on download
download_to_browser_content_types: string[]; // Auto-download completed files to browser for selected content types
release_search_timeout: number; // Server-side budget for one release search, in seconds
settings_enabled: boolean; // Whether config directory is mounted and writable
onboarding_complete: boolean; // Whether the user has completed initial setup
default_sort: string; // Default sort for direct mode
+1 -1
View File
@@ -1,4 +1,4 @@
type BookTargetChangeEvent = {
export type BookTargetChangeEvent = {
provider: string;
bookId: string;
target: string;
@@ -0,0 +1,29 @@
export interface EnterAnimationState {
key: string | null;
animate: boolean;
}
export const INITIAL_ENTER_ANIMATION: EnterAnimationState = { key: null, animate: true };
/**
* Decide whether a release modal session should play its enter animation.
*
* The decision is made once, when a session key first appears, and then held for
* that session's lifetime — re-rendering mid-session must not restart the
* animation. Swapping between sessions in combined mode is a step transition
* rather than an entrance, so it does not animate.
*/
export const nextEnterAnimation = (
current: EnterAnimationState,
sessionKey: string | null,
isCombinedMode: boolean,
): EnterAnimationState => {
if (current.key === sessionKey) {
return current;
}
return {
key: sessionKey,
animate: !isCombinedMode || current.key === null,
};
};
@@ -0,0 +1,39 @@
import { isApiResponseError, isTimeoutError } from '../services/api';
// Shown when we genuinely have nothing better: the request failed without the server
// saying why, which really can mean blocked mirrors or a restricted network.
export const UNREACHABLE_SOURCE_MESSAGE =
'Unable to reach download source. Network may be restricted or mirrors blocked.';
// Shown when the client's own abort fired. It is the backstop for a server that never
// answered at all, and it says nothing about mirrors - the budget the client waited out
// is the server's own, which the user can raise. See issue #1285.
export const CLIENT_TIMEOUT_MESSAGE =
'The search took longer than the server said it would. Raise the release search ' +
'timeout if your setup is simply slow.';
/**
* The sentence to show for a failed direct-mode search.
*
* The server knows why a search failed - a spent search budget, an unsolved protection
* challenge - and blanket-replacing that with the mirrors line told users their network
* was broken when it was not. So: the server's own words when it explained itself, the
* timeout line when we gave up before it answered, and the mirrors line only when
* neither applies. See issue #1285.
*/
export const describeSearchFailure = (error: unknown): string => {
if (isTimeoutError(error)) {
return CLIENT_TIMEOUT_MESSAGE;
}
if (isApiResponseError(error) && error.serverMessage) {
return error.serverMessage;
}
const message = error instanceof Error ? error.message : '';
if (message.includes('Network restricted') || message.includes('Unable to reach')) {
return message;
}
return UNREACHABLE_SOURCE_MESSAGE;
};
+47
View File
@@ -266,3 +266,50 @@ def test_page_load_loop_still_makes_one_attempt_on_a_spent_budget(monkeypatch, b
assert bypass._run_bypass_in_current_process("https://example.com", 10) == "<html>solved</html>"
assert attempts["n"] == 1
class _FakeElement:
async def get_html_async(self) -> str:
return "<html>solved</html>"
class _FakePage:
"""A page that only produces its document after `ready_after` seconds of waiting."""
def __init__(self, ready_after: float = 0.0) -> None:
self.ready_after = ready_after
self.waited_with: list[float] = []
async def find(self, selector: str, timeout: float = 1):
self.waited_with.append(timeout)
if timeout < self.ready_after:
msg = f"Time ran out while waiting for: {{{selector}}}"
raise TimeoutError(msg)
return _FakeElement()
def test_page_source_waits_longer_than_seleniumbases_one_second(bypass):
"""A page still navigating after a solve must not lose the solve.
SeleniumBase's get_page_source() allows one second for the document. Anna's Archive
hands back a redirect to the real content instead, so the read raised TimeoutError
while the challenge had in fact been cleared.
"""
page = _FakePage(ready_after=5.0)
assert asyncio.run(bypass._read_page_source(page)) == "<html>solved</html>"
assert page.waited_with == [bypass._PAGE_SOURCE_TIMEOUT_DEFAULT]
def test_page_source_timeout_is_configurable(bypass, monkeypatch):
"""BYPASS_PAGE_SOURCE_TIMEOUT overrides the default for slow or fast setups."""
monkeypatch.setattr(
bypass.app_config,
"get",
lambda key, default=None: 45 if key == "BYPASS_PAGE_SOURCE_TIMEOUT" else default,
)
page = _FakePage()
asyncio.run(bypass._read_page_source(page))
assert page.waited_with == [45.0]
+88
View File
@@ -0,0 +1,88 @@
"""No method in the list may be a step another method already takes first.
`BYPASS_METHODS` used to open with a solve-only entry that called `page.solve_captcha()`
and checked the result. `_bypass_method_cdp_gui_click`, the entry behind it, opens by
doing exactly that and returns the moment it works - so against a challenge that
`solve_captcha()` cannot clear, the first method could only repeat the half that had
already failed, then charge the loop's backoff before the method that does work started.
Measured on Anna's Archive at 0/19 successes and ~5.5s of the ~26s each solve cost
(issue #1285).
The passive-solve window added in v1.3.13 keeps most solves away from this loop entirely,
so this is about what the loop costs when it does run.
"""
import asyncio
import pytest
import shelfmark.bypass.internal_bypasser as ib
@pytest.fixture
def no_sleep(monkeypatch):
async def _no_sleep(_seconds) -> None:
return None
monkeypatch.setattr(ib.asyncio, "sleep", _no_sleep)
monkeypatch.setattr(ib._RNG, "uniform", lambda _a, _b: 0)
class _Page:
"""Records what a method asked the page to do."""
def __init__(self, *, solve_clears: bool) -> None:
self.solve_clears = solve_clears
self.calls: list[str] = []
async def solve_captcha(self) -> None:
self.calls.append("solve_captcha")
async def is_element_visible(self, selector: str) -> bool:
self.calls.append(f"visible:{selector}")
return False
async def click_with_offset(self, selector: str, _x, _y, center=True) -> None:
self.calls.append(f"click:{selector}")
def _stub_is_bypassed(monkeypatch, page: _Page) -> None:
async def _is_bypassed(*_a, **_kw) -> bool:
return page.solve_clears and "solve_captcha" in page.calls
monkeypatch.setattr(ib, "_is_bypassed", _is_bypassed)
def test_no_solve_only_method_remains_in_the_list():
names = [method.__name__ for method in ib.BYPASS_METHODS]
assert "_bypass_method_cdp_solve" not in names
assert names[0] == "_bypass_method_cdp_gui_click"
def test_the_first_method_still_tries_solve_captcha_first(monkeypatch, no_sleep):
"""Coverage is only preserved because gui_click opens with the same call."""
page = _Page(solve_clears=True)
_stub_is_bypassed(monkeypatch, page)
assert asyncio.run(ib.BYPASS_METHODS[0](page)) is True
assert page.calls == ["solve_captcha"], "it must return before touching any selector"
def test_it_falls_through_to_clicking_when_solve_does_not_clear(monkeypatch, no_sleep):
"""The half that actually works on DDoS-Guard still runs in the same attempt."""
page = _Page(solve_clears=False)
_stub_is_bypassed(monkeypatch, page)
assert asyncio.run(ib.BYPASS_METHODS[0](page)) is False
assert page.calls[0] == "solve_captcha"
assert any(call.startswith("visible:") for call in page.calls), (
"the selector pass should have been reached in the same attempt"
)
def test_the_derived_budgets_follow_the_shortened_list():
"""Both budgets are computed from the list, so removing an entry must not strand them."""
assert ib._BYPASS_METHOD_ATTEMPTS == len(ib.BYPASS_METHODS) + 1
assert ib._BYPASS_METHOD_ATTEMPTS >= len(ib.BYPASS_METHODS), (
"every method must still get a turn"
)
+214
View File
@@ -0,0 +1,214 @@
"""A challenge page is a failed solve, whatever verdict the solver reports on itself.
Regression for #1292. FlareSolverr answers "Challenge solved!" for anything it does not
recognise as a Cloudflare challenge, and DDoS-Guard's manual CAPTCHA page is one such
thing. The external bypasser logged a warning that the solve had not cleared the
protection and then returned the page as a success anyway, which had three consequences:
the retry-and-rotate loop that could still have reached a working mirror was never
entered, the CAPTCHA page's own __ddg cookies were filed as that host's clearance, and
the user was told to go and check a bypasser that was working perfectly.
"""
import pytest
from shelfmark.bypass import ChallengeNotSolvedError
# Verbatim from the annas-archive.pk page in #1292, trimmed to the markers. This is the
# *manual* CAPTCHA - "could not verify your browser automatically" - not the ~900 byte
# JS interstitial that a browser clears on its own.
DDOS_GUARD_CAPTCHA = (
'<html><head><title>DDOS-GUARD</title><meta charset="utf-8">'
'<link rel="stylesheet" href="/.well-known/ddos-guard/ddg-captcha-page/index.css">'
'<script defer="defer" src="/.well-known/ddos-guard/ddg-captcha-page/index.js"></script>'
'</head><body><div class="container"><h1 id="title">Checking your browser before '
'accessing annas-archive.pk</h1><p id="description">Sorry, we could not verify your '
"browser automatically. Complete the manual check to continue</p>"
'<div id="ddg-captcha"></div></div></body></html>'
)
class _FakeResponse:
def __init__(self, payload: dict) -> None:
self._payload = payload
def raise_for_status(self) -> None:
return None
def json(self) -> dict:
return self._payload
def _stub_solution(monkeypatch, external_bypasser, solution: dict) -> None:
"""Answer every bypass with `solution`, with config and SSL stubbed out."""
def fake_get(key, default=""):
values = {
"EXT_BYPASSER_URL": "https://bypass.example",
"EXT_BYPASSER_PATH": "/v1",
"EXT_BYPASSER_TIMEOUT": 60000,
}
return values.get(key, default)
monkeypatch.setattr(external_bypasser.config, "get", fake_get)
monkeypatch.setattr(
external_bypasser.requests,
"post",
# "Challenge solved!" is the solver's verdict; the page is the evidence.
lambda *_a, **_k: _FakeResponse(
{"status": "ok", "message": "Challenge solved!", "solution": solution}
),
)
monkeypatch.setattr(external_bypasser, "get_ssl_verify", lambda _url: False)
def test_a_captcha_page_is_reported_as_unsolved_not_returned(monkeypatch):
import shelfmark.bypass.external_bypasser as external_bypasser
_stub_solution(monkeypatch, external_bypasser, {"response": DDOS_GUARD_CAPTCHA})
with pytest.raises(ChallengeNotSolvedError) as excinfo:
external_bypasser._fetch_via_bypasser("https://annas-archive.pk/search?q=dune")
# The marker travels with the failure so the user-facing message can name it.
assert str(excinfo.value) == "/.well-known/ddos-guard/"
def test_cookies_from_a_captcha_page_are_never_filed_as_clearance(monkeypatch):
"""They belong to an unsolved check, so replaying them only re-arms the gate."""
import shelfmark.bypass.cookie_store as cookie_store
import shelfmark.bypass.external_bypasser as external_bypasser
monkeypatch.setattr(cookie_store, "_cf_cookies", {})
monkeypatch.setattr(cookie_store, "_cf_user_agents", {})
_stub_solution(
monkeypatch,
external_bypasser,
{
"response": DDOS_GUARD_CAPTCHA,
"userAgent": "Mozilla/5.0 (solver)",
"cookies": [{"name": "__ddg1_", "value": "from-a-captcha"}],
},
)
with pytest.raises(ChallengeNotSolvedError):
external_bypasser._fetch_via_bypasser("https://annas-archive.pk/search?q=dune")
assert cookie_store.get_cf_cookies_for_domain("annas-archive.pk") == {}
assert cookie_store.get_cf_user_agent_for_domain("annas-archive.pk") is None
class _FakeSelector:
"""Two mirrors, rotated on demand - each is its own DDoS-Guard host."""
def __init__(self) -> None:
self.current_base = "https://mirror-one.example"
self.rotate_calls = 0
def rewrite(self, url: str) -> str:
return url.replace("https://orig.example", self.current_base, 1)
def next_mirror_or_rotate_dns(self) -> tuple[str | None, str]:
self.rotate_calls += 1
self.current_base = "https://mirror-two.example"
return self.current_base, "mirror"
def _no_sleeping(monkeypatch, external_bypasser) -> None:
monkeypatch.setattr(external_bypasser, "_sleep_with_cancellation", lambda _seconds, _flag: None)
def test_an_unsolved_challenge_rotates_to_the_next_mirror(monkeypatch):
"""The recovery the old code skipped by calling the CAPTCHA page a success."""
import shelfmark.bypass.external_bypasser as external_bypasser
_no_sleeping(monkeypatch, external_bypasser)
fetched: list[str] = []
def fake_fetch(url: str) -> str | None:
fetched.append(url)
if "mirror-one" in url:
raise ChallengeNotSolvedError("/.well-known/ddos-guard/")
return "<html>real page</html>"
monkeypatch.setattr(external_bypasser, "_fetch_via_bypasser", fake_fetch)
selector = _FakeSelector()
result = external_bypasser.get_bypassed_page("https://orig.example/search", selector=selector)
assert result == "<html>real page</html>"
assert fetched == [
"https://mirror-one.example/search",
"https://mirror-two.example/search",
]
assert selector.rotate_calls == 1
def test_every_attempt_challenged_blames_the_host_not_the_bypasser(monkeypatch):
import shelfmark.bypass.external_bypasser as external_bypasser
_no_sleeping(monkeypatch, external_bypasser)
def always_challenged(_url: str) -> str | None:
raise ChallengeNotSolvedError("/.well-known/ddos-guard/")
monkeypatch.setattr(external_bypasser, "_fetch_via_bypasser", always_challenged)
with pytest.raises(ChallengeNotSolvedError) as excinfo:
external_bypasser.get_bypassed_page("https://orig.example/search", selector=_FakeSelector())
message = str(excinfo.value)
assert "manual CAPTCHA" in message
assert "the bypasser itself is working" in message
def test_an_unreachable_bypasser_still_reports_as_such(monkeypatch):
"""The other cause must stay distinguishable: None, not an unsolved challenge."""
import shelfmark.bypass.external_bypasser as external_bypasser
_no_sleeping(monkeypatch, external_bypasser)
monkeypatch.setattr(external_bypasser, "_fetch_via_bypasser", lambda _url: None)
assert (
external_bypasser.get_bypassed_page("https://orig.example/search", selector=_FakeSelector())
is None
)
def test_html_get_page_surfaces_the_host_as_the_cause(monkeypatch):
"""The message the user actually reads must not send them to fix FlareSolverr.
`_run_bypasser`'s generic handler says "the protection bypasser failed", and the
search layer's give-up used to add "check that the bypasser is reachable and
working" - which is what #1292 spent its investigation doing.
"""
import shelfmark.download.http as http
import shelfmark.download.network as network
monkeypatch.setattr(http, "_is_cf_bypass_enabled", lambda: True)
monkeypatch.setattr(http.network, "should_rotate_dns_for_url", lambda _url: True)
def challenged(*_args, **_kwargs):
msg = "the site kept answering with a protection challenge - manual CAPTCHA"
raise ChallengeNotSolvedError(msg)
monkeypatch.setattr(http, "get_bypassed_page", challenged)
statuses: list[tuple[str, str | None]] = []
selector = network.AAMirrorSelector()
html = http.html_get_page(
"https://annas-archive.pk/search?q=dune",
retry=1,
selector=selector,
status_callback=lambda stage, detail: statuses.append((stage, detail)),
use_bypasser=True,
success_delay=0,
)
assert html == ""
assert selector.last_failure is not None
assert "manual CAPTCHA" in selector.last_failure
assert "reachable" not in selector.last_failure
assert ("error", "the site kept answering with a protection challenge - manual CAPTCHA") in (
statuses
)
+41
View File
@@ -137,3 +137,44 @@ def test_no_budget_leaks_out_of_the_request(client, main_module):
_request(client, main_module, [{"name": "direct_download", "enabled": True}], lambda *_: [])
assert search_deadline.current() is None
def test_the_client_is_told_what_the_budget_is(client, main_module):
"""The browser has to outlast the server, or the message above never arrives.
The frontend puts its own AbortController on the direct_download search. That abort
was a fixed 180s while this budget defaults to 300s, so the client always gave up
first and replaced the sentence tested above with a generic network/proxy error -
and raising RELEASE_SEARCH_TIMEOUT changed nothing a user could see, because the
hard-coded 180s was in the hashed bundle inside the image. Reporting the budget lets
the client set its backstop behind it. See issue #1285.
"""
_authenticate(client)
with patch.object(main_module, "get_auth_mode", return_value="none"):
resp = client.get("/api/config")
assert resp.status_code == 200
reported = resp.get_json()["release_search_timeout"]
assert reported == search_deadline.budget_seconds()
assert reported > 0
def test_the_reported_budget_is_the_one_actually_enforced(client, main_module):
"""An out-of-range setting is clamped, so the raw config value would mislead."""
_authenticate(client)
with (
patch.object(main_module, "get_auth_mode", return_value="none"),
patch.object(
main_module.app_config,
"get",
side_effect=lambda key, default=None, **_kw: (
99999 if key == "RELEASE_SEARCH_TIMEOUT" else default
),
),
):
resp = client.get("/api/config")
reported = resp.get_json()["release_search_timeout"]
assert reported == search_deadline._MAX_SEARCH_BUDGET_SECONDS
+140
View File
@@ -0,0 +1,140 @@
"""A release search uses one author, not every contributor the book lists.
`bookTransformers.ts` joins a book's authors with ", " for display, and the release modal
sends that display string back as the `author` request parameter; several providers set
`search_author` from equally joined text. `pick_search_author` returned it verbatim while
the authors[] fallback beside it deliberately narrowed to the first name, so a book with
translators was searched for as
Blindness Jose Saramago, Giovanni Pontiero, <persian translator>
which matches nothing on Anna's Archive. The bypass succeeds, the search returns empty,
and the user is told the book has no releases. Reported on issue #1252.
"""
from shelfmark.core.search_plan import build_release_search_plan, first_author, pick_search_author
from shelfmark.metadata_providers import BookMetadata
# The exact string from the report, as the frontend would join it.
JOINED = "José Saramago, Giovanni Pontiero, زهره افتخاری"
def _book(**kwargs) -> BookMetadata:
base = {
"provider": "manual",
"provider_id": "x",
"title": "Blindness",
"search_title": "Blindness",
}
return BookMetadata(**{**base, **kwargs})
def _queries(book: BookMetadata) -> list[str]:
plan = build_release_search_plan(book, languages=["en"])
return [f"{v.title} {plan.author}".strip() for v in plan.grouped_title_variants]
def test_search_author_is_narrowed_to_the_first_author():
book = _book(search_author=JOINED, authors=[a.strip() for a in JOINED.split(",")])
assert _queries(book) == ["Blindness José Saramago"]
def test_the_authors_fallback_still_narrows():
"""Unchanged behaviour, kept under test so the two paths cannot drift apart again."""
book = _book(authors=[JOINED])
assert _queries(book) == ["Blindness José Saramago"]
def test_a_single_author_is_left_alone():
book = _book(search_author="José Saramago")
assert _queries(book) == ["Blindness José Saramago"]
def test_a_book_with_no_author_still_searches_by_title():
book = _book(authors=[])
assert _queries(book) == ["Blindness"]
def test_last_first_collapses_to_the_surname():
"""Still a usable search term, and what the fallback has always done."""
assert first_author("Saramago, José") == "Saramago"
def test_first_author_trims_and_tolerates_odd_input():
assert first_author(" José Saramago ") == "José Saramago"
assert first_author("") == ""
assert first_author(",") == ""
assert first_author("José Saramago,") == "José Saramago"
def test_manual_query_is_untouched():
"""A manual query is the user's own words; narrowing it would rewrite their search."""
book = _book(search_author=JOINED)
plan = build_release_search_plan(book, manual_query=JOINED)
assert plan.manual_query == JOINED
assert plan.author == ""
def test_irc_query_uses_one_author_too():
"""The IRC source builds its own query and had the same verbatim preference."""
from shelfmark.release_sources.irc.source import IRCReleaseSource
book = _book(search_author=JOINED, authors=[a.strip() for a in JOINED.split(",")])
assert IRCReleaseSource()._build_query(book) == "Blindness José Saramago"
def test_a_blank_leading_contributor_falls_back_instead_of_dropping_the_author():
"""`authors.join(', ')` does not drop an empty entry, so the join can start with ",".
Narrowing that to "" and stopping would search by title alone - losing an author the
book was holding all along, in authors[], one line below.
"""
book = _book(search_author=", Giovanni Pontiero", authors=["", "Giovanni Pontiero"])
assert _queries(book) == ["Blindness Giovanni Pontiero"]
def test_a_blank_leading_contributor_does_not_strand_the_irc_query_either():
from shelfmark.release_sources.irc.source import IRCReleaseSource
book = _book(search_author=", Giovanni Pontiero", authors=["", "Giovanni Pontiero"])
assert IRCReleaseSource()._build_query(book) == "Blindness Giovanni Pontiero"
def test_an_all_blank_author_leaves_a_clean_title_only_query():
"""No trailing space, no empty part: the query is just the title."""
from shelfmark.release_sources.irc.source import IRCReleaseSource
book = _book(search_author=", ,", authors=["", " "])
assert _queries(book) == ["Blindness"]
assert IRCReleaseSource()._build_query(book) == "Blindness"
def test_a_bare_string_in_authors_is_not_iterated_character_by_character():
"""The IRC source guarded against this before it shared `pick_search_author`."""
book = _book(authors="José Saramago")
assert pick_search_author(book) == "José Saramago"
def test_the_producers_narrow_before_the_field_is_ever_set():
"""The two places that join also hold the split, so they set search_author from it.
Narrowing downstream cannot tell a comma that joins contributors from one inside a
single name; here the information is still present. See the same-named finding.
"""
from shelfmark.release_sources import BrowseRecord, browse_record_to_book_metadata
record = BrowseRecord(id="abc", title="Blindness", source="direct_download")
book = browse_record_to_book_metadata(record, author_override=JOINED)
assert book.search_author == "José Saramago"
assert book.authors == [a.strip() for a in JOINED.split(",")]
+51
View File
@@ -122,3 +122,54 @@ class TestReleaseSearchPlan:
plan = build_release_search_plan(book, languages=["fr"], user_id=7)
assert plan.languages == ["fr"]
class TestSearchAuthorNormalization:
"""A credit list must not reach the query, whichever field carries it.
Anna's Archive answers "Blindness Jose Saramago, Giovanni Pontiero, ..." with
nothing, so a book whose author string lists translators alongside the author
finds no releases at all.
"""
MULTI = "Jose Saramago, Giovanni Pontiero, Zohreh Eftekhari"
def test_search_author_is_trimmed_to_the_first_name(self):
book = BookMetadata(
provider="manual",
provider_id="1",
title="Blindness",
authors=[self.MULTI],
search_author=self.MULTI,
)
assert build_release_search_plan(book).primary_query == "Blindness Jose Saramago"
def test_search_author_matches_the_authors_list(self):
"""Same credit list, two fields, one query."""
via_authors = BookMetadata(
provider="manual", provider_id="1", title="Blindness", authors=[self.MULTI]
)
via_search_author = BookMetadata(
provider="manual",
provider_id="1",
title="Blindness",
authors=[self.MULTI],
search_author=self.MULTI,
)
assert (
build_release_search_plan(via_search_author).primary_query
== build_release_search_plan(via_authors).primary_query
)
def test_single_author_is_untouched(self):
book = BookMetadata(
provider="manual",
provider_id="1",
title="Elantris",
authors=["Brandon Sanderson"],
search_author="Brandon Sanderson",
)
assert build_release_search_plan(book).primary_query == "Elantris Brandon Sanderson"
@@ -0,0 +1,217 @@
"""A real Anna's Archive page must not be mistaken for a protection interstitial.
Regression for #1289/#1292. `_looks_like_challenge_page` substring-matched "ddos-guard"
over the whole document, and DDoS-Guard-fronted sites carry that string on their *own*
pages - Anna's Archive ships a `DDOS-GUARD` comment in the inline JS it serves on every
page. Any real AA response that was not a results table was therefore reported as an
unsolved challenge, telling users to go fix a bypasser that had just succeeded.
"""
import pytest
from bs4 import Tag
# Verbatim from a live annas-archive.pk 403, trimmed of nothing that matters: this is
# what an interstitial actually looks like, and it is under a kilobyte.
DDOS_GUARD_INTERSTITIAL = (
'<!doctype html><html><head><title>DDoS-Guard</title><meta charset="utf-8"/>'
'<link rel="stylesheet" href="/.well-known/ddos-guard/js-challenge/index.css">'
'<script defer="defer" src="/.well-known/ddos-guard/js-challenge/index.js"></script>'
'<script src="https://check.ddos-guard.net/check.js"></script></head>'
'<body data-ddg-origin="true"><div class="container"><h1 id="ddg-l10n-title">'
'Checking your browser before accessing <span class="ddg-origin"></span></h1>'
"<p>Please wait a few seconds.</p></div></body></html>"
)
# The marker that made every real AA page look like a challenge, quoted from the live
# site's inline JS, plus enough real page to clear the 64 KB size guard.
_AA_DDG_COMMENT = '// "text/css" for DDOS-GUARD caching.'
AA_PAGE_WITHOUT_TABLE = (
"<!doctype html><html><head><title>Anna’s Archive</title></head><body>"
f"<script>function f(){{ {_AA_DDG_COMMENT} fetch('/dyn/recent_downloads/'); }}</script>"
'<main><a href="/md5/abc123">a record</a>'
+ ("<p>real page body content</p>" * 3000)
+ "</main></body></html>"
)
class _Selector:
def __init__(self, bases: list[str]) -> None:
self._bases = bases
self._index = 0
self.current_base = bases[0]
self.quarantined: list[str] = []
def rewrite(self, url: str) -> str:
for base in self._bases:
if url.startswith(base):
return url.replace(base, self.current_base, 1)
return url
def next_mirror_or_rotate_dns(self, *, fatal: bool = False, reason: str = ""):
if fatal:
self.quarantined.append(self.current_base)
self._index += 1
if self._index >= len(self._bases):
return None, "exhausted"
self.current_base = self._bases[self._index]
return self.current_base, "mirror"
def _patch_pages(monkeypatch, pages: list[str]):
import shelfmark.release_sources.direct_download as dd
calls: list[str] = []
def fake_get(url, **_kwargs):
calls.append(url)
return pages[len(calls) - 1] if len(calls) <= len(pages) else ""
monkeypatch.setattr(dd.downloader, "html_get_page", fake_get)
monkeypatch.setattr(dd.network, "get_available_aa_urls", lambda: ["a", "b"])
return dd, calls
@pytest.fixture
def search_logs():
"""Collect this module's log messages.
setup_logger builds loggers outside the standard hierarchy, so their records never
reach the root handler caplog installs - see tests/bypass/test_ddg_cookie_reuse.py.
"""
import logging
import shelfmark.release_sources.direct_download as dd
messages: list[str] = []
class _Capture(logging.Handler):
def emit(self, record: logging.LogRecord) -> None:
messages.append(record.getMessage())
handler = _Capture()
dd.logger.addHandler(handler)
previous = dd.logger.level
dd.logger.setLevel(logging.DEBUG)
# Logger.setLevel only invalidates the is-enabled cache through the manager, which
# these loggers are not registered with; without this the DEBUG line stays filtered.
dd.logger._cache.clear()
try:
yield messages
finally:
dd.logger.removeHandler(handler)
dd.logger.setLevel(previous)
def test_the_size_guard_is_what_separates_a_real_page_from_an_interstitial():
"""The two inputs this bug turned on, checked directly."""
import shelfmark.release_sources.direct_download as dd
from shelfmark.bypass.challenge import MAX_CHALLENGE_HTML_CHARS
assert len(DDOS_GUARD_INTERSTITIAL) < MAX_CHALLENGE_HTML_CHARS
assert len(AA_PAGE_WITHOUT_TABLE) > MAX_CHALLENGE_HTML_CHARS
# Both contain "ddos-guard"; only one is a challenge.
assert "ddos-guard" in AA_PAGE_WITHOUT_TABLE.lower()
assert dd._looks_like_challenge_page(DDOS_GUARD_INTERSTITIAL)
assert not dd._looks_like_challenge_page(AA_PAGE_WITHOUT_TABLE)
def test_real_aa_page_without_a_table_is_not_reported_as_a_challenge(monkeypatch):
"""The #1289 failure: a served AA page raised "unsolved protection challenge"."""
dd, calls = _patch_pages(monkeypatch, [AA_PAGE_WITHOUT_TABLE])
selector = _Selector(["https://real.test", "https://other.test"])
html, table = dd._fetch_search_table("https://real.test/search?q=malice", selector)
assert table is None
assert html == AA_PAGE_WITHOUT_TABLE
# A live mirror holding our clearance: neither quarantined nor rotated away from.
assert selector.quarantined == []
assert len(calls) == 1
def test_aa_markers_win_over_challenge_markers_on_the_same_page(monkeypatch):
"""Ordering, not just the size guard, keeps a marker-carrying AA page readable."""
dd, _calls = _patch_pages(monkeypatch, [AA_PAGE_WITHOUT_TABLE])
monkeypatch.setattr(dd, "_looks_like_challenge_page", lambda _html: True)
selector = _Selector(["https://real.test", "https://other.test"])
_html, table = dd._fetch_search_table("https://real.test/search?q=malice", selector)
assert table is None
def test_genuine_interstitial_still_raises(monkeypatch):
"""The behaviour the check exists for is untouched."""
dd, _calls = _patch_pages(monkeypatch, [DDOS_GUARD_INTERSTITIAL])
selector = _Selector(["https://real.test", "https://other.test"])
with pytest.raises(dd.SearchUnavailableError, match="protection challenge"):
dd._fetch_search_table("https://real.test/search?q=malice", selector)
assert selector.quarantined == []
def test_results_table_is_still_returned(monkeypatch):
"""A page carrying the marker and a table is read as results, as before."""
page = AA_PAGE_WITHOUT_TABLE.replace(
"<main>", "<main><table><tbody><tr><td>Malice</td></tr></tbody></table>"
)
dd, _calls = _patch_pages(monkeypatch, [page])
selector = _Selector(["https://real.test"])
_html, table = dd._fetch_search_table("https://real.test/search?q=malice", selector)
assert isinstance(table, Tag)
def test_untabled_page_is_fingerprinted_in_the_log(monkeypatch, search_logs):
"""#1289 was unsolvable from the bundle because no line said what came back.
The log must carry the facts that separate the two cases, so the next report is read
rather than reverse-engineered: size, whether the size guard applied, the AA markers
found, and the challenge marker (or its absence).
"""
dd, _calls = _patch_pages(monkeypatch, [AA_PAGE_WITHOUT_TABLE])
selector = _Selector(["https://real.test", "https://other.test"])
dd._fetch_search_table("https://real.test/search?q=malice", selector)
verdict = next(m for m in search_logs if "no results table" in m)
assert f"bytes={len(AA_PAGE_WITHOUT_TABLE)}" in verdict
assert "over_challenge_size_cap=True" in verdict
assert "challenge_marker=None" in verdict
assert "/md5/" in verdict
# The head of the document is quoted too, bounded so a 180 KB page cannot flood the
# log file that ships inside the debug bundle.
head = next(m for m in search_logs if "Untabled search page head" in m)
assert AA_PAGE_WITHOUT_TABLE[:200] in head
assert len(head) < 2000
def test_interstitial_fingerprint_names_the_marker_that_proved_it(monkeypatch, search_logs):
"""The same line must also settle the opposite case, without needing the body."""
dd, _calls = _patch_pages(monkeypatch, [DDOS_GUARD_INTERSTITIAL])
selector = _Selector(["https://real.test", "https://other.test"])
with pytest.raises(dd.SearchUnavailableError):
dd._fetch_search_table("https://real.test/search?q=malice", selector)
verdict = next(m for m in search_logs if "no results table" in m)
assert "over_challenge_size_cap=False" in verdict
assert "challenge_marker='/.well-known/ddos-guard/'" in verdict
assert "aa_markers=none" in verdict
def test_fingerprint_failure_never_breaks_a_search(monkeypatch):
"""Diagnostics are best-effort; a bug in them must not cost the user their search."""
dd, _calls = _patch_pages(monkeypatch, [AA_PAGE_WITHOUT_TABLE])
def boom(_html):
raise RuntimeError("marker scan blew up")
monkeypatch.setattr(dd, "challenge_marker", boom)
selector = _Selector(["https://real.test", "https://other.test"])
_html, table = dd._fetch_search_table("https://real.test/search?q=malice", selector)
assert table is None
@@ -0,0 +1,193 @@
"""One search fetches each distinct AA URL once, however many passes ask for it.
`DirectDownloadSource.search` fans out: a title variant per grouped variant, then -
when nothing was found - the whole set again without the language filter. With
DIRECT_DOWNLOAD_LANGUAGE_FROM_PATH on, the requested language is applied locally
rather than as `&lang=`, so both passes build a byte-identical URL and the retry
re-fetches a page it already had. Behind DDoS-Guard that repeat is a fresh browser
solve, tens of seconds for nothing. See issue #1285.
"""
import pytest
from bs4 import BeautifulSoup
import shelfmark.release_sources.direct_download as dd
from shelfmark.core import search_deadline
@pytest.fixture(autouse=True)
def _no_ambient_deadline():
token = search_deadline._current.set(None)
yield
search_deadline._current.reset(token)
class _Selector:
current_base = "https://annas-archive.gl"
last_failure = None
def rewrite(self, url: str) -> str:
return url
def next_mirror_or_rotate_dns(self, *, fatal: bool = False, reason: str = ""):
return None, "exhausted"
_PAGE = "<html><body><main><table><tbody></tbody></table></main></body></html>"
def _count_fetches(monkeypatch) -> list[str]:
fetched: list[str] = []
monkeypatch.setattr(
dd.downloader, "html_get_page", lambda url, **_k: fetched.append(url) or _PAGE
)
monkeypatch.setattr(dd.network, "get_available_aa_urls", lambda: ["https://annas-archive.gl"])
return fetched
def test_repeated_url_is_fetched_once_within_one_search(monkeypatch):
fetched = _count_fetches(monkeypatch)
url = "https://annas-archive.gl/search?q=dune"
with dd._search_page_reuse():
first_html, first_table = dd._fetch_search_table(url, _Selector())
second_html, second_table = dd._fetch_search_table(url, _Selector())
assert fetched == [url], "the second ask should have been served from the search's cache"
assert first_html == second_html
assert second_table is first_table
def test_distinct_urls_are_still_fetched_separately(monkeypatch):
fetched = _count_fetches(monkeypatch)
with dd._search_page_reuse():
dd._fetch_search_table("https://annas-archive.gl/search?q=dune", _Selector())
dd._fetch_search_table("https://annas-archive.gl/search?q=dune&lang=en", _Selector())
assert len(fetched) == 2
def test_cache_does_not_leak_between_searches(monkeypatch):
"""A later request must not be answered from an earlier request's pages."""
fetched = _count_fetches(monkeypatch)
url = "https://annas-archive.gl/search?q=dune"
with dd._search_page_reuse():
dd._fetch_search_table(url, _Selector())
with dd._search_page_reuse():
dd._fetch_search_table(url, _Selector())
assert fetched == [url, url]
def test_without_the_context_every_fetch_still_goes_out(monkeypatch):
"""Callers outside a search - `get_book_info`, downloads - are unaffected."""
fetched = _count_fetches(monkeypatch)
url = "https://annas-archive.gl/search?q=dune"
dd._fetch_search_table(url, _Selector())
dd._fetch_search_table(url, _Selector())
assert fetched == [url, url]
def test_a_failure_is_not_cached(monkeypatch):
"""A spent budget raises; the next search must not inherit that as a stored answer."""
fetched = _count_fetches(monkeypatch)
url = "https://annas-archive.gl/search?q=dune"
with dd._search_page_reuse():
with search_deadline.search_deadline(60) as deadline:
deadline.event.set()
with pytest.raises(dd.SearchUnavailableError):
dd._fetch_search_table(url, _Selector())
dd._fetch_search_table(url, _Selector())
assert fetched == [url], "the successful retry should be the only fetch"
def test_a_give_up_page_is_not_cached(monkeypatch):
"""Exhausting the mirrors is not an answer, and the retry must get a fresh attempt.
`_fetch_search_table_uncached` exists to rotate past domains that are not AA, and when
it runs out it *returns* rather than raising: a page with no results table and no
marker. Storing that would hand the language-filter retry - the pass this cache exists
for - a mirror set that may have recovered since, turning a transient outage into
"this book has no releases".
"""
parked = "<html><body>This domain is for sale.</body></html>"
fetched: list[str] = []
monkeypatch.setattr(
dd.downloader, "html_get_page", lambda url, **_k: fetched.append(url) or parked
)
monkeypatch.setattr(dd.network, "get_available_aa_urls", lambda: ["https://annas-archive.gl"])
url = "https://annas-archive.gl/search?q=dune"
with dd._search_page_reuse():
assert dd._fetch_search_table(url, _Selector()) == (parked, None)
assert dd._fetch_search_table(url, _Selector()) == (parked, None)
assert fetched == [url, url], "the second pass must not inherit the first's give-up"
def test_a_genuinely_empty_result_is_still_cached(monkeypatch):
"""A page saying "No files found." is a real answer from a healthy mirror."""
empty = "<html><body><main>No files found. <a href='/md5/x'>x</a></main></body></html>"
fetched: list[str] = []
monkeypatch.setattr(
dd.downloader, "html_get_page", lambda url, **_k: fetched.append(url) or empty
)
monkeypatch.setattr(dd.network, "get_available_aa_urls", lambda: ["https://annas-archive.gl"])
url = "https://annas-archive.gl/search?q=nothing"
with dd._search_page_reuse():
assert dd._fetch_search_table(url, _Selector()) == (empty, None)
assert dd._fetch_search_table(url, _Selector()) == (empty, None)
assert fetched == [url], "an empty answer is an answer; re-solving for it buys nothing"
def test_language_retry_reuses_the_page_it_already_fetched(monkeypatch):
"""The end-to-end shape: language-from-path makes both passes build the same URL."""
fetched = _count_fetches(monkeypatch)
original_get = dd.config.get
def _fake_get(key: str, default=None, user_id=None):
del user_id
if key == "DIRECT_DOWNLOAD_LANGUAGE_FROM_PATH":
return True
return original_get(key, default)
monkeypatch.setattr(dd.config, "get", _fake_get)
monkeypatch.setattr(dd.network, "get_aa_base_url", lambda: "https://annas-archive.gl")
filters_with_lang = dd.SearchFilters(lang=["en"])
filters_without = dd.SearchFilters()
with dd._search_page_reuse():
dd.search_books("dune", filters_with_lang)
dd.search_books("dune", filters_without)
assert len(fetched) == 1, f"both passes build the same URL, got {fetched}"
assert "lang=" not in fetched[0]
def test_soup_reuse_is_safe_for_repeated_parsing(monkeypatch):
"""The cached Tag is read repeatedly, so reuse must not consume it."""
page = (
"<html><body><main><table><tbody><tr><td>row</td></tr></tbody></table></main></body></html>"
)
monkeypatch.setattr(dd.downloader, "html_get_page", lambda _url, **_k: page)
monkeypatch.setattr(dd.network, "get_available_aa_urls", lambda: ["https://annas-archive.gl"])
with dd._search_page_reuse():
_, first = dd._fetch_search_table("https://annas-archive.gl/search?q=dune", _Selector())
_, second = dd._fetch_search_table("https://annas-archive.gl/search?q=dune", _Selector())
assert first is not None
assert second is not None
assert len(first.find_all("tr")) == 1
assert len(second.find_all("tr")) == 1
assert isinstance(BeautifulSoup(str(second), "html.parser"), BeautifulSoup)
+2 -2
View File
@@ -306,8 +306,8 @@ def test_search_books_filters_locally_when_path_language_enabled(monkeypatch):
captured_url: dict[str, str] = {}
def _fake_html_get_page(url: str, selector, allow_bypasser_fallback=False):
del selector, allow_bypasser_fallback
def _fake_html_get_page(url: str, selector, **_kwargs):
del selector
captured_url["url"] = url
return r"""
<table>
@@ -0,0 +1,114 @@
"""The untabled-page diagnostic must name the mirror that actually answered.
Regression for #1298. `html_get_page` rotates mirrors and follows redirects internally,
so the URL the search layer passed in is only where the attempt started. Logging that
one made the debug bundle report the untabled page against annas-archive.gl when the
body had come from .pk - the exact triage cost the #1289 diagnostics were added to
remove, reintroduced by reading the wrong variable.
"""
import logging
import pytest
# A protection challenge, so the fingerprint line fires without looking like AA.
CHALLENGE_PAGE = (
"<html><head><title>DDOS-GUARD</title>"
'<link rel="stylesheet" href="/.well-known/ddos-guard/ddg-captcha-page/index.css">'
"</head><body>Complete the manual check to continue</body></html>"
)
@pytest.fixture
def search_logs():
"""Collect this module's log messages.
setup_logger builds loggers outside the standard hierarchy, so their records never
reach the root handler caplog installs - see tests/bypass/test_ddg_cookie_reuse.py.
"""
import shelfmark.release_sources.direct_download as dd
messages: list[str] = []
class _Capture(logging.Handler):
def emit(self, record: logging.LogRecord) -> None:
messages.append(record.getMessage())
handler = _Capture()
dd.logger.addHandler(handler)
previous = dd.logger.level
dd.logger.setLevel(logging.DEBUG)
dd.logger._cache.clear()
try:
yield messages
finally:
dd.logger.removeHandler(handler)
dd.logger.setLevel(previous)
class _Selector:
current_base = "https://annas-archive.gl"
last_failure = None
def rewrite(self, url: str) -> str:
return url
def next_mirror_or_rotate_dns(self, *, fatal: bool = False, reason: str = ""):
return None, "exhausted"
REQUESTED = "https://annas-archive.gl/search?q=Ken+follett"
ANSWERED = "https://annas-archive.pk/search?q=Ken+follett"
def test_the_fingerprint_names_the_mirror_that_answered(monkeypatch, search_logs):
import shelfmark.release_sources.direct_download as dd
def fake_get(url, **kwargs):
# The caller must ask for it, or there is nothing to report.
assert kwargs["include_response_url"] is True
assert url == REQUESTED
# What an internal rotation looks like from the outside: a different host.
return CHALLENGE_PAGE, ANSWERED
monkeypatch.setattr(dd.downloader, "html_get_page", fake_get)
monkeypatch.setattr(dd.network, "get_available_aa_urls", lambda: ["a"])
with pytest.raises(dd.SearchUnavailableError):
dd._fetch_search_table_uncached(REQUESTED, _Selector())
fingerprint = [m for m in search_logs if m.startswith("Search page has no results table")]
assert len(fingerprint) == 1
assert ANSWERED in fingerprint[0]
assert "annas-archive.gl" not in fingerprint[0]
def test_a_downloader_that_reports_no_url_falls_back_to_the_request(monkeypatch, search_logs):
"""The plain-string shape stays supported; the line is still worth having."""
import shelfmark.release_sources.direct_download as dd
monkeypatch.setattr(dd.downloader, "html_get_page", lambda _url, **_k: CHALLENGE_PAGE)
monkeypatch.setattr(dd.network, "get_available_aa_urls", lambda: ["a"])
with pytest.raises(dd.SearchUnavailableError):
dd._fetch_search_table_uncached(REQUESTED, _Selector())
fingerprint = [m for m in search_logs if m.startswith("Search page has no results table")]
assert len(fingerprint) == 1
assert REQUESTED in fingerprint[0]
def test_the_empty_body_give_up_survives_the_tuple_shape(monkeypatch):
"""`("", url)` is truthy, so the exhaustion check has to read the body."""
import shelfmark.release_sources.direct_download as dd
monkeypatch.setattr(dd.downloader, "html_get_page", lambda _url, **_k: ("", REQUESTED))
monkeypatch.setattr(dd.network, "get_available_aa_urls", lambda: ["a"])
selector = _Selector()
selector.last_failure = "Every mirror refused the connection."
with pytest.raises(dd.SearchUnavailableError) as excinfo:
dd._fetch_search_table_uncached(REQUESTED, selector)
assert "Every mirror refused the connection." in str(excinfo.value)
@@ -0,0 +1,139 @@
"""A solver must never be handed DDoS-Guard's ?check=1 probe URL.
Regression for #1292. `html_get_page` follows Anna's Archive redirects by hand, and
DDoS-Guard's gate answers /search with a 302 to the same path plus `check=1`. Because
the follower walks that handshake by reassigning `current_url`, every downstream handoff
- the 403 branch, the 503-challenge branch, the redirect-loop rescues - passed the
*probe* URL to the bypasser rather than the page we wanted.
A solver opens that in a fresh browser holding none of the cookies the probe exists to
collect, so DDoS-Guard cannot verify it automatically and serves the manual CAPTCHA page
that nothing can solve. The reporter's log is exactly that: a 403 handed off on a
`&check=1` URL, FlareSolverr answering "Challenge solved!", and a 4.7 KB DDOS-GUARD
CAPTCHA page coming back.
"""
import requests
class _FakeResponse:
"""Minimal stand-in for requests.Response covering what html_get_page touches."""
def __init__(
self,
status_code: int,
*,
url: str,
text: str = "",
headers: dict[str, str] | None = None,
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", **(headers or {})}
self.is_redirect = 300 <= status_code < 400
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 _aa_http(monkeypatch):
"""Import http with the network stubbed out and AA treated as an AA host."""
import shelfmark.download.http as 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.time, "sleep", lambda _seconds: None)
monkeypatch.setattr(http, "_is_cf_bypass_enabled", lambda: True)
monkeypatch.setattr(http.network, "should_rotate_dns_for_url", lambda _url: True)
monkeypatch.setattr(http.network, "is_aa_auto_mode", lambda: True)
return http
SEARCH_URL = "https://annas-archive.pk/search?index=&display=table&q=Ken+follett"
PROBE_URL = f"{SEARCH_URL}&check=1"
def test_403_on_the_check_probe_hands_over_the_pre_probe_url(monkeypatch):
"""The reporter's exact sequence: 302 to ?check=1, then 403 on the probe."""
http = _aa_http(monkeypatch)
bypassed: list[str] = []
def fake_get(url: str, **_kwargs):
if "check=1" not in url:
return _FakeResponse(302, url=url, headers={"Location": PROBE_URL})
return _FakeResponse(403, url=url)
monkeypatch.setattr(http.requests, "get", fake_get)
monkeypatch.setattr(
http, "get_bypassed_page", lambda url, *_a, **_k: bypassed.append(url) or "<html>ok</html>"
)
html = http.html_get_page(SEARCH_URL, retry=1, success_delay=0)
assert html == "<html>ok</html>"
# The page we wanted, not the handshake hop we happened to be standing on.
assert bypassed == [SEARCH_URL]
def test_redirect_loop_hands_over_the_pre_probe_url(monkeypatch):
"""Stale clearance turns the gate into an endless ?check=1 bounce."""
http = _aa_http(monkeypatch)
bypassed: list[str] = []
monkeypatch.setattr(
http.requests,
"get",
lambda url, **_kwargs: _FakeResponse(302, url=url, headers={"Location": PROBE_URL}),
)
monkeypatch.setattr(
http, "get_bypassed_page", lambda url, *_a, **_k: bypassed.append(url) or "<html>ok</html>"
)
html = http.html_get_page(SEARCH_URL, retry=1, success_delay=0)
assert html == "<html>ok</html>"
assert bypassed == [SEARCH_URL]
def test_only_the_check_parameter_is_dropped(monkeypatch):
"""Everything else about the URL survives - it is still the search we asked for."""
http = _aa_http(monkeypatch)
url = (
"https://annas-archive.pk/search?index=&page=1&display=table&acc=aa_download"
"&acc=external_download&ext=epub&q=Ken+follett&check=1"
)
assert http._solvable_url(url) == (
"https://annas-archive.pk/search?index=&page=1&display=table&acc=aa_download"
"&acc=external_download&ext=epub&q=Ken+follett"
)
def test_a_url_without_the_probe_is_returned_untouched(monkeypatch):
"""No rewriting, no re-encoding: an unrelated URL must come back identical."""
http = _aa_http(monkeypatch)
url = "https://annas-archive.pk/md5/abc?q=a%20b&empty="
assert http._solvable_url(url) is url
def test_non_aa_hosts_keep_their_check_parameter(monkeypatch):
"""Elsewhere `check` is an ordinary query parameter and none of our business."""
import shelfmark.download.http as http
monkeypatch.setattr(http.network, "should_rotate_dns_for_url", lambda _url: False)
url = "https://example.com/api?check=1"
assert http._solvable_url(url) == url
+2 -2
View File
@@ -97,7 +97,7 @@ end-to-end (`docker compose up` + suite + teardown) and passes.
> **`full` profile exercises the real end-to-end
> CF solve**: AA search/detail are reachable, but the AA slow-download link points
> at the gate, so downloading Moby-Dick forces the in-image headless Chromium to
> detect the challenge, solve it (`_bypass_method_cdp_solve`), and fetch the file —
> detect the challenge, solve it (`_bypass_method_cdp_gui_click`), and fetch the file —
> verified live (`Challenge detected: cloudflare` → `Bypass successful` → Moby-Dick
> in `/books`).
>
@@ -151,7 +151,7 @@ demand (excluded from the PR matrix). It spins up, with **no** mock bypasser, an
gate (`mock-cf`), whose challenge page runs JS that issues `cf_clearance` and
reloads. Downloading Moby-Dick forces the in-image headless Chromium (seleniumbase
CDP, in the `shelfmark` image via `xvfb`+`chromium`) to load the gate, detect the
challenge (`Challenge detected: cloudflare`), solve it (`_bypass_method_cdp_solve`),
challenge (`Challenge detected: cloudflare`), solve it (`_bypass_method_cdp_gui_click`),
and fetch the cleared "Download now" page → the file lands in `/books`. That
outcome is *only* reachable if Chrome solved the gate — the literal "spin a Chrome
browser" path and the strongest guard for the #1 cluster. Two subtleties this
+186
View File
@@ -0,0 +1,186 @@
"""Author agreement ranks Prowlarr results; it never narrows the query (#1293).
MyAnonamouse - the only indexer Shelfmark treats as enriched - used to receive
"{title} {author}". MAM ANDs its search terms, so any difference between the
metadata provider's author spelling and the tracker's ("Timothy Ferriss" vs
"Tim Ferriss") returned nothing at all and the UI reported the book as missing.
"""
import pytest
from shelfmark.metadata_providers import BookMetadata
from shelfmark.release_sources.prowlarr.source import ProwlarrSource
from shelfmark.release_sources.prowlarr.utils import (
AUTHOR_MATCH,
AUTHOR_MISMATCH,
AUTHOR_UNKNOWN,
author_affinity,
)
MAM_INDEXER_ID = 1
class TestAuthorAffinity:
@pytest.mark.parametrize(
("wanted", "offered"),
[
("Timothy Ferriss", "Tim Ferriss"),
("Tim Ferriss", "Timothy Ferriss"),
("T. Ferriss", "Timothy Ferriss"),
("Ursula K. Le Guin", "Ursula Le Guin"),
("Iain M. Banks", "Iain Banks"),
("Frank Herbert", "Frank Herbert, Brian Herbert"),
("Robert Jordan Jr.", "Robert Jordan"),
("homer", "Homer"),
],
)
def test_same_author_spelled_differently_agrees(self, wanted, offered):
assert author_affinity(wanted, offered) == AUTHOR_MATCH
@pytest.mark.parametrize(
("wanted", "offered"),
[
("Timothy Ferriss", "Frank Herbert"),
("Frank Herbert", "Brian Herbert"),
("Homer", "Virgil"),
],
)
def test_different_author_disagrees(self, wanted, offered):
assert author_affinity(wanted, offered) == AUTHOR_MISMATCH
@pytest.mark.parametrize(
("wanted", "offered"),
[
("Timothy Ferriss", None),
("Timothy Ferriss", ""),
("", "Tim Ferriss"),
(None, "Tim Ferriss"),
("Timothy Ferriss", {"name": "Tim Ferriss"}),
],
)
def test_missing_metadata_is_neither_agreement_nor_disagreement(self, wanted, offered):
# An indexer that reports no author must not sort below one that reports
# the wrong author, so this tier sits between the two.
assert author_affinity(wanted, offered) == AUTHOR_UNKNOWN
assert AUTHOR_MATCH < AUTHOR_UNKNOWN < AUTHOR_MISMATCH
def test_a_surname_alone_is_not_enough_for_a_full_name(self):
# "Ferriss" appearing under some other given name is a different person.
assert author_affinity("Timothy Ferriss", "Bruce Ferriss") == AUTHOR_MISMATCH
class _EnrichedIndexerClient:
"""Stands in for a Prowlarr with MyAnonamouse enabled."""
def __init__(self, search_results=None):
self.queries: list[str] = []
self.search_results = search_results or []
self.indexer_timeout = 90
def get_enabled_indexers_detailed(self, *, raise_on_error=False):
del raise_on_error
return [
{
"id": MAM_INDEXER_ID,
"enable": True,
"implementation": "MyAnonamouse",
"capabilities": {
"categories": [
{"id": 7000, "subCategories": []},
{"id": 3030, "subCategories": []},
]
},
}
]
def torznab_search(
self, *, indexer_id, query, categories=None, search_type="book", limit=100, offset=0
):
del indexer_id, categories, search_type, limit, offset
self.queries.append(query)
return self.search_results
def get_enriched_indexer_ids(self, restrict_to=None, indexers=None):
del restrict_to, indexers
return [MAM_INDEXER_ID]
def get_indexer_seed_settings(self, restrict_to=None):
del restrict_to
return {}
def _mam_result(guid: str, author: str | None) -> dict:
return {
"guid": guid,
"title": "The Tao of Seneca",
"author": author,
"indexerId": MAM_INDEXER_ID,
"indexer": "MyAnonamouse",
"protocol": "torrent",
"size": 1048576,
"seeders": 10,
"leechers": 1,
"categories": [{"id": 7020}],
"infoUrl": f"https://tracker.example/{guid}",
}
def _search(monkeypatch, client, *, manual_query=None):
import shelfmark.release_sources.prowlarr.source as prowlarr_source
from shelfmark.core.search_plan import build_release_search_plan
values = {"PROWLARR_INDEXERS": "", "PROWLARR_AUTO_EXPAND": False}
monkeypatch.setattr(
prowlarr_source.config, "get", lambda key, default=None: values.get(key, default)
)
source = ProwlarrSource()
monkeypatch.setattr(source, "_get_client", lambda: client)
book = BookMetadata(
provider="hardcover",
provider_id="123",
title="The Tao of Seneca",
authors=["Timothy Ferriss"],
)
plan = build_release_search_plan(book, languages=["en"], manual_query=manual_query)
return source.search(book, plan, content_type="ebook")
class TestEnrichedIndexerQuery:
def test_enriched_indexer_is_queried_without_the_author(self, monkeypatch):
client = _EnrichedIndexerClient()
_search(monkeypatch, client)
assert client.queries == ["The Tao of Seneca"]
assert not any("Ferriss" in query for query in client.queries)
class TestAuthorOrdering:
def test_matching_author_leads_and_mismatch_stays_visible(self, monkeypatch):
client = _EnrichedIndexerClient(
search_results=[
_mam_result("other-author", "Frank Herbert"),
_mam_result("no-author", None),
_mam_result("right-author", "Tim Ferriss"),
]
)
results = _search(monkeypatch, client)
# Ranked, not filtered: the wrong author is last but still reachable.
assert [r.extra["author"] for r in results] == ["Tim Ferriss", None, "Frank Herbert"]
def test_manual_query_is_not_reordered_against_the_metadata_author(self, monkeypatch):
client = _EnrichedIndexerClient(
search_results=[
_mam_result("other-author", "Frank Herbert"),
_mam_result("right-author", "Tim Ferriss"),
]
)
results = _search(monkeypatch, client, manual_query="tao seneca")
assert client.queries == ["tao seneca"]
assert [r.extra["author"] for r in results] == ["Frank Herbert", "Tim Ferriss"]
Generated
+57 -57
View File
@@ -451,11 +451,11 @@ wheels = [
[[package]]
name = "filelock"
version = "3.32.4"
version = "3.32.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6d/30/03b03951873a1a0ffc7e8ca0e10c15597b59e8d0e39260704cd2ea087bc4/filelock-3.32.4.tar.gz", hash = "sha256:2bde2e4cf732e0153406d8a7bc80620ecf5e621fe0d25e41143c4e3b4733ff30", size = 222126, upload-time = "2026-08-23T17:37:55.363Z" }
sdist = { url = "https://files.pythonhosted.org/packages/0a/a0/50c2c0ce5e74d7721bbb1b19a26ebd339aac5878553a6e35308c2f31f935/filelock-3.32.5.tar.gz", hash = "sha256:f6a6a28f743f9b95ce19db5abe0f376f75eb56517dff21e1a4751e2657d3e83d", size = 222838, upload-time = "2026-08-31T18:56:34.729Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/01/a4/9b63d595d748e3aff8812b65eacc1a2c4bd90b7c2012e08e72373b4835eb/filelock-3.32.4-py3-none-any.whl", hash = "sha256:22e58ca3b1ae3b98993b762d7338367ae64fe50252bf78d59da3bfebcdf1cedd", size = 99864, upload-time = "2026-08-23T17:37:53.913Z" },
{ url = "https://files.pythonhosted.org/packages/36/d2/b70a31e13d04456d28493f31d2aa087e99eeb2767ef0293b2625727ccb8c/filelock-3.32.5-py3-none-any.whl", hash = "sha256:142cd9fa77a872c5e78c62329a0d15278fadc686eb89e760017968961a4fd6b2", size = 100003, upload-time = "2026-08-31T18:56:33.078Z" },
]
[[package]]
@@ -598,11 +598,11 @@ wheels = [
[[package]]
name = "gunicorn"
version = "26.1.0"
version = "26.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/38/b8/ec4ba3f6cace4091c34e27478b576bb80f2f06fab80fd42c0ecc785b308f/gunicorn-26.1.0.tar.gz", hash = "sha256:1413d777bf99d31ebeb08acd354b01f1ecc44db0aa7b811ae7b86c669232e4f7", size = 755923, upload-time = "2026-08-18T11:49:39.438Z" }
sdist = { url = "https://files.pythonhosted.org/packages/d9/8a/e4ef6ee11701b6cd64702848415ffb69eeff85cb388a3c6c7fe86f22f3f8/gunicorn-26.2.0.tar.gz", hash = "sha256:62b864895d9ebff0b2f9867ba04fe811c93121596540830c9c916d0769668447", size = 787921, upload-time = "2026-08-24T15:05:59.3Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/19/dc/7a55fc605543fd5cb11c003fbbb21a1911d5e88a582cce6c5e063bf5c176/gunicorn-26.1.0-py3-none-any.whl", hash = "sha256:9f45bcddec5e9dc7a25a3bdccb0c6832f11fd5d4739b1ee36c8d2fec25f1dc86", size = 216237, upload-time = "2026-08-18T11:49:38.001Z" },
{ url = "https://files.pythonhosted.org/packages/fe/85/7522a52e5e2f42faf1a129113ab63e548c42e103e9af395b7bfe65e403e2/gunicorn-26.2.0-py3-none-any.whl", hash = "sha256:bd249d0b3f7972f7432f0a6b6ff3b3ee2d129f70cd1ff6c09a9dd9e29a2b88e3", size = 228389, upload-time = "2026-08-24T15:05:57.67Z" },
]
[[package]]
@@ -911,11 +911,11 @@ wheels = [
[[package]]
name = "platformdirs"
version = "4.11.3"
version = "4.11.7"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b8/d7/e7bfbc86e9f99ff7807e24de7703f032e9c9ba80bb355cf26e0e9bc5a75e/platformdirs-4.11.3.tar.gz", hash = "sha256:66a73d38a849810252df809a3d8bcbda8e26f6c189920e7535ad608a48dbb5ab", size = 33050, upload-time = "2026-08-13T22:43:27.52Z" }
sdist = { url = "https://files.pythonhosted.org/packages/69/b7/802a56eca9f2fac455b8bab5375a2647b0f0e14a2cd63ef077de3c4a7658/platformdirs-4.11.7.tar.gz", hash = "sha256:4f41487eeeeeb07f3a6625e61d9bc0ae6809f92d3386dbd74392fbb76108104d", size = 35127, upload-time = "2026-09-01T13:35:10.502Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/19/a9/c34aebedd3a4c9afe5101b1b8713710b3fec18087c8a36c35d2f909861bd/platformdirs-4.11.3-py3-none-any.whl", hash = "sha256:5ed065d443751de711da036041a7a214122efc4a4de393b3f4137ba5576540e7", size = 23491, upload-time = "2026-08-13T22:43:26.121Z" },
{ url = "https://files.pythonhosted.org/packages/27/6e/80993e10a0482f630cef528635789233224f36b1ffd11592aa15d13ff9ce/platformdirs-4.11.7-py3-none-any.whl", hash = "sha256:8a02cb259042c79d1cd0450facc2fe6dc9d303ae7901afbe33bf8ea0b188cef6", size = 23938, upload-time = "2026-09-01T13:35:09.02Z" },
]
[[package]]
@@ -929,26 +929,26 @@ wheels = [
[[package]]
name = "prek"
version = "0.4.14"
version = "0.5.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/cf/51/135dc6ba2c021ce32b40700c8c337db72d802893e15291f4b3056076582f/prek-0.4.14.tar.gz", hash = "sha256:f6d0952e31ffd6e508660749dd51b8d8de96e955ed12c40e411f3224f502fed2", size = 537668, upload-time = "2026-08-17T04:27:55.031Z" }
sdist = { url = "https://files.pythonhosted.org/packages/ed/c8/ad3efcaf7007e4796f9a7482b4a3d84402c1535109695cba76fcb87cb03d/prek-0.5.0.tar.gz", hash = "sha256:8df015db60c1e9a30b4a266e65fa14c4d41d2b2b3b90879d76a5d8970dcce64d", size = 544033, upload-time = "2026-08-27T03:50:33.079Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9d/75/727724174d419cab6e11e0c22c3fbcdd083312b0a0202dfa6befdb7f1bcb/prek-0.4.14-py3-none-linux_armv6l.whl", hash = "sha256:cf7fe2e07c99948ca3326fbd4254054cf4caa71a69cf9032be09c3938544e1e3", size = 5878406, upload-time = "2026-08-17T04:27:30.135Z" },
{ url = "https://files.pythonhosted.org/packages/34/b6/82dfb41347342b4c53c1ddd27cfa81d3e1cf4ad0d5d76493c51e8da58dc9/prek-0.4.14-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6c8043aa5555c2ada561f4c69429e5117f370ad92f7d708340613b0965e725bd", size = 6216669, upload-time = "2026-08-17T04:27:31.795Z" },
{ url = "https://files.pythonhosted.org/packages/5b/68/ade0ba8b8c0044a3f7ca1a5cd7c97bc10656398c67466b04235f0f0b7418/prek-0.4.14-py3-none-macosx_11_0_arm64.whl", hash = "sha256:355570f0d8366a56817e55f44edf736ffc9ff2323894e21cd739ab519d343541", size = 5728085, upload-time = "2026-08-17T04:27:33.322Z" },
{ url = "https://files.pythonhosted.org/packages/a0/cc/8ddb67fb000d1ad4c95288e6c7e27989d3c172cd88893e3a6f9cdd7199fb/prek-0.4.14-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:334f48a2b19e63c5ab741e601cb6088caaa11d1b75146900dc298b055452d551", size = 6043650, upload-time = "2026-08-17T04:27:34.699Z" },
{ url = "https://files.pythonhosted.org/packages/5b/49/2780489798147fa1e81fa017af384a40229802cf36680cf72c74478d143c/prek-0.4.14-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5fbb17270df2dcb3c1aa6edfaa68f850d7968862413e58b1572e41c981f644f9", size = 5786094, upload-time = "2026-08-17T04:27:36.282Z" },
{ url = "https://files.pythonhosted.org/packages/6a/2a/27a4cdfa767b46663eb32a2b5f78ebaaeefc6e5caa87a21587216a504e31/prek-0.4.14-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ec46be0b1b45943a0746fdf69feeede274b0230bad32b11e2489bb600846081", size = 6239874, upload-time = "2026-08-17T04:27:37.984Z" },
{ url = "https://files.pythonhosted.org/packages/e0/ca/5ae58d95bc9dbad75ea13aea83fe0acfaca37d027c6bd7f9f1d1d97f3666/prek-0.4.14-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a21eea6ee996dbba351c1af8c6bd1959a61c37949757c0b97aef3edde82c126a", size = 6968009, upload-time = "2026-08-17T04:27:39.48Z" },
{ url = "https://files.pythonhosted.org/packages/bb/1c/c6a7800406f987559fc8e7c2cfd257a5a68096e806e953f143b488e00e4f/prek-0.4.14-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:34169cb1c8dfbe4b9cb7af164f5c1c3dff68929c61ca1e1de28d378b32720836", size = 6443370, upload-time = "2026-08-17T04:27:41.181Z" },
{ url = "https://files.pythonhosted.org/packages/92/38/4bf84223216ce2d31b500691d1644a9f35d6e468cb8ecb9a7dfbc66bc82c/prek-0.4.14-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:e39772ebf579f957fdbbc66ef6a7f7433bf603128cd60d5c804c70caac5f69c0", size = 6056449, upload-time = "2026-08-17T04:27:42.722Z" },
{ url = "https://files.pythonhosted.org/packages/75/0a/f1980039ab7bf8342c4aa4da2a4cafe40e6a840f456d5bbf86ba024fcdca/prek-0.4.14-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a79e5940354a789e1311172a960c5daa75e3f643a47ee2be85b7d2ca1d7d980a", size = 5839941, upload-time = "2026-08-17T04:27:44.306Z" },
{ url = "https://files.pythonhosted.org/packages/f1/e2/67f067dcb912e157bc7f4cd2aeb422a0de65e7744c675d7f439676f6c832/prek-0.4.14-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:60b6539c1b28804807849173a6b49d467f6f36ca98bbb4536f5e34114d79942a", size = 5762723, upload-time = "2026-08-17T04:27:45.737Z" },
{ url = "https://files.pythonhosted.org/packages/84/39/6669fff5c3336a2b79cf853c86b95547cf9be720a2ee7e4ae5eaa6cb46a1/prek-0.4.14-py3-none-musllinux_1_1_i686.whl", hash = "sha256:24cfabd9e5b8c5546ecaef562841a41a255a9dec1b7ae7761ce5a7a024ab9dd9", size = 6090040, upload-time = "2026-08-17T04:27:47.185Z" },
{ url = "https://files.pythonhosted.org/packages/2a/23/c23210be4c89795a854e76146c2465aaffc3d85ea83df61c1d4d5bb41bca/prek-0.4.14-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:1f08d368da45439f949885bb0b637bc7dfe6910140d5285fa8bae058697ddd06", size = 6570273, upload-time = "2026-08-17T04:27:48.673Z" },
{ url = "https://files.pythonhosted.org/packages/00/9c/4f10728ed36295347e84f01bb3e4c9078c9e337edc66215f5e2066b30551/prek-0.4.14-py3-none-win32.whl", hash = "sha256:5bfb30808ce2099c67d2a2d4cd68dc031e3fec1eeb61d73ef51a8f7c04d021cb", size = 5586715, upload-time = "2026-08-17T04:27:50.471Z" },
{ url = "https://files.pythonhosted.org/packages/4f/a7/d080a157d4e92927f7618d62d6a23979ef64a10c76a040cb8a8a194c50d7/prek-0.4.14-py3-none-win_amd64.whl", hash = "sha256:29364012d5704475d1092eb8a96ea30b163279096ad5e0c80a620fffa79bc639", size = 5969946, upload-time = "2026-08-17T04:27:51.971Z" },
{ url = "https://files.pythonhosted.org/packages/37/e1/6fc64bb82e7270f61707e00b6d3154a4592ae0b2d3bd308173aa7aabe0a1/prek-0.4.14-py3-none-win_arm64.whl", hash = "sha256:ff588c02e10c8d05150763607671a22d5585c0ff7036c884d3489c2726eb215c", size = 5729906, upload-time = "2026-08-17T04:27:53.52Z" },
{ url = "https://files.pythonhosted.org/packages/89/83/a5b36db04d5ed8c461b9fec216193a52eee5be37170a9387022f832312b6/prek-0.5.0-py3-none-linux_armv6l.whl", hash = "sha256:df794a883e347ef78cb74522a143dbd8cc2a66e765ef328f48b615f37098015e", size = 5593469, upload-time = "2026-08-27T03:50:00.402Z" },
{ url = "https://files.pythonhosted.org/packages/ca/b5/cd6cab203693a1c45d5a3bae3b8eb56174d595da8af6d6e1595c795f909a/prek-0.5.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:19d6fd892e112cf7dc300735a7ac8e596813ff1c11649ba2d2b9dd6fdfd6c96e", size = 5980270, upload-time = "2026-08-27T03:50:02.675Z" },
{ url = "https://files.pythonhosted.org/packages/9d/b6/bbf5134c7b1360aad3f62c0f0391a7c6403b3500d16df25848089ad957c3/prek-0.5.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:313e2535483c37bba884ab034f478c174de7d3b9627081f2eedd16ac2c03bcc3", size = 5521835, upload-time = "2026-08-27T03:50:04.609Z" },
{ url = "https://files.pythonhosted.org/packages/78/72/7c79e9de5603fdc54d2101f0982f1af5e5ffa1a45c0bcbdbe5f985ed6b90/prek-0.5.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:1e9b34caeb465f1fa7cbb5d872d4611560eaff877162c4ad80a608838b0819a3", size = 5822039, upload-time = "2026-08-27T03:50:07.281Z" },
{ url = "https://files.pythonhosted.org/packages/dc/d2/68c21ec26f773b2370b132d69fe325589db08865fbe8b3df7d8ea8d8fc49/prek-0.5.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:561c2d028c7679fe8969937aa9ab74d96d6f7c748b8abf98edf6dc031dfbbdc9", size = 5510802, upload-time = "2026-08-27T03:50:09.2Z" },
{ url = "https://files.pythonhosted.org/packages/7a/9e/ce6db8d998cb3e48bf747b7390e1ec87bb817ec500cfa5791af3a0d2eded/prek-0.5.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:310579ceb4acbc5646df9d663ec38a57b47258a903780089cfacfbf0dc9d49f1", size = 5969141, upload-time = "2026-08-27T03:50:11.211Z" },
{ url = "https://files.pythonhosted.org/packages/5a/10/35e4c8fc4534d3b98c1bef2bfd559ba2aeedc9e540fcd4091fd4d91543b6/prek-0.5.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:da38e9c1d227773728c05d2f644704f3d3ace67f6ea58e41d406ebb596c3a135", size = 6731634, upload-time = "2026-08-27T03:50:13.244Z" },
{ url = "https://files.pythonhosted.org/packages/a2/fc/433b55d10e928de8ab2a9b83dfdbe8cba936fafc6e0a5c65b85f4007a8ea/prek-0.5.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b845f25f481a7df56f39dddcba35576a10d78aa71e09e4f88bcccce0729ebfb2", size = 6203980, upload-time = "2026-08-27T03:50:15.158Z" },
{ url = "https://files.pythonhosted.org/packages/7b/b8/b5cdd27d6da0d754abcf9cc06a092f476bd9242f0e8e19e3dfd1a2d1b315/prek-0.5.0-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:93b4086a1b69c24a967b5c80608cfc4a111cc2b0f69bc1d1f278eb4c23ad430c", size = 5827384, upload-time = "2026-08-27T03:50:16.898Z" },
{ url = "https://files.pythonhosted.org/packages/18/e5/1d8fd614dfe04aa9378b33fb23f7285da582ca090c321973da8466a754d5/prek-0.5.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:3299eca7269582665255f4c8f3ab38bc64e4d16ab2e9ce5e3d20cf65bc9f40ee", size = 5589195, upload-time = "2026-08-27T03:50:18.857Z" },
{ url = "https://files.pythonhosted.org/packages/6c/89/e758cefe423cd1422e882325baef65af2bf2b6a903c32d657323a993d394/prek-0.5.0-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:005c20b3df988f240bfa50518777d5babeab50cf774a8917dba1705567e1f561", size = 5491009, upload-time = "2026-08-27T03:50:20.836Z" },
{ url = "https://files.pythonhosted.org/packages/ea/90/6b30d898a2610113378b78b82f1d700bcdb79993ba92b85f492a02859f53/prek-0.5.0-py3-none-musllinux_1_1_i686.whl", hash = "sha256:a1a46d8de94d7c7c58b027a3622763861f1c76b01bafe9dc6d8b408713ad3946", size = 5826007, upload-time = "2026-08-27T03:50:23.1Z" },
{ url = "https://files.pythonhosted.org/packages/48/23/3fd00ff6d844b1756b95fc913730c6ec2f772f36558693ff3c4cfb2d3071/prek-0.5.0-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:e2aad66e0111cab24a03af2f67fca737c3d8f7b1188cf9b8994bbbf7b52f53b8", size = 6319092, upload-time = "2026-08-27T03:50:25.41Z" },
{ url = "https://files.pythonhosted.org/packages/ca/10/5d99bdfc77b51ab427a139e0b9f2dd5eacc74fe02fd1228c7be1a8ede6ad/prek-0.5.0-py3-none-win32.whl", hash = "sha256:d4970878d5032ad101b4ec5662f3848a70f80c0295ed5a1fea0aed82979c1251", size = 5340535, upload-time = "2026-08-27T03:50:27.452Z" },
{ url = "https://files.pythonhosted.org/packages/ab/2a/835019081b3a09acefc6e28a6dd561a45c8270f8a84e85fa00d6f2be0300/prek-0.5.0-py3-none-win_amd64.whl", hash = "sha256:7bf2df923ba48ec72af7dc69033d613f4775eb301cdf975e19e749ecda2a28c4", size = 5725188, upload-time = "2026-08-27T03:50:29.492Z" },
{ url = "https://files.pythonhosted.org/packages/55/dc/a634d4c5951bd899526c6d8f2a4776bc42af1df4b01951e4f90412909cfc/prek-0.5.0-py3-none-win_arm64.whl", hash = "sha256:43e9d2d727435b0be28fe2195a6a22b0e5e0d937270ae0c88edfc42b71db8fab", size = 5492540, upload-time = "2026-08-27T03:50:31.49Z" },
]
[[package]]
@@ -1377,27 +1377,27 @@ wheels = [
[[package]]
name = "ruff"
version = "0.16.4"
version = "0.16.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/00/8f/d8074b1f25e003164087a8bfe79a0f1a3945135764dbb6aaab04103dcaf9/ruff-0.16.4.tar.gz", hash = "sha256:13171aa9d9af2240ee3504e639de73122c67e74036de5ba2e1d01422cd17e3dc", size = 4899731, upload-time = "2026-08-20T17:43:59.196Z" }
sdist = { url = "https://files.pythonhosted.org/packages/f3/85/c8e12473c93018f92d19dd988a294202e1c27426c47ec4de53ffb847b8d8/ruff-0.16.5.tar.gz", hash = "sha256:1b88500f9ffbcab3dedb0082c9f9492e91ec3d618aac1236a3e0189938f7040b", size = 4912003, upload-time = "2026-08-27T16:34:18.258Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ff/80/779895ef584e089d22f2c6df0d0e99a65ec2df0805f1fffd439415b8c1f0/ruff-0.16.4-py3-none-linux_armv6l.whl", hash = "sha256:df4075f71ddac40b9934af60c3ec8a53047dd5a5fdc43224e6e4e8e9a27cb6f7", size = 10006909, upload-time = "2026-08-20T17:43:16.888Z" },
{ url = "https://files.pythonhosted.org/packages/a9/e6/f553199b5e8927a05cb5c422d921fd0656b29ab976e91c44802107c6b0da/ruff-0.16.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0c95538517af68004306b0fb3214ff2f2af67a65092aee77cd9eb86db6656604", size = 10240201, upload-time = "2026-08-20T17:43:19.337Z" },
{ url = "https://files.pythonhosted.org/packages/1c/70/4a6dc4bb34da4dee35e30f09bbd1bfbdd26f33b62fb9b8df31f08a199cd2/ruff-0.16.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:963f83df8e69e575b64d67dd447ebbc917db41a14bf38d4593a4183e7aaa8255", size = 9835122, upload-time = "2026-08-20T17:43:21.708Z" },
{ url = "https://files.pythonhosted.org/packages/24/12/c6e22d686372c15bcb7af99831f1a1be96df696491babf4f24e4f942c527/ruff-0.16.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32a5057c7ff3f6e6480a48fccfb3a412a690f48a3d03ac5cf08177d6c2da3ade", size = 9977162, upload-time = "2026-08-20T17:43:24.236Z" },
{ url = "https://files.pythonhosted.org/packages/46/49/72b10ec912f5ab5854992eaf7aa7cd36729b6937d9dc4e0fb41b3bf428ec/ruff-0.16.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b3dce8d9b0c57c265b91885a66a567d8ea1372e8eb4e250fa8e5e3f579e99cff", size = 9829789, upload-time = "2026-08-20T17:43:26.966Z" },
{ url = "https://files.pythonhosted.org/packages/fa/80/0f30e32e7f6ee26edc39075502db9d368d788a44a79b55f763eb4ab03796/ruff-0.16.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7dc651db49283c69f8e72c834eec4fe5573e4c646856aebece0ce385dceb2a80", size = 10527949, upload-time = "2026-08-20T17:43:29.384Z" },
{ url = "https://files.pythonhosted.org/packages/52/3d/86e8ad3542169e56cac3859a343afdb9df2ad54d35a59ce1e67baee83421/ruff-0.16.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3817b87dbcabc92f13b05019257c5b89b5b4d51b5fb20f56fb5235ceb723cd07", size = 11333695, upload-time = "2026-08-20T17:43:31.872Z" },
{ url = "https://files.pythonhosted.org/packages/d0/16/481c29b380c20a0054a8261066665e1b3488e23636c49d0a43e75975b9bb/ruff-0.16.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e9fce1499134b2c8c68e5166f95705a5812062bb93aacc5f9873bb1a27084bc7", size = 10727741, upload-time = "2026-08-20T17:43:34.596Z" },
{ url = "https://files.pythonhosted.org/packages/5e/b6/56bc0b8cf45b54b28b3a5e6381c8945d51b5b18adf659454c32295209a31/ruff-0.16.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f2d812e482f5a7e02eee26cd73d2a37ebbdf47d795ea63ba1b89110ae93e9fb3", size = 10286522, upload-time = "2026-08-20T17:43:37.288Z" },
{ url = "https://files.pythonhosted.org/packages/e8/8b/b345b4fb110f2fbe2bd31eabd271e5e8b3b7e4ee6c0e02f2dc6be78db000/ruff-0.16.4-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:6baaf984aa7976edf93d3b627fe2d1d22ee94bbca05fa6f90fc76d73924e3454", size = 10584182, upload-time = "2026-08-20T17:43:39.984Z" },
{ url = "https://files.pythonhosted.org/packages/29/e5/827b34041c35f58774a9681a4213994c164fc987800f4dddabcf451da0bf/ruff-0.16.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:bdfcf0b28662eb890372d50f92c283bb94e67e7635ed93c7fd533970acff7b2b", size = 10134195, upload-time = "2026-08-20T17:43:42.351Z" },
{ url = "https://files.pythonhosted.org/packages/0f/10/d0bffcdd6729b87afc82ba0ef377173356a7dc8e972f5179968cf2fdf98c/ruff-0.16.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b66b02cb9b04f537643cadf5768e5f98dc461890d530cb67113d71c8c76e605d", size = 9825821, upload-time = "2026-08-20T17:43:44.532Z" },
{ url = "https://files.pythonhosted.org/packages/f5/32/0db2a863b796ca62d83e92a07a3ccf00921b14db02059347576a2fda3d4b/ruff-0.16.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:8528bf9a4b291a60bf02ea453511e8ce6215bd2b982ee80405b66b008b6c30a0", size = 10267658, upload-time = "2026-08-20T17:43:46.989Z" },
{ url = "https://files.pythonhosted.org/packages/b2/a0/fbdeb59e48c6261f523e56c8f12e9c08fbe693786595cc7e3959207a9232/ruff-0.16.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:fbd85d2875fdd67e833213a651f613bbf25303abf6aa822a5121f4531195678d", size = 10697071, upload-time = "2026-08-20T17:43:49.891Z" },
{ url = "https://files.pythonhosted.org/packages/aa/28/0c6dd865859c6d17bc8ccc34cb72b0e02d6c7eb25e8a1e22b5bea681e2c0/ruff-0.16.4-py3-none-win32.whl", hash = "sha256:312769988007aaeb8e189b443ccdd03c0e6374489e053467be6d96518ebff76e", size = 10021687, upload-time = "2026-08-20T17:43:52.281Z" },
{ url = "https://files.pythonhosted.org/packages/a3/03/e724450f621698117f9aa6dd241c94d0274ae96781378dc86745ae29f0e7/ruff-0.16.4-py3-none-win_amd64.whl", hash = "sha256:05d9d27a18c4bcbefada602480ec9e01e0bc949d432e0ced5df77edac195919c", size = 10567657, upload-time = "2026-08-20T17:43:54.78Z" },
{ url = "https://files.pythonhosted.org/packages/0e/fe/da8b9e1347696bb22120b77280ec5ce25d500ca5cb39d5ad6e5c18de19c1/ruff-0.16.4-py3-none-win_arm64.whl", hash = "sha256:a3a61621c9b6f6a89573e938a080e648f1695baa3f58570a3a707bc51ff65a21", size = 10451579, upload-time = "2026-08-20T17:43:57.135Z" },
{ url = "https://files.pythonhosted.org/packages/c6/b6/77c90a970fe2dae17a723acbd011043ea97c98d7deacccefdc4ba74ec512/ruff-0.16.5-py3-none-linux_armv6l.whl", hash = "sha256:12e5f673e774c35fbb62f288809c7653b73445f8ecec6b6063fd6ea3521aa14b", size = 10011941, upload-time = "2026-08-27T16:33:41.287Z" },
{ url = "https://files.pythonhosted.org/packages/4b/46/6cf67cf6411885a1d6f7f6d801682f155536a85176d10b605e2ceffed8bd/ruff-0.16.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:eda58a5802de40e7ed5b32b64e0b32539338cc6fcd2c78f61e3ad6a0d79f51c3", size = 10204049, upload-time = "2026-08-27T16:33:44.056Z" },
{ url = "https://files.pythonhosted.org/packages/46/fd/c8720ca7a090abf0c2fef4abe8a5ef6e5127ed15196d8886ff75a2b370e2/ruff-0.16.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5ae9a7b9a8875131f40f8fe967cc86abf899779efd663cb7ce3d572d01da7eb", size = 9809037, upload-time = "2026-08-27T16:33:46.257Z" },
{ url = "https://files.pythonhosted.org/packages/43/45/a684caacdedaca180f52bacccc40bf0789d2c5a7c75f25324853e9eaedb5/ruff-0.16.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b719b0a1f4d59710d283ab2965f621684a108a9e41da622e3b23f0326cd0025", size = 9964129, upload-time = "2026-08-27T16:33:48.352Z" },
{ url = "https://files.pythonhosted.org/packages/9e/f2/5d2bcdaca6b5b93d1b4dfc166cd2aebf7680143a1b38a28759df13a94d31/ruff-0.16.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2298f2780ed1be0c5cb1361e32ab7b1467f3cce7dabe101d2210a314f2fe42e9", size = 9821518, upload-time = "2026-08-27T16:33:50.57Z" },
{ url = "https://files.pythonhosted.org/packages/aa/ff/011cce29accf9257d5974145b733fc653a37985ed6825413a3987cefbfe0/ruff-0.16.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:258f29035a2dd021e7861e631b227a5b3f14e50c1184c9a6a122c5f4576154d7", size = 10534835, upload-time = "2026-08-27T16:33:52.522Z" },
{ url = "https://files.pythonhosted.org/packages/d7/5a/f0cf109bada9bba0e96c90c21c9f9251803f57225c32d293327a03c710d6/ruff-0.16.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b9a4f0432966834019c74d1b7e5c51224305d7713f3d7faf3e7451f1a3be3cde", size = 11252550, upload-time = "2026-08-27T16:33:54.521Z" },
{ url = "https://files.pythonhosted.org/packages/63/4d/1d481aaea2046c6a7ed7c291f9004c669cce3c087b6b376ed5b08271e3fe/ruff-0.16.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b5eb3a8c3d0ade9cea42b591fd530368e8798380e30e0a308b85a5cf718f09ea", size = 10777949, upload-time = "2026-08-27T16:33:56.88Z" },
{ url = "https://files.pythonhosted.org/packages/ee/34/ee245ca55f64443233034b3d02b03236b19242004281247c079390b7facd/ruff-0.16.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ef0f69e191a13a3c9816f63163c88790cb12cd157bbbb384e9c44745702ab105", size = 10311656, upload-time = "2026-08-27T16:33:59.12Z" },
{ url = "https://files.pythonhosted.org/packages/a7/4d/c33a333e341c0a2b96c715b52d89a606f5a34cd4ac493cd9b8d0187186b8/ruff-0.16.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:0eeab41fbea2c42f98dfb9822cdccda9d24ba38d49f6dc945b5c236d48f0ef29", size = 10532125, upload-time = "2026-08-27T16:34:01.166Z" },
{ url = "https://files.pythonhosted.org/packages/30/e1/a64cef78b40192497bb98a27a8aa8f2c98ee9ee15bc97f7712d94ef32937/ruff-0.16.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:f0768e9df4300713fff30733c87575f68b6f1d8de41184e505b7fdd9c0c95eaf", size = 10097648, upload-time = "2026-08-27T16:34:03.16Z" },
{ url = "https://files.pythonhosted.org/packages/cc/4e/4cdc9ed3c3e109d2f71e62572a37457298d7bc7501ec3138babb7ed32bbd/ruff-0.16.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:95cc70cdc7aa80c338de356279d2adbeb2de0f520b9ecd8aba75b94e95e02f91", size = 9829344, upload-time = "2026-08-27T16:34:05.134Z" },
{ url = "https://files.pythonhosted.org/packages/39/4a/31ed35ce31729955fc583ee0d176d6e784c1290cb0b0a75cb2134c1ab72a/ruff-0.16.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d185c8398ded1bfd91c0c2cb258346307571eccc473a8490af8c3977399c384a", size = 10277117, upload-time = "2026-08-27T16:34:07.425Z" },
{ url = "https://files.pythonhosted.org/packages/a8/a0/60356d86687b4b666d593df213f4dc3041750d024cb7bf2cfa81cfd65c2e/ruff-0.16.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:fb8e3a3c4c6a784150a7ced53b015f4b253fc2bf97a610886419ead64b4756ef", size = 10711653, upload-time = "2026-08-27T16:34:09.712Z" },
{ url = "https://files.pythonhosted.org/packages/ed/20/656d67f5b25ca9bda4e02b1de25867b2954e1d19e03648060f167ad0f4cc/ruff-0.16.5-py3-none-win32.whl", hash = "sha256:288b0a5f080492fe5635db849f9e2e84aa3cce7b7f0e955997d416c507c76a26", size = 10034250, upload-time = "2026-08-27T16:34:11.8Z" },
{ url = "https://files.pythonhosted.org/packages/5b/42/ee8e68a207b9127fcde6c3d7e197def432f346cb1af159e1fa14ca0d1cdc/ruff-0.16.5-py3-none-win_amd64.whl", hash = "sha256:ddc6385fb2137f616357ca03d6c74f4be987f80fed4008566b754f6032b8546f", size = 10516714, upload-time = "2026-08-27T16:34:13.963Z" },
{ url = "https://files.pythonhosted.org/packages/73/e3/7df5a396e445b9ba49ce9a9437439a4d80042c61c0ade199abf8d16de1ac/ruff-0.16.5-py3-none-win_arm64.whl", hash = "sha256:a64abe90968719b851bb7cedffaa8753fbdbdadab483089682db623f3edc587e", size = 10391564, upload-time = "2026-08-27T16:34:16.064Z" },
]
[[package]]
@@ -1411,7 +1411,7 @@ wheels = [
[[package]]
name = "selenium"
version = "4.47.0"
version = "4.48.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
@@ -1421,14 +1421,14 @@ dependencies = [
{ name = "urllib3", extra = ["socks"] },
{ name = "websocket-client" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c3/a2/213190a606bc036b4db1b8129f399964988872a555b50dfbfddf612d333c/selenium-4.47.0.tar.gz", hash = "sha256:4f6667c23080646e045fb91d2039687e88f549d667961f6ce85832b17384b68e", size = 1014095, upload-time = "2026-08-10T17:54:11.99Z" }
sdist = { url = "https://files.pythonhosted.org/packages/7f/8c/db97bdc1a8b41e7b6bf9d3099722ed8e7ac61328af8637f20004015c642b/selenium-4.48.0.tar.gz", hash = "sha256:045c1ec054c94e3be6c10febc509aa513b4c05e9146d1a9cf3de5375ec6ca2a1", size = 1055269, upload-time = "2026-08-27T20:05:51.235Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e4/0b/652575986d2ed03d29103d8574580a03aefe50d243d225b441e2375bd0f6/selenium-4.47.0-py3-none-any.whl", hash = "sha256:2eac6b8e7c017f57ecc40820383da8881a6fd7a90ea555c1b0af322f2344b347", size = 9511195, upload-time = "2026-08-10T17:54:09.369Z" },
{ url = "https://files.pythonhosted.org/packages/d3/ee/5d1b0e9cb43965902f0a9f7add527134db71cabf2f26766ae2fdb7774b9a/selenium-4.48.0-py3-none-any.whl", hash = "sha256:b2a1d77019db92513e59aa2376710fe3d42b65a4d493dccd0c799c1a0d574d93", size = 9561411, upload-time = "2026-08-27T20:05:48.778Z" },
]
[[package]]
name = "seleniumbase"
version = "4.52.2"
version = "4.53.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "attrs" },
@@ -1492,9 +1492,9 @@ dependencies = [
{ name = "wheel" },
{ name = "wsproto" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ef/0e/23b3f5232caf0cdadaa67b9e05ff83c9438839c20fe3161c5f0b98f19a8a/seleniumbase-4.52.2.tar.gz", hash = "sha256:261271b3c6d18d404acbe7b7efc661061ff02f0835cfc7cc6e9f52b13f1fa530", size = 677927, upload-time = "2026-08-23T23:36:25.076Z" }
sdist = { url = "https://files.pythonhosted.org/packages/0f/da/6521c25ce5498853a69cf01e98dd46ed9330b28be5a7b47d2757ea512129/seleniumbase-4.53.5.tar.gz", hash = "sha256:500c94bc86fb1c0f285aadaadf0332397eb4886e7b4fecc6808e8bbd02c4ab54", size = 690221, upload-time = "2026-09-02T05:34:51.789Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e9/36/d87a0f455bb80af763f99b0e3a1abea4f35700509338dbb80ce01d1a5422/seleniumbase-4.52.2-py3-none-any.whl", hash = "sha256:d7a7080767cf23ff9f4ace5589035f7eaf35f1ee9544dc038dbf0814f1a0701d", size = 682841, upload-time = "2026-08-23T23:36:21.471Z" },
{ url = "https://files.pythonhosted.org/packages/1b/03/cc31c8fb3cf096548c7716cfa6870481d7b85c4672f63b55ea301211acec/seleniumbase-4.53.5-py3-none-any.whl", hash = "sha256:0c0cdb16d56eb1f95ea73bd7428d0cdd8721f152e28a5aee164ef4b7c522e599", size = 695247, upload-time = "2026-09-02T05:34:49.366Z" },
]
[[package]]
@@ -1575,7 +1575,7 @@ requires-dist = [
{ name = "qbittorrent-api", specifier = ">=2026.8.1" },
{ name = "rarfile" },
{ name = "requests", extras = ["socks"] },
{ name = "seleniumbase", marker = "extra == 'browser'", specifier = "==4.52.2" },
{ name = "seleniumbase", marker = "extra == 'browser'", specifier = "==4.53.5" },
{ name = "tqdm" },
{ name = "transmission-rpc" },
]
@@ -1588,7 +1588,7 @@ dev = [
{ name = "pytest" },
{ name = "pytest-cov" },
{ name = "pytest-xdist", specifier = ">=3.8.0" },
{ name = "ruff", specifier = "==0.16.4" },
{ name = "ruff", specifier = "==0.16.5" },
{ name = "vulture", specifier = ">=2.14" },
]
@@ -1751,11 +1751,11 @@ wheels = [
[[package]]
name = "websocket-client"
version = "1.9.0"
version = "1.9.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/2c/41/aa4bf9664e4cda14c3b39865b12251e8e7d239f4cd0e3cc1b6c2ccde25c1/websocket_client-1.9.0.tar.gz", hash = "sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98", size = 70576, upload-time = "2025-10-07T21:16:36.495Z" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/cb/a5abcc2891249f393827c650c6296660ce40374ac22d99ab9aea41f9d2a2/websocket_client-1.9.2.tar.gz", hash = "sha256:0fcb57545848be86992e128218fd96dd87a6769ffdb1a968dff79632b85604d0", size = 84110, upload-time = "2026-08-31T14:08:40.964Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/34/db/b10e48aa8fff7407e67470363eac595018441cf32d5e1001567a7aeba5d2/websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef", size = 82616, upload-time = "2025-10-07T21:16:34.951Z" },
{ url = "https://files.pythonhosted.org/packages/d5/d2/cc4dc1271e464942db7ee278baae2daa99ee77cb2af744025c04da585a3e/websocket_client-1.9.2-py3-none-any.whl", hash = "sha256:e1a673830a9c7bfa47b1cd3d5e4178f4c9651d80a4eab02c9c23a1c3ec6250ce", size = 95786, upload-time = "2026-08-31T14:08:39.899Z" },
]
[[package]]