Compare commits

..
11 Commits
Author SHA1 Message Date
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
54 changed files with 2897 additions and 1119 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.52.4",
"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",
]
+20
View File
@@ -7,6 +7,7 @@ from typing import TYPE_CHECKING, Any
import requests
from shelfmark.bypass import BypassCancelledError
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
@@ -143,6 +144,25 @@ 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:
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,
)
try:
_store_solution_clearance(target_url, solution)
except AttributeError, KeyError, TypeError, ValueError:
+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:
+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,
+144 -15
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
@@ -556,13 +559,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 +568,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
@@ -615,6 +719,19 @@ 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(attempt_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
@@ -625,10 +742,6 @@ def _fetch_search_table(url: str, selector: network.AAMirrorSelector) -> tuple[s
"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 +2028,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"
)
+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
@@ -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
+48 -48
View File
@@ -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.5"
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/ea/06/cf1564dcc2e2261c8c8c6c05628dc8b418943bdae2a4e58640ceb2f770fa/platformdirs-4.11.5.tar.gz", hash = "sha256:e8b31f4f8bcbbedef91a6b57a706255e4f148d2a4e01648382a0a47342539173", size = 34823, upload-time = "2026-08-27T21:36:37.46Z" }
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/c7/12/6f3fcd5067a9cbf4f8664b32957973498da8b083455203c8d9cab83a725c/platformdirs-4.11.5-py3-none-any.whl", hash = "sha256:89f8d42695853b89c7170bd49bc3dc593f98a71e695ede88e06a3b247bc4563b", size = 23900, upload-time = "2026-08-27T21:36:36.227Z" },
]
[[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]]
@@ -1428,7 +1428,7 @@ wheels = [
[[package]]
name = "seleniumbase"
version = "4.52.2"
version = "4.52.4"
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/97/d8/ffc8d0090c678d200b2d6828c12d96127485b5fcd005e50c8e1057f1583b/seleniumbase-4.52.4.tar.gz", hash = "sha256:93bc048d63634c416f0e9a9326950f8c4b753fecea16fb95dd72f17217e4f22b", size = 678429, upload-time = "2026-08-25T18:25:34.2Z" }
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/f0/05/4fd503779d32495215d57ca2b1a41caa2e0078af4978504a7e5daeda7281/seleniumbase-4.52.4-py3-none-any.whl", hash = "sha256:1cde15f47bd71deefe6d8ea6318a7864ebda1f970861c736211c830aa3f28d20", size = 683214, upload-time = "2026-08-25T18:25:31.277Z" },
]
[[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.52.4" },
{ 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" },
]