Compare commits

...
54 Commits
Author SHA1 Message Date
CaliBrain 2c6d6a02cd ci: debounce dev image builds instead of building nightly (#1376)
Replace the nightly cron and its check-changes job with a debounce.
Every push to main starts dev-image-debounce.yml, which waits out the
60-minute wait timer on the dev-image-debounce environment, then
dispatches the Docker workflow only if main still points at its commit.
A burst of merges now publishes one dev image, an hour after the last
merge.

The Docker workflow keeps only its tag and workflow_dispatch triggers,
so its history holds real builds only. The debounce workflow deletes its
own finished runs, so no-op runs don't pile up either.

Requires the dev-image-debounce environment with a 60-minute wait
timer (Settings → Environments).
2026-09-21 02:38:06 -04:00
dependabot[bot] 4a0675e0d3 build(deps): bump the python-deps group with 4 updates (#1375)
Bumps the python-deps group with 4 updates:
[gevent](https://github.com/gevent/gevent),
[emoji](https://github.com/carpedm20/emoji),
[seleniumbase](https://github.com/seleniumbase/SeleniumBase) and
[ruff](https://github.com/astral-sh/ruff).

Updates `gevent` from 26.8.0 to 26.9.0
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/gevent/gevent/commit/003c77a6f3da41c014dd415022fb61f9d91fc95d"><code>003c77a</code></a>
Preparing release 26.9.0</li>
<li><a
href="https://github.com/gevent/gevent/commit/725ecc3e53dd74f3828f1cf189e1cbeaebbdfae9"><code>725ecc3</code></a>
Merge pull request <a
href="https://redirect.github.com/gevent/gevent/issues/2209">#2209</a>
from bojanz/issue2207-resolve-result-on-setup-failure</li>
<li><a
href="https://github.com/gevent/gevent/commit/a5b68e812e8ddf65d13dfa117d8656fcb713d69b"><code>a5b68e8</code></a>
Adjust the ThreadPool after an unexpected worker exit</li>
<li><a
href="https://github.com/gevent/gevent/commit/67a725659bd622ac476bd39019209e11a1f18f2b"><code>67a7256</code></a>
Resolve the ThreadResult when worker task setup fails</li>
<li><a
href="https://github.com/gevent/gevent/commit/a3b307bb82cd9b875933a964e883015bf0ce18b2"><code>a3b307b</code></a>
Add change note for <a
href="https://redirect.github.com/gevent/gevent/issues/2211">#2211</a> /
<a
href="https://redirect.github.com/gevent/gevent/issues/2039">#2039</a>
[skip ci]</li>
<li><a
href="https://github.com/gevent/gevent/commit/b0ec8d3b4cb7cde5799dc57d35f74267e3b8259a"><code>b0ec8d3</code></a>
Merge pull request <a
href="https://redirect.github.com/gevent/gevent/issues/2211">#2211</a>
from afonsojanu/fix/subprocess-stdin-mode-missing-bi...</li>
<li><a
href="https://github.com/gevent/gevent/commit/29b00308c74a703e26178229f6edacf98a31a1cb"><code>29b0030</code></a>
Merge pull request <a
href="https://redirect.github.com/gevent/gevent/issues/2210">#2210</a>
from Shivakarthikeya23/issue-1946</li>
<li><a
href="https://github.com/gevent/gevent/commit/47ea41c7e2bc8ca50785a60e929e7335c75fc308"><code>47ea41c</code></a>
Merge pull request <a
href="https://redirect.github.com/gevent/gevent/issues/2208">#2208</a>
from bojanz/issue2206-skip-missing-hooks</li>
<li><a
href="https://github.com/gevent/gevent/commit/0ee0c838cf50255c40647109de5391f84630af6a"><code>0ee0c83</code></a>
Report the full binary mode string from FileObjectPosix</li>
<li><a
href="https://github.com/gevent/gevent/commit/3f3dc574d2b1e5381ea70bfea0da565fd4fcd6c8"><code>3f3dc57</code></a>
Fix AsyncResult accumulating traceback frames on repeated get()</li>
<li>Additional commits viewable in <a
href="https://github.com/gevent/gevent/compare/26.8.0...26.9.0">compare
view</a></li>
</ul>
</details>
<br />

Updates `emoji` from 2.15.0 to 2.16.0
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/carpedm20/emoji/releases">emoji's
releases</a>.</em></p>
<blockquote>
<h2>v2.16.0</h2>
<ul>
<li>Update to Unicode 18.0</li>
<li>Update translations to CLDR release-49-alpha2</li>
</ul>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/carpedm20/emoji/blob/master/CHANGES.md">emoji's
changelog</a>.</em></p>
<blockquote>
<h2>v2.16.0 (2026-09-16)</h2>
<ul>
<li>Update to Unicode 18.0</li>
<li>Update translations to CLDR release-49-alpha2</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/carpedm20/emoji/commit/d5d59d591f8590ada989f0bdf31603b3fe84e103"><code>d5d59d5</code></a>
Merge pull request <a
href="https://redirect.github.com/carpedm20/emoji/issues/335">#335</a>
from carpedm20/update-unicode-18.0</li>
<li><a
href="https://github.com/carpedm20/emoji/commit/7acc7f0740b0ded33c7450d788c6641b74e84e16"><code>7acc7f0</code></a>
update to Unicode 18.0.0</li>
<li>See full diff in <a
href="https://github.com/carpedm20/emoji/compare/v2.15.0...v2.16.0">compare
view</a></li>
</ul>
</details>
<br />

Updates `seleniumbase` from 4.54.5 to 4.54.9
<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.54.9 - MCP Server: Patch 17</h2>
<h2>MCP Server: Patch 17</h2>
<ul>
<li><a
href="https://redirect.github.com/seleniumbase/SeleniumBase/pull/4508/changes/e323a9d851b58a99b8b55b041218f415652a9fd6">Update
the MCP server</a></li>
<li><a
href="https://redirect.github.com/seleniumbase/SeleniumBase/pull/4508/commits/bc88accb2909629f7b0af68be73bebf014c17373">Refresh
Python dependencies</a></li>
</ul>
<h2>What's Changed</h2>
<ul>
<li>MCP Server: Patch 17 by <a
href="https://github.com/mdmintz"><code>@​mdmintz</code></a> in <a
href="https://redirect.github.com/seleniumbase/SeleniumBase/pull/4508">seleniumbase/SeleniumBase#4508</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/seleniumbase/SeleniumBase/compare/v4.54.8...v4.54.9">https://github.com/seleniumbase/SeleniumBase/compare/v4.54.8...v4.54.9</a></p>
<h2>4.54.8 - Fix command injection and more</h2>
<h2>Fix command injection and more</h2>
<ul>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/7c8744798ed97616fe246effe982b93b0cc1d15e">Fix
command injection in console scripts</a></li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/7c9871c812bf410164b3a6ed14aec9b9cfe2abd3">Fix
issue with clearing a field that has autocomplete</a></li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/969a36c19fb3a05eec50821ad6be63805a68e21a">Fix
typos in behave_sb.py</a></li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/47034f13729e9f24564c18ba1952b1307798f080">Refactor
console scripts</a></li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/f7da99c23471a0f0a1101b0afd9a0a82d38a99ba">Update
the MCP server</a></li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/4bc64fb41dcd6ea6030887456fe97de72a3443a7">Refresh
Python dependencies</a></li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/c1b492a650c8077ae6ef43915b3a455fab8f4e8d">Update
examples</a></li>
</ul>
<h2>What's Changed</h2>
<ul>
<li>Fix command injection and more by <a
href="https://github.com/mdmintz"><code>@​mdmintz</code></a> in <a
href="https://redirect.github.com/seleniumbase/SeleniumBase/pull/4507">seleniumbase/SeleniumBase#4507</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/seleniumbase/SeleniumBase/compare/v4.54.7...v4.54.8">https://github.com/seleniumbase/SeleniumBase/compare/v4.54.7...v4.54.8</a></p>
<h2>4.54.7 - MCP Server: Patch 16</h2>
<h2>MCP Server: Patch 16</h2>
<ul>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/534c0c124002bfb48e10a7198194c91c61f4d4db">Update
the MCP server</a>
--&gt; annotations and docstrings</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/4a5453ee14dff60599b058669eaff025488b75d2">Refresh
Python dependencies</a></li>
</ul>
<h2>What's Changed</h2>
<ul>
<li>MCP Server: Patch 16 by <a
href="https://github.com/mdmintz"><code>@​mdmintz</code></a> in <a
href="https://redirect.github.com/seleniumbase/SeleniumBase/pull/4505">seleniumbase/SeleniumBase#4505</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/seleniumbase/SeleniumBase/compare/v4.54.6...v4.54.7">https://github.com/seleniumbase/SeleniumBase/compare/v4.54.6...v4.54.7</a></p>
<h2>4.54.6 - MCP Server: Patch 15</h2>
<h2>MCP Server: Patch 15</h2>
<ul>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/fbc665f3cb19ea6e5eb4ed6ffcf4eec32e4d9580">Add
tool annotations to the MCP Server</a>
--&gt; This resolves <a
href="https://redirect.github.com/seleniumbase/SeleniumBase/issues/4503">seleniumbase/SeleniumBase#4503</a></li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/a406b7f277de3a56dc3d83d4b5466dbca34a35e4">Update
the Dockerfile</a></li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/8c9805f870bd1a2a0bb9b8ef79e710848a9e81f2">Update
CDP Mode</a></li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/7f488faed38e8afce01d18b30291906e75dfe72d">Update
uv</a></li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/b4910211a8e801eebab9f0b1a6679bf1077ac53f">Update
CDP Mode examples</a></li>
</ul>
<h2>What's Changed</h2>
<ul>
<li>MCP Server: Patch 15 by <a
href="https://github.com/mdmintz"><code>@​mdmintz</code></a> in <a
href="https://redirect.github.com/seleniumbase/SeleniumBase/pull/4504">seleniumbase/SeleniumBase#4504</a></li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/ddd0f047e522c584221176bccf078784cac03b00"><code>ddd0f04</code></a>
Merge pull request <a
href="https://redirect.github.com/seleniumbase/SeleniumBase/issues/4508">#4508</a>
from seleniumbase/mcp-server-patch-17</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/918078082a49b2a57f3a33fc9a4149973f780e64"><code>9180780</code></a>
Version 4.54.9</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/bc88accb2909629f7b0af68be73bebf014c17373"><code>bc88acc</code></a>
Refresh Python dependencies</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/d10f01b50168d62aea34bdc756ae249f75fdf642"><code>d10f01b</code></a>
Update MCP versioning</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/e323a9d851b58a99b8b55b041218f415652a9fd6"><code>e323a9d</code></a>
Update the MCP server</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/52ceddcfbc466c937f88b322cfa69f11600b1e0a"><code>52ceddc</code></a>
Merge pull request <a
href="https://redirect.github.com/seleniumbase/SeleniumBase/issues/4507">#4507</a>
from seleniumbase/fix-command-injection-and-more</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/1b18c10456cc064ce38e7024913b285b3053197a"><code>1b18c10</code></a>
Version 4.54.8</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/c1b492a650c8077ae6ef43915b3a455fab8f4e8d"><code>c1b492a</code></a>
Update examples</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/a64c668254f20c3384c9f0ec1635de7e9eba45ce"><code>a64c668</code></a>
Update MCP versioning</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/4bc64fb41dcd6ea6030887456fe97de72a3443a7"><code>4bc64fb</code></a>
Refresh Python dependencies</li>
<li>Additional commits viewable in <a
href="https://github.com/seleniumbase/SeleniumBase/compare/v4.54.5...v4.54.9">compare
view</a></li>
</ul>
</details>
<br />

Updates `ruff` from 0.16.7 to 0.16.8
<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.8</h2>
<h2>Release Notes</h2>
<p>Released on 2026-09-16.</p>
<h3>Bug fixes</h3>
<ul>
<li>Visit functional <code>TypedDict</code> keyword arguments correctly
(<a
href="https://redirect.github.com/astral-sh/ruff/pull/28584">#28584</a>)</li>
<li>[<code>flake8-simplify</code>] Detect nested <code>async with</code>
under sync parent (<code>SIM117</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/27821">#27821</a>)</li>
<li>[<code>flake8-simplify</code>] Preserve operand order in
<code>SIM109</code> fix (<a
href="https://redirect.github.com/astral-sh/ruff/pull/27824">#27824</a>)</li>
<li>[<code>pyupgrade</code>] Preserve required parentheses in multiline
<code>UP040</code> fixes (<a
href="https://redirect.github.com/astral-sh/ruff/pull/28164">#28164</a>)</li>
<li>[<code>pyupgrade</code>] Skip <code>TypeVarTuple</code> and
<code>ParamSpec</code> conversions with bounds or constraints
(<code>UP040</code>, <code>UP046</code>, <code>UP047</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/28505">#28505</a>)</li>
</ul>
<h3>Rule changes</h3>
<ul>
<li>Add support for <code>__lazy_modules__</code> (<a
href="https://redirect.github.com/astral-sh/ruff/pull/28459">#28459</a>)</li>
<li>Recognize PEP-728 <code>TypedDict</code> class keywords (<a
href="https://redirect.github.com/astral-sh/ruff/pull/28533">#28533</a>)</li>
<li>Recognize quoted types in <code>typing.TypeForm</code> (<a
href="https://redirect.github.com/astral-sh/ruff/pull/28507">#28507</a>)</li>
<li>Support conditional assignment to <code>__lazy_modules__</code> (<a
href="https://redirect.github.com/astral-sh/ruff/pull/28491">#28491</a>)</li>
<li>[<code>flake8-type-checking</code>] Prefer lazy imports over
<code>TYPE_CHECKING</code> on Python 3.15 and later (<code>TC001</code>,
<code>TC002</code>, <code>TC003</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/28541">#28541</a>)</li>
<li>[<code>pyupgrade</code>] Make the fix for <code>UP040</code> always
unsafe (<a
href="https://redirect.github.com/astral-sh/ruff/pull/28526">#28526</a>)</li>
<li>[<code>pyupgrade</code>] Stop recommending deprecated
<code>ByteString</code> aliases (<code>UP035</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/28498">#28498</a>)</li>
<li>[<code>ruff</code>, <code>flake8-use-pathlib</code>] Recognize the
<code>parent_mode</code> argument (<code>RUF064</code>,
<code>PTH103</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/28528">#28528</a>)</li>
<li>[<code>ruff</code>] Detect <code>\Z</code> in
<code>pytest.raises()</code> match patterns (<code>RUF043</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/28598">#28598</a>)</li>
</ul>
<h3>CLI</h3>
<ul>
<li>Use rule name and code in formatter incompatibility warnings (<a
href="https://redirect.github.com/astral-sh/ruff/pull/28571">#28571</a>)</li>
</ul>
<h3>Configuration</h3>
<ul>
<li>[<code>flake8-tidy-imports</code>] Add
<code>extend-banned-api</code> (<a
href="https://redirect.github.com/astral-sh/ruff/pull/28644">#28644</a>)</li>
</ul>
<h3>Contributors</h3>
<ul>
<li><a
href="https://github.com/VedantMadane"><code>@​VedantMadane</code></a></li>
<li><a href="https://github.com/alzeph"><code>@​alzeph</code></a></li>
<li><a
href="https://github.com/charliermarsh"><code>@​charliermarsh</code></a></li>
<li><a
href="https://github.com/fredrikblau"><code>@​fredrikblau</code></a></li>
<li><a href="https://github.com/ntBre"><code>@​ntBre</code></a></li>
<li><a href="https://github.com/zsol"><code>@​zsol</code></a></li>
<li><a
href="https://github.com/Aniket-a14"><code>@​Aniket-a14</code></a></li>
<li><a href="https://github.com/r-b-1"><code>@​r-b-1</code></a></li>
</ul>
<h2>Install ruff 0.16.8</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.8/ruff-installer.sh
| sh
&lt;/tr&gt;&lt;/table&gt; 
</code></pre>
</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.8</h2>
<p>Released on 2026-09-16.</p>
<h3>Bug fixes</h3>
<ul>
<li>Visit functional <code>TypedDict</code> keyword arguments correctly
(<a
href="https://redirect.github.com/astral-sh/ruff/pull/28584">#28584</a>)</li>
<li>[<code>flake8-simplify</code>] Detect nested <code>async with</code>
under sync parent (<code>SIM117</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/27821">#27821</a>)</li>
<li>[<code>flake8-simplify</code>] Preserve operand order in
<code>SIM109</code> fix (<a
href="https://redirect.github.com/astral-sh/ruff/pull/27824">#27824</a>)</li>
<li>[<code>pyupgrade</code>] Preserve required parentheses in multiline
<code>UP040</code> fixes (<a
href="https://redirect.github.com/astral-sh/ruff/pull/28164">#28164</a>)</li>
<li>[<code>pyupgrade</code>] Skip <code>TypeVarTuple</code> and
<code>ParamSpec</code> conversions with bounds or constraints
(<code>UP040</code>, <code>UP046</code>, <code>UP047</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/28505">#28505</a>)</li>
</ul>
<h3>Rule changes</h3>
<ul>
<li>Add support for <code>__lazy_modules__</code> (<a
href="https://redirect.github.com/astral-sh/ruff/pull/28459">#28459</a>)</li>
<li>Recognize PEP-728 <code>TypedDict</code> class keywords (<a
href="https://redirect.github.com/astral-sh/ruff/pull/28533">#28533</a>)</li>
<li>Recognize quoted types in <code>typing.TypeForm</code> (<a
href="https://redirect.github.com/astral-sh/ruff/pull/28507">#28507</a>)</li>
<li>Support conditional assignment to <code>__lazy_modules__</code> (<a
href="https://redirect.github.com/astral-sh/ruff/pull/28491">#28491</a>)</li>
<li>[<code>flake8-type-checking</code>] Prefer lazy imports over
<code>TYPE_CHECKING</code> on Python 3.15 and later (<code>TC001</code>,
<code>TC002</code>, <code>TC003</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/28541">#28541</a>)</li>
<li>[<code>pyupgrade</code>] Make the fix for <code>UP040</code> always
unsafe (<a
href="https://redirect.github.com/astral-sh/ruff/pull/28526">#28526</a>)</li>
<li>[<code>pyupgrade</code>] Stop recommending deprecated
<code>ByteString</code> aliases (<code>UP035</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/28498">#28498</a>)</li>
<li>[<code>ruff</code>, <code>flake8-use-pathlib</code>] Recognize the
<code>parent_mode</code> argument (<code>RUF064</code>,
<code>PTH103</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/28528">#28528</a>)</li>
<li>[<code>ruff</code>] Detect <code>\Z</code> in
<code>pytest.raises()</code> match patterns (<code>RUF043</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/28598">#28598</a>)</li>
</ul>
<h3>CLI</h3>
<ul>
<li>Use rule name and code in formatter incompatibility warnings (<a
href="https://redirect.github.com/astral-sh/ruff/pull/28571">#28571</a>)</li>
</ul>
<h3>Configuration</h3>
<ul>
<li>[<code>flake8-tidy-imports</code>] Add
<code>extend-banned-api</code> (<a
href="https://redirect.github.com/astral-sh/ruff/pull/28644">#28644</a>)</li>
</ul>
<h3>Contributors</h3>
<ul>
<li><a
href="https://github.com/VedantMadane"><code>@​VedantMadane</code></a></li>
<li><a href="https://github.com/alzeph"><code>@​alzeph</code></a></li>
<li><a
href="https://github.com/charliermarsh"><code>@​charliermarsh</code></a></li>
<li><a
href="https://github.com/fredrikblau"><code>@​fredrikblau</code></a></li>
<li><a href="https://github.com/ntBre"><code>@​ntBre</code></a></li>
<li><a href="https://github.com/zsol"><code>@​zsol</code></a></li>
<li><a
href="https://github.com/Aniket-a14"><code>@​Aniket-a14</code></a></li>
<li><a href="https://github.com/r-b-1"><code>@​r-b-1</code></a></li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/astral-sh/ruff/commit/62914c4b9b79a9e5004374a9c482ad2ed69290e1"><code>62914c4</code></a>
Bump version to 0.16.8 (<a
href="https://redirect.github.com/astral-sh/ruff/issues/28648">#28648</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/c47e0cdc665f56536ce7f7a8ac40fa0ff3f79482"><code>c47e0cd</code></a>
[ty] Bound aliased intersection expansion during inference (<a
href="https://redirect.github.com/astral-sh/ruff/issues/28546">#28546</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/ff4747b509ab4fffbe5689fcae39caa7503d1dcf"><code>ff4747b</code></a>
renovate: update uv hashes correctly with setup-uv (<a
href="https://redirect.github.com/astral-sh/ruff/issues/28621">#28621</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/94efeaa28630d80b2a74adf3c3963de99ed4ee29"><code>94efeaa</code></a>
[ty] Compact reachable binding and declaration histories (<a
href="https://redirect.github.com/astral-sh/ruff/issues/28349">#28349</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/50020fb1e8aa83b0826fa6f5f33a1e93c10cc60e"><code>50020fb</code></a>
[ty] Avoid storing constraint nodes twice (<a
href="https://redirect.github.com/astral-sh/ruff/issues/28375">#28375</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/446bb68da50014bb75f5ce1504a80c5883e3b0b2"><code>446bb68</code></a>
[ty] Compare bound-method receivers before signatures (<a
href="https://redirect.github.com/astral-sh/ruff/issues/28384">#28384</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/304ab86be5de6507e276ab09f5b43f44aeb92469"><code>304ab86</code></a>
[<code>flake8-type-checking</code>] Prefer lazy imports over
<code>TYPE_CHECKING</code> on 3.15+ (`...</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/d940b244f7512427b0d87c7953e88c60e69f9bdf"><code>d940b24</code></a>
[ty] Watch script dependencies in CLI watch mode (<a
href="https://redirect.github.com/astral-sh/ruff/issues/28125">#28125</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/fe9f065a504127b11da72c2ff6d7813ddf3ce8ac"><code>fe9f065</code></a>
[flake8-tidy-imports] Add <code>extend-banned-api</code> (<a
href="https://redirect.github.com/astral-sh/ruff/issues/28644">#28644</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/31131db44f057cce68fa6b95552b7db54167b0b3"><code>31131db</code></a>
[ty] Support <code>type[A &amp; B]</code> (<a
href="https://redirect.github.com/astral-sh/ruff/issues/27124">#27124</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/astral-sh/ruff/compare/0.16.7...0.16.8">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-21 00:44:08 -04:00
CaliBrain e1c3f057ab fix: bypass recordings, welib wrong-md5 links, footer build sha (#1364) (#1373)
Debug screen recordings never started. Every bypass logged "Capturearea
1540x1050 at position 0.0 outside the screen size 1440x1880".We ask
ffmpeg for the fingerprint screen size plus margin, the size wealso pass
SeleniumBase as xvfb_metrics. SeleniumBase builds thatdisplay with
use_xauth=True, the image ships no xauth binary, so itfalls back to a
fixed 1440x1880 Xvfb and the requested size neverexists. Drop
-video_size so x11grab records the whole screen, whateversize it turned
out to be.

welib could hand back a link for a different book. Welib
answers/md5/<md5> with a search for that md5; when it does not have the
file,the resolver took the first "Download" on the results page
(md5a2c1dc0c... resolved to auto_download/9c8cf85d...). On an
/md5/<md5>page a GET/Download link is now only taken when its href names
thatmd5; otherwise the source is reported as not having the file.
Alsoremoves _get_download_urls_from_welib and _is_source_enabled:
themd5-template branch in _get_urls_for_source always handles welib
first,so that resolver could never run.

The footer showed the build date instead of the commit. CI
stampsBUILD_VERSION as <yyyy-mm-dd>-<sha> (pr-<sha> for PR images) and
thefooter kept its first seven characters, so dev images read
"Shelfmarkmain (2026-09)". Take the trailing commit sha instead: "main
(1a5b37d)".The full BUILD_VERSION stays in the hover title
2026-09-21 00:15:41 -04:00
CaliBrain d978896142 fix(auth): rename the API_KEY env var to SHELFMARK_API_KEY (#1374) 2026-09-21 00:10:10 -04:00
Gavin McFallandClaude Fable 5.1 3b280009ae feat(auth): static API_KEY (env) accepted as Bearer or X-Api-Key, cookie or key (#1366)
Supersedes #1353, per the discussion in #1352: one `API_KEY` environment
variable; when set, a request carrying it is authenticated as the first
admin, and cookie sessions keep working exactly as before (cookie **or**
key). Nothing else changes. No table, no UI, no settings-tab switch, no
per-user keys.

## What

- `API_KEY` (env). Unset → the feature is off and none of the new code
runs.
- `Authorization: Bearer <key>` or `X-Api-Key: <key>` on any existing
`/api/*` route authenticates that request as the first admin in
`users.db` (`ORDER BY id`), or as a bare admin identity
(`user_id="api"`, `is_admin=True`, no local user row) if the install has
no admin yet. Per request only; nothing is persisted; the admin's role
is read live, so deleting or demoting that user takes effect on the next
request.
- Both headers are checked and either may match. That is what makes the
key usable behind a reverse proxy that injects its own `Authorization`
header (oauth2-proxy, Authelia, forwardAuth): send the key in
`X-Api-Key`.
- A credential that is **not** the key is ignored and the request
continues on the normal session path, so proxy-forwarded tokens are
unaffected. Without a valid session such a request gets the usual `401
{"error": "Unauthorized"}`, identical to a request with no credential,
so there is nothing to probe.

## How

- `shelfmark/config/env.py`: `API_KEY = os.getenv("API_KEY",
"").strip()`.
- `shelfmark/core/api_key.py`: `extract_api_key_candidates()` (Bearer
token if the scheme is Bearer, then `X-Api-Key`) and `matches_api_key()`
using `hmac.compare_digest` on bytes.
- `shelfmark/core/user_db.py`: `UserDB.get_first_admin()`.
- `shelfmark/main.py`: `api_key_auth_middleware` (`before_request`,
registered before `proxy_auth_middleware`, which early-returns for keyed
requests). Only `/api/` paths; `/api/health` and `/api/auth/*` exempt;
no-op when `API_KEY` is unset or the auth mode is `none`. On a match it
mirrors the proxy-auth pattern: `session.clear()` then populate
`user_id` / `is_admin` / `db_user_id` for this request, `permanent =
False`, `modified = False`, `g.api_key_auth = True`. An `after_request`
hook guarantees no `Set-Cookie` is written for a keyed request even if a
handler dirties the session.
- `docs/api-access.md` (new), the `API_KEY` entry in
`docs/environment-variables.md`, and a README link.

## Security

- Constant-time compare; the key is never logged or echoed.
- Keyed requests never mint or refresh a session cookie and ignore any
cookie sent with them (a non-admin cookie plus the key yields admin for
that request; the browser's own session is left untouched and usable).
- The mismatch path touches neither the session nor `g`, so a stray
bearer on a browser request can neither log the user out nor change how
their cookie is refreshed.
- Store errors during the admin lookup fail closed (`500 {"error":
"Authentication error"}`), never to anonymous.
- Verified against Flask's `save_session` / `should_set_cookie`
ordering, and under auth modes `none`, `builtin`, `proxy`.

## Tests

`tests/core/test_api_key_env.py` (36): extraction and matching;
first-admin lookup; middleware behaviour on a guarded route and an admin
route, with and without a user_db, `X-Api-Key`, both-headers
combinations, no `Set-Cookie` when a handler dirties the session,
incoming non-admin cookie ignored, browser cookie still usable after a
keyed request, security headers, store error → 500, mismatch → guard's
401 / cookie path / permanent cookie untouched, unset → off, exempt
paths and path probes, `none` and `proxy` modes, deleted and demoted
first admin, a keyed write passing the guard. Existing auth suites
unchanged. All CI gates green on the fork:
https://github.com/gavinmcfall/shelfmark/pull/2 (CI-only draft).

Also exercised against a running instance: 47 scripted checks including
150 concurrent requests, proxy-mode switching through the key, an
unset-key restart, and a log scan for the key.

## Naming

`API_KEY` as discussed. If you'd rather namespace it
(`SHELFMARK_API_KEY`) to avoid clashing with other tools' env vars in
shared compose files, it is a one-line change; say the word.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 00:00:42 -04:00
splitsec2 1a5b37d9d3 fix(deluge): send seeding ratio limit under Deluge's own keys (#1367)
Deluge's per-torrent options are `stop_at_ratio` (bool) and `stop_ratio`
(float), and `torrentmanager` checks `options['stop_at_ratio'] and
get_ratio() >= options['stop_ratio']`. We were putting the indexer's
float into `stop_at_ratio`, which only switched stopping on and left the
daemon's global ratio (default 2.0) as the one actually enforced. A
`ratio_limit` of 0 turned stopping off entirely. `stop_at_ratio_enabled`
is not a Deluge option at all.

Deluge has no per-torrent seeding time limit, `seed_time_limit` is a
global core preference, so the value is logged as unapplied instead of
sent as a key the daemon drops.

qBittorrent and Transmission already honour both indexer limits, so this
removes a silent difference between clients.

## Verification

- `tests/prowlarr/test_deluge_client.py`: the ratio arrives as
`stop_ratio` with `stop_at_ratio` set, and no key Deluge does not define
is sent. Both fail on current main and pass here.
- Full suite (3165), ruff, ruff format, basedpyright, vulture green.
2026-09-20 23:06:14 -04:00
splitsec2 7c8e89c567 fix(googlebooks): page by the capped size, not the raw limit (#1370)
The Google Books search builds `maxResults` as `min(limit, 40)` because
the API caps a page at 40 volumes, but advances `startIndex` by the full
`limit`. The pages then stop tiling. With `limit=50`, page 1 covers
items 0 to 39 and page 2 starts at 50, so items 40 to 49 are never
returned and every later page drops another 10.

This computes the page size once and uses it for both `maxResults` and
`startIndex`. The shipped frontend asks for 40 and is unaffected.
`/api/metadata/search` clamps `limit` to 100, so an API caller passing
41 to 100 was hitting it.

One thing I left alone. The provider uses the base `search_paginated`
heuristic, `has_more = len(books) >= options.limit`, which still reports
`has_more: false` for a limit above 40 since Google can never return
that many. That was already the behaviour before this change, and fixing
it means either touching the shared heuristic or adding a provider
override, so I kept this patch to the stride. Happy to follow up if you
want it.

## Verification

- `tests/metadata/test_googlebooks_parse.py`: pages 1 and 2 at
`limit=50` must tile exactly, plus a guard that `limit=25` still strides
by 25. The first fails on current main and passes here.
- Full suite (3165), ruff, ruff format, basedpyright, vulture green.
2026-09-20 23:05:48 -04:00
splitsec2 bc03ad062e fix(http): keep the host of a protocol-relative download link (#1368)
`get_absolute_url()` replaced both `netloc` and `scheme` whenever either
one was missing. A protocol-relative href such as
`//cdn.example.org/f.epub`, scraped from a page on
`https://annas-archive.org/...`, parses with a netloc and an empty
scheme, so it came back pointing at the page's own host. The download
then 404s and the source is skipped.

Each field now falls back to the base URL only when the parsed URL does
not supply it. Plain relative paths resolve exactly as before, which the
control test covers.

This affects the Z-Library, welib and generic download link handling in
`release_sources/direct_download/annas_archive.py`.

## Verification

- New `tests/download/test_http_absolute_url.py`: a protocol-relative
link keeps its own host, and a plain `/path` still resolves against the
base. The first fails on current main and passes here.
- Full suite (3165), ruff, ruff format, basedpyright, vulture green.
2026-09-20 23:04:22 -04:00
splitsec2 ab3aa9a8b0 fix(requests): reject non-object items in the batch endpoint (#1369)
`POST /api/requests/batch` checks that `requests` is a non-empty list
and then hands each element to the shared preparation helper, which
calls `.get()` on it. A bare string, number or null in the list raises
`AttributeError` and the caller gets a 500, while `POST /api/requests`
answers 400 with a message for the same mistake.

This validates the element type beside the existing list check. One bad
item rejects the whole batch rather than being reported per item, which
matches the endpoint's current contract: every other failure path
already aborts the batch with a single error body, as
`test_batch_create_requests_is_atomic` asserts. Responses for valid
payloads are unchanged.

## Verification

- `tests/core/test_request_routes_api.py`: a case per bad shape (int,
str, null, list), plus a mixed valid and invalid batch that asserts
nothing was created. All fail on current main with a 500 and pass here.
- Full suite (3168), ruff, ruff format, basedpyright, vulture green.
2026-09-20 23:03:31 -04:00
Paul Rimmer d4619be69a Feature: Show the AA search result stats (#1362)
When doing a direct search for a book, show the stats of the AA results.
For example if we search for "The Great Gatsby", AA reports it has 240
hits and shows the first page of 50. Provide this stats info in the
shelfmark webUI via ResultsSection.tsx and ReleaseModal.tsx. This table
shows what should be displayed based on the total number of hits found:

|Total	|Display|
|-------|-------|
|1	|"Result 1 (1 Total)"|
|6	|"Results 1-6 (6 Total)"|
|144	|"Results 1-50 (144 Total)"|
|500+	|"Results 1-50 (500+ Total)"|

Currently, shelfmark also only shows us the first 50 search hits even if
there were more available from AA. This could be added later if
considered desirable.

As usual, a picture is worth a 1000 words:

<img width="1012" height="610" alt="direct-results-stat"
src="https://github.com/user-attachments/assets/8c0c688f-444e-482a-a9a3-2dee5a5563b8"
/>

<img width="1013" height="741" alt="universal-results-info"
src="https://github.com/user-attachments/assets/399bbb79-87f2-4432-a3d0-64937795f5f1"
/>

Coded with llama.cpp, opencode and 🤖
2026-09-20 13:03:32 -04:00
splitsec2 c1315a2b23 fix(download): check task ownership before serving queued files (#1357)
`/api/localdownload` resolves the file through the live queue and
returns it before checking who owns the task; the owner check only runs
on the download-history fallback, once the task has aged out of the
queue. Task ids are source ids, so two users who searched the same book
can end up with the same id.

This applies the same rule on the queue path, reusing the
`_task_owned_by_actor` helper the cancel/retry/priority routes already
use, so both paths answer a non-owner with the same 404. Admin behaviour
is unchanged.

The 404 matches what this endpoint's history path already returns for a
non-owner rather than the 403 `download_not_owned` the
cancel/retry/priority routes use, happy to switch it if you prefer
consistency with the siblings instead.

## Verification

- `tests/core/test_activity_routes_api.py`: the owner still receives
their queued file; a different user receives 404. The new case fails on
current main and passes here; the existing history-fallback test is
unchanged.
- Full suite (3148), ruff, ruff format, basedpyright, vulture green.
2026-09-20 12:56:23 -04:00
splitsec2 7934924678 fix(queue): don't stamp CANCELLED over a finished download (#1361)
`cancel_download` reads the task status under the queue lock, releases
it, and only then writes CANCELLED through `update_status`. A download
that finishes in that window has its COMPLETE overwritten. The queue and
the UI show the task as cancelled while the file is already on disk, and
the terminal hook fires for both statuses.

The check and the write now happen in a single lock hold. Because the
lock is non-reentrant and the terminal hook has to run after it is
released (the stall canceller depends on that), the lock-held part of
`update_status` moved into a small private helper that both paths share;
`update_status` is a thin wrapper over it. A cancel arriving once the
task is already terminal still returns `False`.

## Verification

-
`tests/core/test_queue.py::test_cancel_does_not_overwrite_a_download_that_finished_first`:
a worker thread completes the download while the cancel is in flight,
with the handover driven by events rather than sleeps. The task stays
complete. Fails on main, passes here.
- Full suite (3147), plus `tests/download/` and
`tests/core/test_download_api_guardrails.py`, ruff, ruff format,
basedpyright, vulture green.
2026-09-20 12:54:05 -04:00
splitsec2 a6204a318e fix(oidc): reject backslash paths in the return_to sanitizer (#1359)
The OIDC `return_to` sanitizer rejects values starting with `//` and
then relies on `urlsplit` to catch anything carrying a netloc. A value
such as `/\host` has no netloc, so it is stored in the session and used
as the post-login redirect target and browsers resolve the backslash as
a path separator, which lands the user outside the app after a
successful login.

`_normalize_return_to` now also rejects values whose path contains a
backslash. That matches the frontend sanitizer in `authRedirect.ts`,
which parses with `URL` and already discards those forms, so the two
ends agree again. The check covers the path only, so query and fragment
backslashes still round-trip, and it also catches the script-root case
where `/app/\host` strips to `/\host`.

## Verification

- New cases in `tests/core/test_oidc_routes.py` cover the rejected
forms, including under a script root, and confirm `/`, `/settings` and
`/search?q=x#frag` are unaffected. They fail on current main and pass
here.
- Full suite (3155), ruff, ruff format, basedpyright, vulture green.
2026-09-20 12:51:21 -04:00
splitsec2 545480c557 fix(download): default is_admin to False in the request policy guard (#1358)
`_resolve_policy_mode_for_current_user` reads `session.get("is_admin",
True)`, so a session carrying `user_id` but no `is_admin` key skips the
request policy entirely, while every other admin check in the codebase
defaults the key to `False`.

This uses the same default here. Every authenticated login path
(builtin, CWA, proxy, OIDC) writes `is_admin` into the session, and
`AUTH_METHOD=none` is already short-circuited one line earlier, so
sessions from those flows behave exactly as before.

## Verification

- `tests/core/test_request_routes_api.py::TestDownloadPolicyGuards`: a
session without `is_admin` now gets `policy_requires_request` and
nothing is queued. Fails on current main, passes here.
- Full suite (3147), ruff, ruff format, basedpyright, vulture green.
2026-09-20 12:50:53 -04:00
splitsec2 127dd82615 fix(users): apply user updates only after the payload validates (#1360)
`PUT /api/users/me` and `PUT /api/admin/users/<id>` write the new
password hash, and then the profile fields, before the rest of the
payload is checked. When the request is rejected further down as an
invalid role, an admin-only setting, an invalid settings value, the
route answers 400 with those writes already committed, so the caller
sees an error while the password has in fact changed.

Both routes now validate the whole payload before touching the database,
and the password hash is folded into the same `update_user` call as the
other fields so the field write is a single transaction. Error messages,
status codes and the order they are reported in are unchanged.

## Verification

- New tests in `tests/core/test_self_user_routes.py` and
`tests/core/test_admin_users_api.py` assert that a rejected update
leaves the password, profile fields and role as they were, plus a
positive case that a valid payload still applies all three. They fail on
current main and pass here.
- Full suite (3150), ruff, ruff format, basedpyright, vulture green.
2026-09-20 12:50:14 -04:00
splitsec2 acd59f7cbb feat(auth): provision proxy users as non-admin once an admin exists (#1356)
With `AUTH_METHOD=proxy` and no admin group configured, every user the
proxy authenticates for the first time is provisioned as an admin
(`is_admin = True` unless the user already exists in `users.db`). The
intent to never lock an instance out makes sense, but the effect is that
anyone the SSO gate lets through becomes an administrator. On an
instance shared with family or a small community that is a footgun; I
hit it when the first invited reader landed as an admin.

This keeps the guarantee and removes the footgun: the first account is
still provisioned as an admin while the instance has no admin at all,
and later first-time users follow a new `PROXY_AUTH_DEFAULT_ROLE`
setting (Security tab / env), default `user`. Known users keep their
stored role; the `PROXY_AUTH_ADMIN_GROUP_NAME` path is unchanged and
still takes precedence. I couldn't find a way with Cloudflare access to
pass this along.

Changes: `UserDB.has_admin()`, `_proxy_default_is_admin()` in the proxy
middleware, the new `SelectField` beside the other proxy settings, the
regenerated `docs/environment-variables.md` entry and a row in
`docs/reverse-proxy.md`.

Compatibility: the default moves from "everyone admin" to "first admin,
then users". Accounts already in `users.db` are unaffected; new SSO
users on an existing instance become regular users unless
`PROXY_AUTH_DEFAULT_ROLE=admin` is set. If you would rather ship this
purely opt-in I can flip the default to `admin`.

## Verification

- `tests/core/test_auth_api.py::TestProxyProvisioningRole`: first user
admin / second user not; `PROXY_AUTH_DEFAULT_ROLE=admin` restores the
old behaviour; an admin from another auth source counts as "an admin
exists"; a known user keeps their role whatever the default.
- Full suite (3094), ruff, ruff format, basedpyright, vulture green.
- Running on my own instance since 2026-09-19.
2026-09-19 23:27:13 -04:00
dependabot[bot] c42edac363 build(deps): bump the gh-actions group with 6 updates (#1350)
Bumps the gh-actions group with 6 updates:

| Package | From | To |
| --- | --- | --- |
|
[docker/setup-buildx-action](https://github.com/docker/setup-buildx-action)
| `4.3.0` | `4.4.0` |
|
[docker/build-push-action](https://github.com/docker/build-push-action)
| `7.3.0` | `7.4.0` |
| [astral-sh/setup-uv](https://github.com/astral-sh/setup-uv) | `10.0.1`
| `10.1.0` |
| [github/codeql-action/init](https://github.com/github/codeql-action) |
`4.37.9` | `4.38.0` |
|
[github/codeql-action/autobuild](https://github.com/github/codeql-action)
| `4.37.9` | `4.38.0` |
|
[github/codeql-action/analyze](https://github.com/github/codeql-action)
| `4.37.9` | `4.38.0` |

Updates `docker/setup-buildx-action` from 4.3.0 to 4.4.0
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/docker/setup-buildx-action/releases">docker/setup-buildx-action's
releases</a>.</em></p>
<blockquote>
<h2>v4.4.0</h2>
<ul>
<li>Use official Buildx releases for cloud driver by <a
href="https://github.com/crazy-max"><code>@​crazy-max</code></a> in <a
href="https://redirect.github.com/docker/setup-buildx-action/pull/606">docker/setup-buildx-action#606</a></li>
<li>Pull BuildKit image before builder creation by <a
href="https://github.com/crazy-max"><code>@​crazy-max</code></a> in <a
href="https://redirect.github.com/docker/setup-buildx-action/pull/609">docker/setup-buildx-action#609</a></li>
<li>Use shared error helpers for Buildx and Docker commands by <a
href="https://github.com/crazy-max"><code>@​crazy-max</code></a> in <a
href="https://redirect.github.com/docker/setup-buildx-action/pull/620">docker/setup-buildx-action#620</a></li>
<li>Bump <code>@​docker/actions-toolkit</code> from 0.95.0 to 0.100.0 in
<a
href="https://redirect.github.com/docker/setup-buildx-action/pull/610">docker/setup-buildx-action#610</a>
<a
href="https://redirect.github.com/docker/setup-buildx-action/pull/618">docker/setup-buildx-action#618</a>
<a
href="https://redirect.github.com/docker/setup-buildx-action/pull/619">docker/setup-buildx-action#619</a></li>
<li>Bump <code>@​humanfs/node</code> from 0.16.7 to 0.16.8 in <a
href="https://redirect.github.com/docker/setup-buildx-action/pull/614">docker/setup-buildx-action#614</a></li>
<li>Bump js-yaml from 5.3.0 to 5.4.2 in <a
href="https://redirect.github.com/docker/setup-buildx-action/pull/608">docker/setup-buildx-action#608</a>
<a
href="https://redirect.github.com/docker/setup-buildx-action/pull/617">docker/setup-buildx-action#617</a></li>
<li>Bump postcss-selector-parser from 7.1.1 to 7.1.5 in <a
href="https://redirect.github.com/docker/setup-buildx-action/pull/611">docker/setup-buildx-action#611</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/docker/setup-buildx-action/compare/v4.3.0...v4.4.0">https://github.com/docker/setup-buildx-action/compare/v4.3.0...v4.4.0</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/docker/setup-buildx-action/commit/594f3bf4285d9ea8dc53c9a0c9c4092420091003"><code>594f3bf</code></a>
Merge pull request <a
href="https://redirect.github.com/docker/setup-buildx-action/issues/609">#609</a>
from crazy-max/pull-buildkit-image-before-create</li>
<li><a
href="https://github.com/docker/setup-buildx-action/commit/bd6e702fc33b636671900d5b5edfab64698c9c25"><code>bd6e702</code></a>
chore: update generated content</li>
<li><a
href="https://github.com/docker/setup-buildx-action/commit/6268c9da9abbd1309c8a16a75f92a878715c3032"><code>6268c9d</code></a>
pull BuildKit image before builder creation</li>
<li><a
href="https://github.com/docker/setup-buildx-action/commit/e8235251b82e23c90e6fad50016f0a78b7f28f11"><code>e823525</code></a>
Merge pull request <a
href="https://redirect.github.com/docker/setup-buildx-action/issues/621">#621</a>
from docker/dependabot/github_actions/codeql-actions-...</li>
<li><a
href="https://github.com/docker/setup-buildx-action/commit/533ed8ed095b0b133ef16fb495aad119524e220d"><code>533ed8e</code></a>
build(deps): bump the codeql-actions group with 2 updates</li>
<li><a
href="https://github.com/docker/setup-buildx-action/commit/bedaf135699075c88620cd30772b9b6eadc9ba99"><code>bedaf13</code></a>
Merge pull request <a
href="https://redirect.github.com/docker/setup-buildx-action/issues/620">#620</a>
from crazy-max/shared-error-helpers</li>
<li><a
href="https://github.com/docker/setup-buildx-action/commit/d5079fba84d5edd23d25ba7f3045122175ca6ee2"><code>d5079fb</code></a>
chore: update generated content</li>
<li><a
href="https://github.com/docker/setup-buildx-action/commit/226a61612ab71c26bbd805f955834049a4a9772f"><code>226a616</code></a>
use shared error helpers for Buildx and Docker commands</li>
<li><a
href="https://github.com/docker/setup-buildx-action/commit/77ce7f4bab9bc462d825001cde7f82d409ee474c"><code>77ce7f4</code></a>
Merge pull request <a
href="https://redirect.github.com/docker/setup-buildx-action/issues/619">#619</a>
from docker/dependabot/npm_and_yarn/docker/actions-to...</li>
<li><a
href="https://github.com/docker/setup-buildx-action/commit/0dc1dc94f0f7c064e93fda72884ca71e054c0354"><code>0dc1dc9</code></a>
[dependabot skip] chore: update generated content</li>
<li>Additional commits viewable in <a
href="https://github.com/docker/setup-buildx-action/compare/37fe631027851001ddb9b187196cc803df7f5f0e...594f3bf4285d9ea8dc53c9a0c9c4092420091003">compare
view</a></li>
</ul>
</details>
<br />

Updates `docker/build-push-action` from 7.3.0 to 7.4.0
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/docker/build-push-action/releases">docker/build-push-action's
releases</a>.</em></p>
<blockquote>
<h2>v7.4.0</h2>
<ul>
<li>Use the shared error helper for Buildx commands by <a
href="https://github.com/crazy-max"><code>@​crazy-max</code></a> in <a
href="https://redirect.github.com/docker/build-push-action/pull/1620">docker/build-push-action#1620</a></li>
<li>Prevent workflow command injection in metadata logs by <a
href="https://github.com/crazy-max"><code>@​crazy-max</code></a> in <a
href="https://redirect.github.com/docker/build-push-action/pull/1617">docker/build-push-action#1617</a></li>
<li>Bump <code>@​docker/actions-toolkit</code> from 0.92.0 to 0.100.0 in
<a
href="https://redirect.github.com/docker/build-push-action/pull/1614">docker/build-push-action#1614</a>
<a
href="https://redirect.github.com/docker/build-push-action/pull/1618">docker/build-push-action#1618</a>
<a
href="https://redirect.github.com/docker/build-push-action/pull/1621">docker/build-push-action#1621</a></li>
<li>Bump <code>@​humanfs/node</code> from 0.16.7 to 0.16.8 in <a
href="https://redirect.github.com/docker/build-push-action/pull/1609">docker/build-push-action#1609</a></li>
<li>Bump brace-expansion from 1.1.13 to 1.1.18 in <a
href="https://redirect.github.com/docker/build-push-action/pull/1592">docker/build-push-action#1592</a></li>
<li>Bump csv-parse from 7.0.0 to 7.0.2 in <a
href="https://redirect.github.com/docker/build-push-action/pull/1613">docker/build-push-action#1613</a></li>
<li>Bump js-yaml from 4.3.0 to 4.3.2 in <a
href="https://redirect.github.com/docker/build-push-action/pull/1605">docker/build-push-action#1605</a>
<a
href="https://redirect.github.com/docker/build-push-action/pull/1615">docker/build-push-action#1615</a></li>
<li>Bump nanoid from 3.3.16 to 3.3.18 in <a
href="https://redirect.github.com/docker/build-push-action/pull/1611">docker/build-push-action#1611</a></li>
<li>Bump postcss from 8.5.10 to 8.5.25 in <a
href="https://redirect.github.com/docker/build-push-action/pull/1590">docker/build-push-action#1590</a></li>
<li>Bump postcss-selector-parser from 7.1.1 to 7.1.5 in <a
href="https://redirect.github.com/docker/build-push-action/pull/1606">docker/build-push-action#1606</a></li>
<li>Bump sigstore from 4.1.0 to 4.1.1 in <a
href="https://redirect.github.com/docker/build-push-action/pull/1577">docker/build-push-action#1577</a></li>
<li>Bump undici from 6.27.0 to 6.28.0 in <a
href="https://redirect.github.com/docker/build-push-action/pull/1594">docker/build-push-action#1594</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/docker/build-push-action/compare/v7.3.0...v7.4.0">https://github.com/docker/build-push-action/compare/v7.3.0...v7.4.0</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/docker/build-push-action/commit/c3c9e263c25d99ce0380d002d59b67737d91b0dc"><code>c3c9e26</code></a>
Merge pull request <a
href="https://redirect.github.com/docker/build-push-action/issues/1621">#1621</a>
from docker/dependabot/npm_and_yarn/docker/actions-t...</li>
<li><a
href="https://github.com/docker/build-push-action/commit/459b6741834dcd35f946352017e7675bd2089d42"><code>459b674</code></a>
[dependabot skip] chore: update generated content</li>
<li><a
href="https://github.com/docker/build-push-action/commit/4dedcb23c91d79c1629bf53ec2c3bcfffef5b34e"><code>4dedcb2</code></a>
chore(deps): Bump <code>@​docker/actions-toolkit</code> from 0.99.0 to
0.100.0</li>
<li><a
href="https://github.com/docker/build-push-action/commit/379bf63a979bd70751945601fa04c50674509952"><code>379bf63</code></a>
Merge pull request <a
href="https://redirect.github.com/docker/build-push-action/issues/1620">#1620</a>
from crazy-max/buildx-error-message</li>
<li><a
href="https://github.com/docker/build-push-action/commit/9877975c9e0b0b661592ff61049069507f9bc2f6"><code>9877975</code></a>
chore: update generated content</li>
<li><a
href="https://github.com/docker/build-push-action/commit/7ed0556ffafb8eb312463411ef0a84a1dfe24d94"><code>7ed0556</code></a>
use the shared Buildx error summary helper</li>
<li><a
href="https://github.com/docker/build-push-action/commit/91670ba5a4df99a24efff8637a78c83fd1b0f6b1"><code>91670ba</code></a>
Merge pull request <a
href="https://redirect.github.com/docker/build-push-action/issues/1618">#1618</a>
from docker/dependabot/npm_and_yarn/docker/actions-t...</li>
<li><a
href="https://github.com/docker/build-push-action/commit/80dbc8614a5c0ce4356740f69179cf829ecdc79a"><code>80dbc86</code></a>
[dependabot skip] chore: update generated content</li>
<li><a
href="https://github.com/docker/build-push-action/commit/50cac3a3b6f55e6015d6483d1dd72a3ecb90d20d"><code>50cac3a</code></a>
chore(deps): Bump <code>@​docker/actions-toolkit</code> from 0.98.0 to
0.99.0</li>
<li><a
href="https://github.com/docker/build-push-action/commit/03b4d6cac0163b44733e1fa60adfd6da560ee4d1"><code>03b4d6c</code></a>
Merge pull request <a
href="https://redirect.github.com/docker/build-push-action/issues/1617">#1617</a>
from crazy-max/fix-metadata-workflow-commands</li>
<li>Additional commits viewable in <a
href="https://github.com/docker/build-push-action/compare/53b7df96c91f9c12dcc8a07bcb9ccacbed38856a...c3c9e263c25d99ce0380d002d59b67737d91b0dc">compare
view</a></li>
</ul>
</details>
<br />

Updates `astral-sh/setup-uv` from 10.0.1 to 10.1.0
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/astral-sh/setup-uv/releases">astral-sh/setup-uv's
releases</a>.</em></p>
<blockquote>
<h2>v10.1.0 🌈 New output <code>python-runtime-id</code>and respect
NO_PROXY</h2>
<h2>Changes</h2>
<p>This release adds more bheind the scene security improvements and
also 2 small improvements.</p>
<h3>NO_PROXY</h3>
<p>This action now respects <code>no_proxy/NO_PROXY</code> environment
variables which were previously ignored.</p>
<h3>New output <code>python-runtime-id</code></h3>
<p>The new output <code>python-runtime-id</code> can be used to know
which python version exactly was installed if you use
<code>activate-environment</code>. See <a
href="https://redirect.github.com/pyca/cryptography/pull/15572#discussion_r3913508686">pyca/cryptography#15572</a>
for details on why this can be useful.</p>
<h2>🐛 Bug fixes</h2>
<ul>
<li>fix: respect no proxy directive <a
href="https://github.com/mj0nez"><code>@​mj0nez</code></a> (<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/1037">#1037</a>)</li>
<li>Use JSON + a typed wrapper instead of TS codegen <a
href="https://github.com/woodruffw"><code>@​woodruffw</code></a> (<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/1025">#1025</a>)</li>
</ul>
<h2>🚀 Enhancements</h2>
<ul>
<li>Expose a Python &quot;identity&quot; output <a
href="https://github.com/woodruffw"><code>@​woodruffw</code></a> (<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/1036">#1036</a>)</li>
<li>Verify downloads with astral-sh/versions checksums <a
href="https://github.com/zaniebot"><code>@​zaniebot</code></a> (<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/1033">#1033</a>)</li>
</ul>
<h2>🧰 Maintenance</h2>
<ul>
<li>chore: update known checksums for 0.12.12 @<a
href="https://github.com/apps/github-actions">github-actions[bot]</a>
(<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/1041">#1041</a>)</li>
<li>chore: update known checksums for 0.12.10/0.12.11 @<a
href="https://github.com/apps/github-actions">github-actions[bot]</a>
(<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/1038">#1038</a>)</li>
<li>chore: update known checksums for 0.12.9 @<a
href="https://github.com/apps/github-actions">github-actions[bot]</a>
(<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/1035">#1035</a>)</li>
<li>chore: update known checksums for 0.12.7/0.12.8 @<a
href="https://github.com/apps/github-actions">github-actions[bot]</a>
(<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/1031">#1031</a>)</li>
<li>chore: update known checksums for 0.12.6 @<a
href="https://github.com/apps/github-actions">github-actions[bot]</a>
(<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/1030">#1030</a>)</li>
<li>chore: update known checksums for 0.12.5 @<a
href="https://github.com/apps/github-actions">github-actions[bot]</a>
(<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/1020">#1020</a>)</li>
<li>Use self-repo syntax for all in-repo actions/reusable workflows <a
href="https://github.com/woodruffw"><code>@​woodruffw</code></a> (<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/1024">#1024</a>)</li>
<li>Pin one-shot tools <a
href="https://github.com/woodruffw"><code>@​woodruffw</code></a> (<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/1022">#1022</a>)</li>
<li>ci: remove obsolete direct push attempts <a
href="https://github.com/eifinger"><code>@​eifinger</code></a> (<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/1019">#1019</a>)</li>
</ul>
<h2>📚 Documentation</h2>
<ul>
<li>docs: update version references to v10.0.1 @<a
href="https://github.com/apps/github-actions">github-actions[bot]</a>
(<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/1018">#1018</a>)</li>
</ul>
<h2>⬆️ Dependency updates</h2>
<ul>
<li>chore(deps-dev): roll up Dependabot updates <a
href="https://github.com/eifinger"><code>@​eifinger</code></a> (<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/1043">#1043</a>)</li>
<li>Harden npm install defaults <a
href="https://github.com/zaniebot"><code>@​zaniebot</code></a> (<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/1026">#1026</a>)</li>
<li>Add dependency cooldowns <a
href="https://github.com/woodruffw"><code>@​woodruffw</code></a> (<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/1021">#1021</a>)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/astral-sh/setup-uv/commit/bec219d24cd3e171d82865faccec33120bb574f4"><code>bec219d</code></a>
chore(deps-dev): roll up Dependabot updates (<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/1043">#1043</a>)</li>
<li><a
href="https://github.com/astral-sh/setup-uv/commit/b90ec40d15bfa44c33c6700196eb6efcdddb4373"><code>b90ec40</code></a>
fix: respect no proxy directive (<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/1037">#1037</a>)</li>
<li><a
href="https://github.com/astral-sh/setup-uv/commit/421feb646df5262e7dd93bc54161edfa30372417"><code>421feb6</code></a>
chore: update known checksums for 0.12.12 (<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/1041">#1041</a>)</li>
<li><a
href="https://github.com/astral-sh/setup-uv/commit/f634bf473ad85bf3e23a613f52c5fa9f363874fc"><code>f634bf4</code></a>
Expose a Python &quot;identity&quot; output (<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/1036">#1036</a>)</li>
<li><a
href="https://github.com/astral-sh/setup-uv/commit/a6772c8f0a09dc9e3582c70a994b0c55af921803"><code>a6772c8</code></a>
chore: update known checksums for 0.12.10/0.12.11 (<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/1038">#1038</a>)</li>
<li><a
href="https://github.com/astral-sh/setup-uv/commit/e105c8fb1d7b13074b851babdaef4185243c6a07"><code>e105c8f</code></a>
chore: update known checksums for 0.12.9 (<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/1035">#1035</a>)</li>
<li><a
href="https://github.com/astral-sh/setup-uv/commit/cd13f9217092d43a771cf9ba7b09bdd3da8d7c4d"><code>cd13f92</code></a>
Verify downloads with astral-sh/versions checksums (<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/1033">#1033</a>)</li>
<li><a
href="https://github.com/astral-sh/setup-uv/commit/3aef7b92c52cec135792ea1e95f4c77683d39e61"><code>3aef7b9</code></a>
chore: update known checksums for 0.12.7/0.12.8 (<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/1031">#1031</a>)</li>
<li><a
href="https://github.com/astral-sh/setup-uv/commit/d08d816a1ea176d61a318eff45abd3dffef415b1"><code>d08d816</code></a>
chore: update known checksums for 0.12.6 (<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/1030">#1030</a>)</li>
<li><a
href="https://github.com/astral-sh/setup-uv/commit/19b4d1e990bec64818914c40230bde93a0de300b"><code>19b4d1e</code></a>
Harden npm install defaults (<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/1026">#1026</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/astral-sh/setup-uv/compare/20cfd1bf945f4377ade1205e4dbc17946fc9a30d...bec219d24cd3e171d82865faccec33120bb574f4">compare
view</a></li>
</ul>
</details>
<br />

Updates `github/codeql-action/init` from 4.37.9 to 4.38.0
<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.38.0</h2>
<ul>
<li>On GitHub-hosted runners, the CodeQL Action now deletes unused
CodeQL bundles from the toolcache before downloading a different bundle,
which frees up disk space for the analysis. We expect to roll this
change out to everyone in September. <a
href="https://redirect.github.com/github/codeql-action/pull/4124">#4124</a></li>
<li>The CodeQL Action now supports CodeQL releases that are compatible
with Linux Arm64 and downloads the native <code>linux-arm64</code>
CodeQL bundle when available. <a
href="https://redirect.github.com/github/codeql-action/pull/4072">#4072</a></li>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.27.0">2.27.0</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/4129">#4129</a></li>
</ul>
</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.38.0 - 09 Sept 2026</h2>
<ul>
<li>On GitHub-hosted runners, the CodeQL Action now deletes unused
CodeQL bundles from the toolcache before downloading a different bundle,
which frees up disk space for the analysis. We expect to roll this
change out to everyone in September. <a
href="https://redirect.github.com/github/codeql-action/pull/4124">#4124</a></li>
<li>The CodeQL Action now supports CodeQL releases that are compatible
with Linux Arm64 and downloads the native <code>linux-arm64</code>
CodeQL bundle when available. <a
href="https://redirect.github.com/github/codeql-action/pull/4072">#4072</a></li>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.27.0">2.27.0</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/4129">#4129</a></li>
</ul>
<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>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/github/codeql-action/commit/b96794f015dfd88f77b49b1c93e0fa7110f94c63"><code>b96794f</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4131">#4131</a>
from github/update-v4.38.0-7e08580a9</li>
<li><a
href="https://github.com/github/codeql-action/commit/02d5093871674ea20274117103ce3038c73c77ef"><code>02d5093</code></a>
Update changelog for v4.38.0</li>
<li><a
href="https://github.com/github/codeql-action/commit/7e08580a93dc4e4b9dda167e364577035cf504c6"><code>7e08580</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4130">#4130</a>
from github/henrymercer/workflow-runner-sizing</li>
<li><a
href="https://github.com/github/codeql-action/commit/bfcc52b4f5d98468a5993daa0bf0e4fb3f3ed698"><code>bfcc52b</code></a>
Run slow macOS checks on larger runners</li>
<li><a
href="https://github.com/github/codeql-action/commit/8c251e757c0260283fc50214a06ac768b61d3af4"><code>8c251e7</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4129">#4129</a>
from github/update-bundle/codeql-bundle-v2.27.0</li>
<li><a
href="https://github.com/github/codeql-action/commit/0b7ca400df35985869d4b9146a067865d4115da1"><code>0b7ca40</code></a>
Add changelog note</li>
<li><a
href="https://github.com/github/codeql-action/commit/40484b339517c6bcf00f81eebc95ca041ddca505"><code>40484b3</code></a>
Update default bundle to codeql-bundle-v2.27.0</li>
<li><a
href="https://github.com/github/codeql-action/commit/977e6ceaea7361825998245d787fa3b4d6b9e5df"><code>977e6ce</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4124">#4124</a>
from github/henrymercer/toolcache-bundle-cleanup</li>
<li><a
href="https://github.com/github/codeql-action/commit/40a6b3824794ae1156e1a5320d32e364bf1dcebc"><code>40a6b38</code></a>
Address toolcache cleanup review feedback</li>
<li><a
href="https://github.com/github/codeql-action/commit/deece8f852f048bc3f52fd42c9cc7a99b1ebb252"><code>deece8f</code></a>
Apply suggestion from <a
href="https://github.com/henrymercer"><code>@​henrymercer</code></a></li>
<li>Additional commits viewable in <a
href="https://github.com/github/codeql-action/compare/cdf488f595d80d6e07e03d4674febd5ab45fa938...b96794f015dfd88f77b49b1c93e0fa7110f94c63">compare
view</a></li>
</ul>
</details>
<br />

Updates `github/codeql-action/autobuild` from 4.37.9 to 4.38.0
<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.38.0</h2>
<ul>
<li>On GitHub-hosted runners, the CodeQL Action now deletes unused
CodeQL bundles from the toolcache before downloading a different bundle,
which frees up disk space for the analysis. We expect to roll this
change out to everyone in September. <a
href="https://redirect.github.com/github/codeql-action/pull/4124">#4124</a></li>
<li>The CodeQL Action now supports CodeQL releases that are compatible
with Linux Arm64 and downloads the native <code>linux-arm64</code>
CodeQL bundle when available. <a
href="https://redirect.github.com/github/codeql-action/pull/4072">#4072</a></li>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.27.0">2.27.0</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/4129">#4129</a></li>
</ul>
</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.38.0 - 09 Sept 2026</h2>
<ul>
<li>On GitHub-hosted runners, the CodeQL Action now deletes unused
CodeQL bundles from the toolcache before downloading a different bundle,
which frees up disk space for the analysis. We expect to roll this
change out to everyone in September. <a
href="https://redirect.github.com/github/codeql-action/pull/4124">#4124</a></li>
<li>The CodeQL Action now supports CodeQL releases that are compatible
with Linux Arm64 and downloads the native <code>linux-arm64</code>
CodeQL bundle when available. <a
href="https://redirect.github.com/github/codeql-action/pull/4072">#4072</a></li>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.27.0">2.27.0</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/4129">#4129</a></li>
</ul>
<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>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/github/codeql-action/commit/b96794f015dfd88f77b49b1c93e0fa7110f94c63"><code>b96794f</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4131">#4131</a>
from github/update-v4.38.0-7e08580a9</li>
<li><a
href="https://github.com/github/codeql-action/commit/02d5093871674ea20274117103ce3038c73c77ef"><code>02d5093</code></a>
Update changelog for v4.38.0</li>
<li><a
href="https://github.com/github/codeql-action/commit/7e08580a93dc4e4b9dda167e364577035cf504c6"><code>7e08580</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4130">#4130</a>
from github/henrymercer/workflow-runner-sizing</li>
<li><a
href="https://github.com/github/codeql-action/commit/bfcc52b4f5d98468a5993daa0bf0e4fb3f3ed698"><code>bfcc52b</code></a>
Run slow macOS checks on larger runners</li>
<li><a
href="https://github.com/github/codeql-action/commit/8c251e757c0260283fc50214a06ac768b61d3af4"><code>8c251e7</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4129">#4129</a>
from github/update-bundle/codeql-bundle-v2.27.0</li>
<li><a
href="https://github.com/github/codeql-action/commit/0b7ca400df35985869d4b9146a067865d4115da1"><code>0b7ca40</code></a>
Add changelog note</li>
<li><a
href="https://github.com/github/codeql-action/commit/40484b339517c6bcf00f81eebc95ca041ddca505"><code>40484b3</code></a>
Update default bundle to codeql-bundle-v2.27.0</li>
<li><a
href="https://github.com/github/codeql-action/commit/977e6ceaea7361825998245d787fa3b4d6b9e5df"><code>977e6ce</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4124">#4124</a>
from github/henrymercer/toolcache-bundle-cleanup</li>
<li><a
href="https://github.com/github/codeql-action/commit/40a6b3824794ae1156e1a5320d32e364bf1dcebc"><code>40a6b38</code></a>
Address toolcache cleanup review feedback</li>
<li><a
href="https://github.com/github/codeql-action/commit/deece8f852f048bc3f52fd42c9cc7a99b1ebb252"><code>deece8f</code></a>
Apply suggestion from <a
href="https://github.com/henrymercer"><code>@​henrymercer</code></a></li>
<li>Additional commits viewable in <a
href="https://github.com/github/codeql-action/compare/cdf488f595d80d6e07e03d4674febd5ab45fa938...b96794f015dfd88f77b49b1c93e0fa7110f94c63">compare
view</a></li>
</ul>
</details>
<br />

Updates `github/codeql-action/analyze` from 4.37.9 to 4.38.0
<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.38.0</h2>
<ul>
<li>On GitHub-hosted runners, the CodeQL Action now deletes unused
CodeQL bundles from the toolcache before downloading a different bundle,
which frees up disk space for the analysis. We expect to roll this
change out to everyone in September. <a
href="https://redirect.github.com/github/codeql-action/pull/4124">#4124</a></li>
<li>The CodeQL Action now supports CodeQL releases that are compatible
with Linux Arm64 and downloads the native <code>linux-arm64</code>
CodeQL bundle when available. <a
href="https://redirect.github.com/github/codeql-action/pull/4072">#4072</a></li>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.27.0">2.27.0</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/4129">#4129</a></li>
</ul>
</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.38.0 - 09 Sept 2026</h2>
<ul>
<li>On GitHub-hosted runners, the CodeQL Action now deletes unused
CodeQL bundles from the toolcache before downloading a different bundle,
which frees up disk space for the analysis. We expect to roll this
change out to everyone in September. <a
href="https://redirect.github.com/github/codeql-action/pull/4124">#4124</a></li>
<li>The CodeQL Action now supports CodeQL releases that are compatible
with Linux Arm64 and downloads the native <code>linux-arm64</code>
CodeQL bundle when available. <a
href="https://redirect.github.com/github/codeql-action/pull/4072">#4072</a></li>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.27.0">2.27.0</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/4129">#4129</a></li>
</ul>
<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>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/github/codeql-action/commit/b96794f015dfd88f77b49b1c93e0fa7110f94c63"><code>b96794f</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4131">#4131</a>
from github/update-v4.38.0-7e08580a9</li>
<li><a
href="https://github.com/github/codeql-action/commit/02d5093871674ea20274117103ce3038c73c77ef"><code>02d5093</code></a>
Update changelog for v4.38.0</li>
<li><a
href="https://github.com/github/codeql-action/commit/7e08580a93dc4e4b9dda167e364577035cf504c6"><code>7e08580</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4130">#4130</a>
from github/henrymercer/workflow-runner-sizing</li>
<li><a
href="https://github.com/github/codeql-action/commit/bfcc52b4f5d98468a5993daa0bf0e4fb3f3ed698"><code>bfcc52b</code></a>
Run slow macOS checks on larger runners</li>
<li><a
href="https://github.com/github/codeql-action/commit/8c251e757c0260283fc50214a06ac768b61d3af4"><code>8c251e7</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4129">#4129</a>
from github/update-bundle/codeql-bundle-v2.27.0</li>
<li><a
href="https://github.com/github/codeql-action/commit/0b7ca400df35985869d4b9146a067865d4115da1"><code>0b7ca40</code></a>
Add changelog note</li>
<li><a
href="https://github.com/github/codeql-action/commit/40484b339517c6bcf00f81eebc95ca041ddca505"><code>40484b3</code></a>
Update default bundle to codeql-bundle-v2.27.0</li>
<li><a
href="https://github.com/github/codeql-action/commit/977e6ceaea7361825998245d787fa3b4d6b9e5df"><code>977e6ce</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4124">#4124</a>
from github/henrymercer/toolcache-bundle-cleanup</li>
<li><a
href="https://github.com/github/codeql-action/commit/40a6b3824794ae1156e1a5320d32e364bf1dcebc"><code>40a6b38</code></a>
Address toolcache cleanup review feedback</li>
<li><a
href="https://github.com/github/codeql-action/commit/deece8f852f048bc3f52fd42c9cc7a99b1ebb252"><code>deece8f</code></a>
Apply suggestion from <a
href="https://github.com/henrymercer"><code>@​henrymercer</code></a></li>
<li>Additional commits viewable in <a
href="https://github.com/github/codeql-action/compare/cdf488f595d80d6e07e03d4674febd5ab45fa938...b96794f015dfd88f77b49b1c93e0fa7110f94c63">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-19 23:19:16 -04:00
dependabot[bot] d09ec9de25 build(deps): bump the npm-deps group in /src/frontend with 9 updates (#1349)
Bumps the npm-deps group in /src/frontend with 9 updates:

| Package | From | To |
| --- | --- | --- |
| [react](https://github.com/react/react/tree/HEAD/packages/react) |
`19.2.8` | `19.3.0` |
|
[@types/react](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react)
| `19.2.18` | `19.3.0` |
|
[react-dom](https://github.com/react/react/tree/HEAD/packages/react-dom)
| `19.2.8` | `19.3.0` |
|
[@types/react-dom](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react-dom)
| `19.2.7` | `19.3.0` |
|
[@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node)
| `26.5.0` | `26.5.1` |
| [knip](https://github.com/webpro-nl/knip/tree/HEAD/packages/knip) |
`6.34.0` | `6.35.1` |
| [oxfmt](https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt) |
`0.67.0` | `0.68.0` |
| [oxlint](https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint) |
`1.82.0` | `1.83.0` |
| [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) |
`8.2.2` | `8.3.0` |

Updates `react` from 19.2.8 to 19.3.0
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/react/react/releases">react's
releases</a>.</em></p>
<blockquote>
<h2>19.3.0 (September 9, 2026)</h2>
<p>Below is a list of all new features, APIs, and bug fixes.</p>
<p>Read the <a href="https://react.dev/blog/2026/09/09/react-19-3">React
19.3 release post</a> for more information.</p>
<h2>New React Features</h2>
<ul>
<li><code>&lt;ViewTransition /&gt;</code>: Adds <code>&lt;ViewTransition
/&gt;</code> and <code>addTransitionType</code> APIs to power View
Transition animations in React (<a
href="https://github.com/sebmarkbage"><code>@​sebmarkbage</code></a>, <a
href="https://github.com/jackpope"><code>@​jackpope</code></a>, <a
href="https://github.com/gaearon"><code>@​gaearon</code></a>: <a
href="https://redirect.github.com/facebook/react/pull/31975">#31975</a>,
<a
href="https://redirect.github.com/facebook/react/pull/31987">#31987</a>,
<a
href="https://redirect.github.com/facebook/react/pull/31996">#31996</a>,
<a
href="https://redirect.github.com/facebook/react/pull/31999">#31999</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32001">#32001</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32002">#32002</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32028">#32028</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32029">#32029</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32031">#32031</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32034">#32034</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32038">#32038</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32041">#32041</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32050">#32050</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32090">#32090</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32105">#32105</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32254">#32254</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32379">#32379</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32422">#32422</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32462">#32462</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32540">#32540</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32545">#32545</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32585">#32585</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32599">#32599</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32611">#32611</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32612">#32612</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32617">#32617</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32651">#32651</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32653">#32653</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32656">#32656</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32664">#32664</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32699">#32699</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32723">#32723</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32734">#32734</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32751">#32751</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32752">#32752</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32760">#32760</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32761">#32761</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32764">#32764</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32772">#32772</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32790">#32790</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32819">#32819</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32820">#32820</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32822">#32822</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32833">#32833</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32849">#32849</a>,
<a
href="https://redirect.github.com/facebook/react/pull/33094">#33094</a>,
<a
href="https://redirect.github.com/facebook/react/pull/33191">#33191</a>,
<a
href="https://redirect.github.com/facebook/react/pull/33200">#33200</a>,
<a
href="https://redirect.github.com/facebook/react/pull/33206">#33206</a>,
<a
href="https://redirect.github.com/facebook/react/pull/33293">#33293</a>,
<a
href="https://redirect.github.com/facebook/react/pull/33330">#33330</a>,
<a
href="https://redirect.github.com/facebook/react/pull/33331">#33331</a>,
<a
href="https://redirect.github.com/facebook/react/pull/33332">#33332</a>,
<a
href="https://redirect.github.com/facebook/react/pull/33357">#33357</a>,
<a
href="https://redirect.github.com/facebook/react/pull/33362">#33362</a>,
<a
href="https://redirect.github.com/facebook/react/pull/33433">#33433</a>,
<a
href="https://redirect.github.com/facebook/react/pull/33576">#33576</a>,
<a
href="https://redirect.github.com/facebook/react/pull/34374">#34374</a>,
<a
href="https://redirect.github.com/facebook/react/pull/34450">#34450</a>,
<a
href="https://redirect.github.com/facebook/react/pull/34481">#34481</a>,
<a
href="https://redirect.github.com/facebook/react/pull/34500">#34500</a>,
<a
href="https://redirect.github.com/facebook/react/pull/34502">#34502</a>,
<a
href="https://redirect.github.com/facebook/react/pull/34510">#34510</a>,
<a
href="https://redirect.github.com/facebook/react/pull/34511">#34511</a>,
<a
href="https://redirect.github.com/facebook/react/pull/34539">#34539</a>,
<a
href="https://redirect.github.com/facebook/react/pull/35567">#35567</a>,
<a
href="https://redirect.github.com/facebook/react/pull/35564">#35564</a>,
<a
href="https://redirect.github.com/facebook/react/pull/35485">#35485</a>,
<a
href="https://redirect.github.com/facebook/react/pull/35380">#35380</a>,
<a
href="https://redirect.github.com/facebook/react/pull/35063">#35063</a>,
<a
href="https://redirect.github.com/facebook/react/pull/35060">#35060</a>,
<a
href="https://redirect.github.com/facebook/react/pull/34676">#34676</a>,
<a
href="https://redirect.github.com/facebook/react/pull/36917">#36917</a>,
<a
href="https://redirect.github.com/facebook/react/pull/35337">#35337</a>,
<a
href="https://redirect.github.com/facebook/react/pull/35520">#35520</a>)</li>
<li>Fragment Refs: Add Refs to <code>&lt;Fragment /&gt;</code> to
support composable platform behavior (<a
href="https://github.com/jackpope"><code>@​jackpope</code></a>, <a
href="https://github.com/sebmarkbage"><code>@​sebmarkbage</code></a>, <a
href="https://github.com/eps1lon"><code>@​eps1lon</code></a>, <a
href="https://github.com/Dhakshin2007"><code>@​Dhakshin2007</code></a>,
<a href="https://github.com/chirokas"><code>@​chirokas</code></a>, <a
href="https://github.com/teamleaderleo"><code>@​teamleaderleo</code></a>,
<a
href="https://github.com/fallintoplace"><code>@​fallintoplace</code></a>:
<a
href="https://redirect.github.com/facebook/react/pull/32465">#32465</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32613">#32613</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32619">#32619</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32654">#32654</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32660">#32660</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32682">#32682</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32722">#32722</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32813">#32813</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32814">#32814</a>,
<a
href="https://redirect.github.com/facebook/react/pull/33056">#33056</a>,
<a
href="https://redirect.github.com/facebook/react/pull/33058">#33058</a>,
<a
href="https://redirect.github.com/facebook/react/pull/33093">#33093</a>,
<a
href="https://redirect.github.com/facebook/react/pull/34069">#34069</a>,
<a
href="https://redirect.github.com/facebook/react/pull/34103">#34103</a>,
<a
href="https://redirect.github.com/facebook/react/pull/34544">#34544</a>,
<a
href="https://redirect.github.com/facebook/react/pull/34545">#34545</a>,
<a
href="https://redirect.github.com/facebook/react/pull/37062">#37062</a>,
<a
href="https://redirect.github.com/facebook/react/pull/37061">#37061</a>,
<a
href="https://redirect.github.com/facebook/react/pull/37060">#37060</a>,
<a
href="https://redirect.github.com/facebook/react/pull/36047">#36047</a>,
<a
href="https://redirect.github.com/facebook/react/pull/36010">#36010</a>,
<a
href="https://redirect.github.com/facebook/react/pull/35642">#35642</a>,
<a
href="https://redirect.github.com/facebook/react/pull/35641">#35641</a>,
<a
href="https://redirect.github.com/facebook/react/pull/35637">#35637</a>,
<a
href="https://redirect.github.com/facebook/react/pull/35630">#35630</a>,
<a
href="https://redirect.github.com/facebook/react/pull/34935">#34935</a>,
<a
href="https://redirect.github.com/facebook/react/pull/37457">#37457</a>,
<a
href="https://redirect.github.com/facebook/react/pull/37408">#37408</a>,
<a
href="https://redirect.github.com/facebook/react/pull/37326">#37326</a>,
<a
href="https://redirect.github.com/facebook/react/pull/37251">#37251</a>,
<a
href="https://redirect.github.com/facebook/react/pull/37171">#37171</a>,
<a
href="https://redirect.github.com/facebook/react/pull/37169">#37169</a>,
<a
href="https://redirect.github.com/facebook/react/pull/37168">#37168</a>,
<a
href="https://redirect.github.com/facebook/react/pull/37167">#37167</a>,
<a
href="https://redirect.github.com/facebook/react/pull/37166">#37166</a>,
<a
href="https://redirect.github.com/facebook/react/pull/37165">#37165</a>,
<a
href="https://redirect.github.com/facebook/react/pull/37164">#37164</a>,
<a
href="https://redirect.github.com/facebook/react/pull/37163">#37163</a>,
<a
href="https://redirect.github.com/facebook/react/pull/37162">#37162</a>,
<a
href="https://redirect.github.com/facebook/react/pull/37161">#37161</a>,
<a
href="https://redirect.github.com/facebook/react/pull/37160">#37160</a>,
<a
href="https://redirect.github.com/facebook/react/pull/37125">#37125</a>,
<a
href="https://redirect.github.com/facebook/react/pull/37063">#37063</a>)</li>
</ul>
<h2>New React DOM Features</h2>
<ul>
<li><code>browser()</code>: a new <code>react-dom</code> API that
returns a usable which errors during server rendering and resolves in
the browser. <code>use(browser())</code> inside a
<code>&lt;Suspense&gt;</code> boundary marks a subtree as browser-only
without reporting a recoverable error (<a
href="https://github.com/gnoff"><code>@​gnoff</code></a>: <a
href="https://redirect.github.com/facebook/react/pull/37143">#37143</a>,
<a
href="https://redirect.github.com/facebook/react/pull/37241">#37241</a>)
<ul>
<li>Added an <code>onBrowserBailout</code> option to the
<code>react-dom/server</code> APIs to observe when a subtree defers to
the browser (<a href="https://github.com/gnoff"><code>@​gnoff</code></a>
<a
href="https://redirect.github.com/facebook/react/pull/37193">#37193</a>)</li>
</ul>
</li>
</ul>
<h2>Notable changes</h2>
<ul>
<li>Enable Trusted Types API integration (<a
href="https://github.com/rickhanlonii"><code>@​rickhanlonii</code></a>
<a
href="https://redirect.github.com/facebook/react/pull/35816">#35816</a>)</li>
<li>Transitions now render independently instead of being entangled into
a single render, so a slow transition no longer holds up unrelated ones
(<a href="https://github.com/acdlite"><code>@​acdlite</code></a> <a
href="https://redirect.github.com/facebook/react/pull/37290">#37290</a>)</li>
<li>Added a DEV-only warning when a component appears to have been
unblocked by calling <code>use()</code> conditionally (<a
href="https://github.com/hoxyq"><code>@​hoxyq</code></a>, <a
href="https://github.com/eps1lon"><code>@​eps1lon</code></a>: <a
href="https://redirect.github.com/facebook/react/pull/37104">#37104</a>,
<a
href="https://redirect.github.com/facebook/react/pull/37203">#37203</a>,
<a
href="https://redirect.github.com/facebook/react/pull/37491">#37491</a>)</li>
</ul>
<h2>All Changes</h2>
<h3>React</h3>
<ul>
<li>Fast Refresh Fixes
<ul>
<li>Fix Fast Refresh to find and remount edits to components wrapped
behind <code>lazy()</code> (<a
href="https://github.com/sophiebits"><code>@​sophiebits</code></a> <a
href="https://redirect.github.com/facebook/react/pull/36965">#36965</a>)</li>
<li>Fix Fast Refresh so edits to a <code>memo()</code> comparison
function take effect (<a
href="https://github.com/sophiebits"><code>@​sophiebits</code></a> <a
href="https://redirect.github.com/facebook/react/pull/36964">#36964</a>)</li>
<li>Fix Fast Refresh crash when an edit changes the kind of a
component's type (<a
href="https://github.com/sophiebits"><code>@​sophiebits</code></a> <a
href="https://redirect.github.com/facebook/react/pull/36963">#36963</a>)</li>
<li>Unify hot reload type resolution for Fast Refresh (<a
href="https://github.com/sophiebits"><code>@​sophiebits</code></a> <a
href="https://redirect.github.com/facebook/react/pull/36962">#36962</a>)</li>
<li>Fix Fast Refresh to remount correctly when an edit changes the
component kind (<a
href="https://github.com/sophiebits"><code>@​sophiebits</code></a> <a
href="https://redirect.github.com/facebook/react/pull/36950">#36950</a>)</li>
<li>Double invoke effects in StrictMode after Fast Refresh (<a
href="https://github.com/eps1lon"><code>@​eps1lon</code></a> <a
href="https://redirect.github.com/facebook/react/pull/35962">#35962</a>)</li>
</ul>
</li>
<li>Performance Track Fixes
<ul>
<li>Prevent crash when accessing <code>$$typeof</code> in Performance
Tracks (<a href="https://github.com/eps1lon"><code>@​eps1lon</code></a>
<a
href="https://redirect.github.com/facebook/react/pull/35679">#35679</a>)</li>
<li>Handle non-string function names in Performance Tracks (<a
href="https://github.com/eps1lon"><code>@​eps1lon</code></a> <a
href="https://redirect.github.com/facebook/react/pull/35659">#35659</a>)</li>
<li>Use minus (<code>-</code>) instead of en dash for removed props in
Performance Tracks (<a
href="https://github.com/eps1lon"><code>@​eps1lon</code></a> <a
href="https://redirect.github.com/facebook/react/pull/35649">#35649</a>)</li>
<li>Handle arrays with bigints in deep objects in Performance Tracks (<a
href="https://github.com/eps1lon"><code>@​eps1lon</code></a> <a
href="https://redirect.github.com/facebook/react/pull/35648">#35648</a>)</li>
<li>Don't enumerate typed array props in Performance Tracks in DEV (<a
href="https://github.com/UditDewan"><code>@​UditDewan</code></a> <a
href="https://redirect.github.com/facebook/react/pull/36913">#36913</a>)</li>
<li>Bail out of diffing wide objects and arrays in Performance Tracks
(<a href="https://github.com/eps1lon"><code>@​eps1lon</code></a> <a
href="https://redirect.github.com/facebook/react/pull/34742">#34742</a>)</li>
<li>Clear potentially large performance measures in DEV (<a
href="https://github.com/hoxyq"><code>@​hoxyq</code></a> <a
href="https://redirect.github.com/facebook/react/pull/34803">#34803</a>)</li>
<li>Fix missing else branch for renders with no props change in
Performance Tracks (<a
href="https://github.com/hoxyq"><code>@​hoxyq</code></a> <a
href="https://redirect.github.com/facebook/react/pull/34837">#34837</a>)</li>
</ul>
</li>
<li>Activity Fixes
<ul>
<li>Fix <code>useSyncExternalStore</code> missing store mutations that
happened while an Activity tree was hidden (<a
href="https://github.com/sophiebits"><code>@​sophiebits</code></a> <a
href="https://redirect.github.com/facebook/react/pull/36947">#36947</a>)</li>
<li>Hide portal contents when an Activity is hidden (<a
href="https://github.com/acdlite"><code>@​acdlite</code></a> <a
href="https://redirect.github.com/facebook/react/pull/35091">#35091</a>)</li>
<li>Prevent metadata hoisting in hidden <code>&lt;Activity&gt;</code>
trees (<a
href="https://github.com/ronnakamoto"><code>@​ronnakamoto</code></a> <a
href="https://redirect.github.com/facebook/react/pull/34983">#34983</a>)</li>
<li>Prevent errors thrown inside a hidden Activity from escaping to the
visible UI (<a
href="https://github.com/acdlite"><code>@​acdlite</code></a> <a
href="https://redirect.github.com/facebook/react/pull/35074">#35074</a>)</li>
<li>Don't unhide a node if a direct parent Offscreen is still hidden (<a
href="https://github.com/sebmarkbage"><code>@​sebmarkbage</code></a> <a
href="https://redirect.github.com/facebook/react/pull/34821">#34821</a>)</li>
<li>Don't show internal <code>&lt;Offscreen&gt;</code> component in
error messages (<a
href="https://github.com/rickhanlonii"><code>@​rickhanlonii</code></a>
<a
href="https://redirect.github.com/facebook/react/pull/35763">#35763</a>)</li>
</ul>
</li>
<li>Warn in DEV when a component appears to have been unblocked by a
conditional <code>use()</code> (<a
href="https://github.com/hoxyq"><code>@​hoxyq</code></a>, <a
href="https://github.com/eps1lon"><code>@​eps1lon</code></a>: <a
href="https://redirect.github.com/facebook/react/pull/37104">#37104</a>,
<a
href="https://redirect.github.com/facebook/react/pull/37203">#37203</a>,
<a
href="https://redirect.github.com/facebook/react/pull/37491">#37491</a>)</li>
<li>Render transitions independently instead of entangling them into a
single render (<a
href="https://github.com/acdlite"><code>@​acdlite</code></a> <a
href="https://redirect.github.com/facebook/react/pull/37290">#37290</a>)</li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/react/react/blob/main/CHANGELOG.md">react's
changelog</a>.</em></p>
<blockquote>
<h2>19.3.0 (September 9, 2026)</h2>
<h3>New React Features</h3>
<ul>
<li><code>&lt;ViewTransition /&gt;</code>: Adds <code>&lt;ViewTransition
/&gt;</code> and <code>addTransitionType</code> APIs to power View
Transition animations in React (<a
href="https://github.com/sebmarkbage"><code>@​sebmarkbage</code></a>, <a
href="https://github.com/jackpope"><code>@​jackpope</code></a>, <a
href="https://github.com/gaearon"><code>@​gaearon</code></a>: <a
href="https://redirect.github.com/facebook/react/pull/31975">#31975</a>,
<a
href="https://redirect.github.com/facebook/react/pull/31987">#31987</a>,
<a
href="https://redirect.github.com/facebook/react/pull/31996">#31996</a>,
<a
href="https://redirect.github.com/facebook/react/pull/31999">#31999</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32001">#32001</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32002">#32002</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32028">#32028</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32029">#32029</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32031">#32031</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32034">#32034</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32038">#32038</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32041">#32041</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32050">#32050</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32090">#32090</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32105">#32105</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32254">#32254</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32379">#32379</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32422">#32422</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32462">#32462</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32540">#32540</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32545">#32545</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32585">#32585</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32599">#32599</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32611">#32611</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32612">#32612</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32617">#32617</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32651">#32651</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32653">#32653</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32656">#32656</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32664">#32664</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32699">#32699</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32723">#32723</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32734">#32734</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32751">#32751</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32752">#32752</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32760">#32760</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32761">#32761</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32764">#32764</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32772">#32772</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32790">#32790</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32819">#32819</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32820">#32820</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32822">#32822</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32833">#32833</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32849">#32849</a>,
<a
href="https://redirect.github.com/facebook/react/pull/33094">#33094</a>,
<a
href="https://redirect.github.com/facebook/react/pull/33191">#33191</a>,
<a
href="https://redirect.github.com/facebook/react/pull/33200">#33200</a>,
<a
href="https://redirect.github.com/facebook/react/pull/33206">#33206</a>,
<a
href="https://redirect.github.com/facebook/react/pull/33293">#33293</a>,
<a
href="https://redirect.github.com/facebook/react/pull/33330">#33330</a>,
<a
href="https://redirect.github.com/facebook/react/pull/33331">#33331</a>,
<a
href="https://redirect.github.com/facebook/react/pull/33332">#33332</a>,
<a
href="https://redirect.github.com/facebook/react/pull/33357">#33357</a>,
<a
href="https://redirect.github.com/facebook/react/pull/33362">#33362</a>,
<a
href="https://redirect.github.com/facebook/react/pull/33433">#33433</a>,
<a
href="https://redirect.github.com/facebook/react/pull/33576">#33576</a>,
<a
href="https://redirect.github.com/facebook/react/pull/34374">#34374</a>,
<a
href="https://redirect.github.com/facebook/react/pull/34450">#34450</a>,
<a
href="https://redirect.github.com/facebook/react/pull/34481">#34481</a>,
<a
href="https://redirect.github.com/facebook/react/pull/34500">#34500</a>,
<a
href="https://redirect.github.com/facebook/react/pull/34502">#34502</a>,
<a
href="https://redirect.github.com/facebook/react/pull/34510">#34510</a>,
<a
href="https://redirect.github.com/facebook/react/pull/34511">#34511</a>,
<a
href="https://redirect.github.com/facebook/react/pull/34539">#34539</a>,
<a
href="https://redirect.github.com/facebook/react/pull/35567">#35567</a>,
<a
href="https://redirect.github.com/facebook/react/pull/35564">#35564</a>,
<a
href="https://redirect.github.com/facebook/react/pull/35485">#35485</a>,
<a
href="https://redirect.github.com/facebook/react/pull/35380">#35380</a>,
<a
href="https://redirect.github.com/facebook/react/pull/35063">#35063</a>,
<a
href="https://redirect.github.com/facebook/react/pull/35060">#35060</a>,
<a
href="https://redirect.github.com/facebook/react/pull/34676">#34676</a>,
<a
href="https://redirect.github.com/facebook/react/pull/36917">#36917</a>,
<a
href="https://redirect.github.com/facebook/react/pull/35337">#35337</a>,
<a
href="https://redirect.github.com/facebook/react/pull/35520">#35520</a>)</li>
<li>Fragment Refs: Add Refs to <code>&lt;Fragment /&gt;</code> to
support composable platform behavior (<a
href="https://github.com/jackpope"><code>@​jackpope</code></a>, <a
href="https://github.com/sebmarkbage"><code>@​sebmarkbage</code></a>, <a
href="https://github.com/eps1lon"><code>@​eps1lon</code></a>, <a
href="https://github.com/Dhakshin2007"><code>@​Dhakshin2007</code></a>,
<a href="https://github.com/chirokas"><code>@​chirokas</code></a>, <a
href="https://github.com/teamleaderleo"><code>@​teamleaderleo</code></a>,
<a
href="https://github.com/fallintoplace"><code>@​fallintoplace</code></a>:
<a
href="https://redirect.github.com/facebook/react/pull/32465">#32465</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32613">#32613</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32619">#32619</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32654">#32654</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32660">#32660</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32682">#32682</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32722">#32722</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32813">#32813</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32814">#32814</a>,
<a
href="https://redirect.github.com/facebook/react/pull/33056">#33056</a>,
<a
href="https://redirect.github.com/facebook/react/pull/33058">#33058</a>,
<a
href="https://redirect.github.com/facebook/react/pull/33093">#33093</a>,
<a
href="https://redirect.github.com/facebook/react/pull/34069">#34069</a>,
<a
href="https://redirect.github.com/facebook/react/pull/34103">#34103</a>,
<a
href="https://redirect.github.com/facebook/react/pull/34544">#34544</a>,
<a
href="https://redirect.github.com/facebook/react/pull/34545">#34545</a>,
<a
href="https://redirect.github.com/facebook/react/pull/37062">#37062</a>,
<a
href="https://redirect.github.com/facebook/react/pull/37061">#37061</a>,
<a
href="https://redirect.github.com/facebook/react/pull/37060">#37060</a>,
<a
href="https://redirect.github.com/facebook/react/pull/36047">#36047</a>,
<a
href="https://redirect.github.com/facebook/react/pull/36010">#36010</a>,
<a
href="https://redirect.github.com/facebook/react/pull/35642">#35642</a>,
<a
href="https://redirect.github.com/facebook/react/pull/35641">#35641</a>,
<a
href="https://redirect.github.com/facebook/react/pull/35637">#35637</a>,
<a
href="https://redirect.github.com/facebook/react/pull/35630">#35630</a>,
<a
href="https://redirect.github.com/facebook/react/pull/34935">#34935</a>,
<a
href="https://redirect.github.com/facebook/react/pull/37457">#37457</a>,
<a
href="https://redirect.github.com/facebook/react/pull/37408">#37408</a>,
<a
href="https://redirect.github.com/facebook/react/pull/37326">#37326</a>,
<a
href="https://redirect.github.com/facebook/react/pull/37251">#37251</a>,
<a
href="https://redirect.github.com/facebook/react/pull/37171">#37171</a>,
<a
href="https://redirect.github.com/facebook/react/pull/37169">#37169</a>,
<a
href="https://redirect.github.com/facebook/react/pull/37168">#37168</a>,
<a
href="https://redirect.github.com/facebook/react/pull/37167">#37167</a>,
<a
href="https://redirect.github.com/facebook/react/pull/37166">#37166</a>,
<a
href="https://redirect.github.com/facebook/react/pull/37165">#37165</a>,
<a
href="https://redirect.github.com/facebook/react/pull/37164">#37164</a>,
<a
href="https://redirect.github.com/facebook/react/pull/37163">#37163</a>,
<a
href="https://redirect.github.com/facebook/react/pull/37162">#37162</a>,
<a
href="https://redirect.github.com/facebook/react/pull/37161">#37161</a>,
<a
href="https://redirect.github.com/facebook/react/pull/37160">#37160</a>,
<a
href="https://redirect.github.com/facebook/react/pull/37125">#37125</a>,
<a
href="https://redirect.github.com/facebook/react/pull/37063">#37063</a>)</li>
</ul>
<h3>New React DOM Features</h3>
<ul>
<li><code>browser()</code>: a new <code>react-dom</code> API that
returns a usable which errors during server rendering and resolves in
the browser. <code>use(browser())</code> inside a
<code>&lt;Suspense&gt;</code> boundary marks a subtree as browser-only
without reporting a recoverable error (<a
href="https://github.com/gnoff"><code>@​gnoff</code></a>: <a
href="https://redirect.github.com/facebook/react/pull/37143">#37143</a>,
<a
href="https://redirect.github.com/facebook/react/pull/37241">#37241</a>)
<ul>
<li>Added an <code>onBrowserBailout</code> option to the
<code>react-dom/server</code> APIs to observe when a subtree defers to
the browser (<a href="https://github.com/gnoff"><code>@​gnoff</code></a>
<a
href="https://redirect.github.com/facebook/react/pull/37193">#37193</a>)</li>
</ul>
</li>
</ul>
<h3>Notable changes</h3>
<ul>
<li>Enable Trusted Types API integration (<a
href="https://github.com/rickhanlonii"><code>@​rickhanlonii</code></a>
<a
href="https://redirect.github.com/facebook/react/pull/35816">#35816</a>)</li>
<li>Transitions now render independently instead of being entangled into
a single render, so a slow transition no longer holds up unrelated ones
(<a href="https://github.com/acdlite"><code>@​acdlite</code></a> <a
href="https://redirect.github.com/facebook/react/pull/37290">#37290</a>)</li>
<li>Added a DEV-only warning when a component appears to have been
unblocked by calling <code>use()</code> conditionally (<a
href="https://github.com/hoxyq"><code>@​hoxyq</code></a>, <a
href="https://github.com/eps1lon"><code>@​eps1lon</code></a>: <a
href="https://redirect.github.com/facebook/react/pull/37104">#37104</a>,
<a
href="https://redirect.github.com/facebook/react/pull/37203">#37203</a>,
<a
href="https://redirect.github.com/facebook/react/pull/37491">#37491</a>)</li>
</ul>
<h3>All Changes</h3>
<h4>React</h4>
<ul>
<li>Fast Refresh Fixes
<ul>
<li>Fix Fast Refresh to find and remount edits to components wrapped
behind <code>lazy()</code> (<a
href="https://github.com/sophiebits"><code>@​sophiebits</code></a> <a
href="https://redirect.github.com/facebook/react/pull/36965">#36965</a>)</li>
<li>Fix Fast Refresh so edits to a <code>memo()</code> comparison
function take effect (<a
href="https://github.com/sophiebits"><code>@​sophiebits</code></a> <a
href="https://redirect.github.com/facebook/react/pull/36964">#36964</a>)</li>
<li>Fix Fast Refresh crash when an edit changes the kind of a
component's type (<a
href="https://github.com/sophiebits"><code>@​sophiebits</code></a> <a
href="https://redirect.github.com/facebook/react/pull/36963">#36963</a>)</li>
<li>Unify hot reload type resolution for Fast Refresh (<a
href="https://github.com/sophiebits"><code>@​sophiebits</code></a> <a
href="https://redirect.github.com/facebook/react/pull/36962">#36962</a>)</li>
<li>Fix Fast Refresh to remount correctly when an edit changes the
component kind (<a
href="https://github.com/sophiebits"><code>@​sophiebits</code></a> <a
href="https://redirect.github.com/facebook/react/pull/36950">#36950</a>)</li>
<li>Double invoke effects in StrictMode after Fast Refresh (<a
href="https://github.com/eps1lon"><code>@​eps1lon</code></a> <a
href="https://redirect.github.com/facebook/react/pull/35962">#35962</a>)</li>
</ul>
</li>
<li>Performance Track Fixes
<ul>
<li>Prevent crash when accessing <code>$$typeof</code> in Performance
Tracks (<a href="https://github.com/eps1lon"><code>@​eps1lon</code></a>
<a
href="https://redirect.github.com/facebook/react/pull/35679">#35679</a>)</li>
<li>Handle non-string function names in Performance Tracks (<a
href="https://github.com/eps1lon"><code>@​eps1lon</code></a> <a
href="https://redirect.github.com/facebook/react/pull/35659">#35659</a>)</li>
<li>Use minus (<code>-</code>) instead of en dash for removed props in
Performance Tracks (<a
href="https://github.com/eps1lon"><code>@​eps1lon</code></a> <a
href="https://redirect.github.com/facebook/react/pull/35649">#35649</a>)</li>
<li>Handle arrays with bigints in deep objects in Performance Tracks (<a
href="https://github.com/eps1lon"><code>@​eps1lon</code></a> <a
href="https://redirect.github.com/facebook/react/pull/35648">#35648</a>)</li>
<li>Don't enumerate typed array props in Performance Tracks in DEV (<a
href="https://github.com/UditDewan"><code>@​UditDewan</code></a> <a
href="https://redirect.github.com/facebook/react/pull/36913">#36913</a>)</li>
<li>Bail out of diffing wide objects and arrays in Performance Tracks
(<a href="https://github.com/eps1lon"><code>@​eps1lon</code></a> <a
href="https://redirect.github.com/facebook/react/pull/34742">#34742</a>)</li>
<li>Clear potentially large performance measures in DEV (<a
href="https://github.com/hoxyq"><code>@​hoxyq</code></a> <a
href="https://redirect.github.com/facebook/react/pull/34803">#34803</a>)</li>
<li>Fix missing else branch for renders with no props change in
Performance Tracks (<a
href="https://github.com/hoxyq"><code>@​hoxyq</code></a> <a
href="https://redirect.github.com/facebook/react/pull/34837">#34837</a>)</li>
</ul>
</li>
<li>Activity Fixes
<ul>
<li>Fix <code>useSyncExternalStore</code> missing store mutations that
happened while an Activity tree was hidden (<a
href="https://github.com/sophiebits"><code>@​sophiebits</code></a> <a
href="https://redirect.github.com/facebook/react/pull/36947">#36947</a>)</li>
<li>Hide portal contents when an Activity is hidden (<a
href="https://github.com/acdlite"><code>@​acdlite</code></a> <a
href="https://redirect.github.com/facebook/react/pull/35091">#35091</a>)</li>
<li>Prevent metadata hoisting in hidden <code>&lt;Activity&gt;</code>
trees (<a
href="https://github.com/ronnakamoto"><code>@​ronnakamoto</code></a> <a
href="https://redirect.github.com/facebook/react/pull/34983">#34983</a>)</li>
<li>Prevent errors thrown inside a hidden Activity from escaping to the
visible UI (<a
href="https://github.com/acdlite"><code>@​acdlite</code></a> <a
href="https://redirect.github.com/facebook/react/pull/35074">#35074</a>)</li>
<li>Don't unhide a node if a direct parent Offscreen is still hidden (<a
href="https://github.com/sebmarkbage"><code>@​sebmarkbage</code></a> <a
href="https://redirect.github.com/facebook/react/pull/34821">#34821</a>)</li>
<li>Don't show internal <code>&lt;Offscreen&gt;</code> component in
error messages (<a
href="https://github.com/rickhanlonii"><code>@​rickhanlonii</code></a>
<a
href="https://redirect.github.com/facebook/react/pull/35763">#35763</a>)</li>
</ul>
</li>
<li>Warn in DEV when a component appears to have been unblocked by a
conditional <code>use()</code> (<a
href="https://github.com/hoxyq"><code>@​hoxyq</code></a>, <a
href="https://github.com/eps1lon"><code>@​eps1lon</code></a>: <a
href="https://redirect.github.com/facebook/react/pull/37104">#37104</a>,
<a
href="https://redirect.github.com/facebook/react/pull/37203">#37203</a>,
<a
href="https://redirect.github.com/facebook/react/pull/37491">#37491</a>)</li>
<li>Render transitions independently instead of entangling them into a
single render (<a
href="https://github.com/acdlite"><code>@​acdlite</code></a> <a
href="https://redirect.github.com/facebook/react/pull/37290">#37290</a>)</li>
<li>Fix hang when updating a dehydrated boundary inside a hidden tree
(<a href="https://github.com/gaearon"><code>@​gaearon</code></a> <a
href="https://redirect.github.com/facebook/react/pull/37135">#37135</a>)</li>
<li>Don't reacquire Host Singletons during dev effect validation (<a
href="https://github.com/gnoff"><code>@​gnoff</code></a> <a
href="https://redirect.github.com/facebook/react/pull/37113">#37113</a>)</li>
<li>Only remove properties from Host Singletons on release (<a
href="https://github.com/gnoff"><code>@​gnoff</code></a> <a
href="https://redirect.github.com/facebook/react/pull/37112">#37112</a>)</li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/react/react/commit/2dc7da790d6388b95b83198ca9b588b2ad5f5c0b"><code>2dc7da7</code></a>
[test] Bump Jest to 30.4 (<a
href="https://github.com/react/react/tree/HEAD/packages/react/issues/37382">#37382</a>)</li>
<li><a
href="https://github.com/react/react/commit/4f9389423b7319e1f7acc3d158c84a8365462748"><code>4f93894</code></a>
docs: remove stale parentType param from validateChildKeys JSDoc (<a
href="https://github.com/react/react/tree/HEAD/packages/react/issues/36928">#36928</a>)</li>
<li><a
href="https://github.com/react/react/commit/dbc37501ffeaf8fec45af5898caf1c3d64ad10bf"><code>dbc3750</code></a>
Update required references to GitHub repo (<a
href="https://github.com/react/react/tree/HEAD/packages/react/issues/36752">#36752</a>)</li>
<li><a
href="https://github.com/react/react/commit/900ae094d85b11c67d53dd14af50a2bda5db4495"><code>900ae09</code></a>
[flow] Bump flow to v0.317.0 (<a
href="https://github.com/react/react/tree/HEAD/packages/react/issues/36701">#36701</a>)</li>
<li><a
href="https://github.com/react/react/commit/fbb137059e4aacfaab1d36516e9b55050b4a0454"><code>fbb1370</code></a>
[flow] Bump flow to v0.307.1 (<a
href="https://github.com/react/react/tree/HEAD/packages/react/issues/36199">#36199</a>)</li>
<li><a
href="https://github.com/react/react/commit/56922cf751fab6c7ab4c12ddbbd15839959fa255"><code>56922cf</code></a>
[react-native-renderer] Delete Paper (legacy) renderer (<a
href="https://github.com/react/react/tree/HEAD/packages/react/issues/36285">#36285</a>)</li>
<li><a
href="https://github.com/react/react/commit/74568e8627aa43469b74f2972f427a209639d0b6"><code>74568e8</code></a>
[Flight] Transport <code>AggregateErrors.errors</code> (<a
href="https://github.com/react/react/tree/HEAD/packages/react/issues/36156">#36156</a>)</li>
<li><a
href="https://github.com/react/react/commit/e66ef6480ecd19c6885f2c06dec34fec1fdc0a98"><code>e66ef64</code></a>
[tests] remove withoutStack from assertConsole helpers (<a
href="https://github.com/react/react/tree/HEAD/packages/react/issues/35498">#35498</a>)</li>
<li><a
href="https://github.com/react/react/commit/db71391c5c70dc113560d1c23d0b6548604d827f"><code>db71391</code></a>
[Fiber] Instrument the lazy initializer thenable in all cases (<a
href="https://github.com/react/react/tree/HEAD/packages/react/issues/35521">#35521</a>)</li>
<li><a
href="https://github.com/react/react/commit/3e1abcc8d7083a13adf4774feb0d67ecbe4a2bc4"><code>3e1abcc</code></a>
[tests] Require exact error messages in assertConsole helpers (<a
href="https://github.com/react/react/tree/HEAD/packages/react/issues/35497">#35497</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/react/react/commits/v19.3.0/packages/react">compare
view</a></li>
</ul>
</details>
<br />

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

Updates `react-dom` from 19.2.8 to 19.3.0
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/react/react/releases">react-dom's
releases</a>.</em></p>
<blockquote>
<h2>19.3.0 (September 9, 2026)</h2>
<p>Below is a list of all new features, APIs, and bug fixes.</p>
<p>Read the <a href="https://react.dev/blog/2026/09/09/react-19-3">React
19.3 release post</a> for more information.</p>
<h2>New React Features</h2>
<ul>
<li><code>&lt;ViewTransition /&gt;</code>: Adds <code>&lt;ViewTransition
/&gt;</code> and <code>addTransitionType</code> APIs to power View
Transition animations in React (<a
href="https://github.com/sebmarkbage"><code>@​sebmarkbage</code></a>, <a
href="https://github.com/jackpope"><code>@​jackpope</code></a>, <a
href="https://github.com/gaearon"><code>@​gaearon</code></a>: <a
href="https://redirect.github.com/facebook/react/pull/31975">#31975</a>,
<a
href="https://redirect.github.com/facebook/react/pull/31987">#31987</a>,
<a
href="https://redirect.github.com/facebook/react/pull/31996">#31996</a>,
<a
href="https://redirect.github.com/facebook/react/pull/31999">#31999</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32001">#32001</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32002">#32002</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32028">#32028</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32029">#32029</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32031">#32031</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32034">#32034</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32038">#32038</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32041">#32041</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32050">#32050</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32090">#32090</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32105">#32105</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32254">#32254</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32379">#32379</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32422">#32422</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32462">#32462</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32540">#32540</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32545">#32545</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32585">#32585</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32599">#32599</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32611">#32611</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32612">#32612</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32617">#32617</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32651">#32651</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32653">#32653</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32656">#32656</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32664">#32664</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32699">#32699</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32723">#32723</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32734">#32734</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32751">#32751</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32752">#32752</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32760">#32760</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32761">#32761</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32764">#32764</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32772">#32772</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32790">#32790</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32819">#32819</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32820">#32820</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32822">#32822</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32833">#32833</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32849">#32849</a>,
<a
href="https://redirect.github.com/facebook/react/pull/33094">#33094</a>,
<a
href="https://redirect.github.com/facebook/react/pull/33191">#33191</a>,
<a
href="https://redirect.github.com/facebook/react/pull/33200">#33200</a>,
<a
href="https://redirect.github.com/facebook/react/pull/33206">#33206</a>,
<a
href="https://redirect.github.com/facebook/react/pull/33293">#33293</a>,
<a
href="https://redirect.github.com/facebook/react/pull/33330">#33330</a>,
<a
href="https://redirect.github.com/facebook/react/pull/33331">#33331</a>,
<a
href="https://redirect.github.com/facebook/react/pull/33332">#33332</a>,
<a
href="https://redirect.github.com/facebook/react/pull/33357">#33357</a>,
<a
href="https://redirect.github.com/facebook/react/pull/33362">#33362</a>,
<a
href="https://redirect.github.com/facebook/react/pull/33433">#33433</a>,
<a
href="https://redirect.github.com/facebook/react/pull/33576">#33576</a>,
<a
href="https://redirect.github.com/facebook/react/pull/34374">#34374</a>,
<a
href="https://redirect.github.com/facebook/react/pull/34450">#34450</a>,
<a
href="https://redirect.github.com/facebook/react/pull/34481">#34481</a>,
<a
href="https://redirect.github.com/facebook/react/pull/34500">#34500</a>,
<a
href="https://redirect.github.com/facebook/react/pull/34502">#34502</a>,
<a
href="https://redirect.github.com/facebook/react/pull/34510">#34510</a>,
<a
href="https://redirect.github.com/facebook/react/pull/34511">#34511</a>,
<a
href="https://redirect.github.com/facebook/react/pull/34539">#34539</a>,
<a
href="https://redirect.github.com/facebook/react/pull/35567">#35567</a>,
<a
href="https://redirect.github.com/facebook/react/pull/35564">#35564</a>,
<a
href="https://redirect.github.com/facebook/react/pull/35485">#35485</a>,
<a
href="https://redirect.github.com/facebook/react/pull/35380">#35380</a>,
<a
href="https://redirect.github.com/facebook/react/pull/35063">#35063</a>,
<a
href="https://redirect.github.com/facebook/react/pull/35060">#35060</a>,
<a
href="https://redirect.github.com/facebook/react/pull/34676">#34676</a>,
<a
href="https://redirect.github.com/facebook/react/pull/36917">#36917</a>,
<a
href="https://redirect.github.com/facebook/react/pull/35337">#35337</a>,
<a
href="https://redirect.github.com/facebook/react/pull/35520">#35520</a>)</li>
<li>Fragment Refs: Add Refs to <code>&lt;Fragment /&gt;</code> to
support composable platform behavior (<a
href="https://github.com/jackpope"><code>@​jackpope</code></a>, <a
href="https://github.com/sebmarkbage"><code>@​sebmarkbage</code></a>, <a
href="https://github.com/eps1lon"><code>@​eps1lon</code></a>, <a
href="https://github.com/Dhakshin2007"><code>@​Dhakshin2007</code></a>,
<a href="https://github.com/chirokas"><code>@​chirokas</code></a>, <a
href="https://github.com/teamleaderleo"><code>@​teamleaderleo</code></a>,
<a
href="https://github.com/fallintoplace"><code>@​fallintoplace</code></a>:
<a
href="https://redirect.github.com/facebook/react/pull/32465">#32465</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32613">#32613</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32619">#32619</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32654">#32654</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32660">#32660</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32682">#32682</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32722">#32722</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32813">#32813</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32814">#32814</a>,
<a
href="https://redirect.github.com/facebook/react/pull/33056">#33056</a>,
<a
href="https://redirect.github.com/facebook/react/pull/33058">#33058</a>,
<a
href="https://redirect.github.com/facebook/react/pull/33093">#33093</a>,
<a
href="https://redirect.github.com/facebook/react/pull/34069">#34069</a>,
<a
href="https://redirect.github.com/facebook/react/pull/34103">#34103</a>,
<a
href="https://redirect.github.com/facebook/react/pull/34544">#34544</a>,
<a
href="https://redirect.github.com/facebook/react/pull/34545">#34545</a>,
<a
href="https://redirect.github.com/facebook/react/pull/37062">#37062</a>,
<a
href="https://redirect.github.com/facebook/react/pull/37061">#37061</a>,
<a
href="https://redirect.github.com/facebook/react/pull/37060">#37060</a>,
<a
href="https://redirect.github.com/facebook/react/pull/36047">#36047</a>,
<a
href="https://redirect.github.com/facebook/react/pull/36010">#36010</a>,
<a
href="https://redirect.github.com/facebook/react/pull/35642">#35642</a>,
<a
href="https://redirect.github.com/facebook/react/pull/35641">#35641</a>,
<a
href="https://redirect.github.com/facebook/react/pull/35637">#35637</a>,
<a
href="https://redirect.github.com/facebook/react/pull/35630">#35630</a>,
<a
href="https://redirect.github.com/facebook/react/pull/34935">#34935</a>,
<a
href="https://redirect.github.com/facebook/react/pull/37457">#37457</a>,
<a
href="https://redirect.github.com/facebook/react/pull/37408">#37408</a>,
<a
href="https://redirect.github.com/facebook/react/pull/37326">#37326</a>,
<a
href="https://redirect.github.com/facebook/react/pull/37251">#37251</a>,
<a
href="https://redirect.github.com/facebook/react/pull/37171">#37171</a>,
<a
href="https://redirect.github.com/facebook/react/pull/37169">#37169</a>,
<a
href="https://redirect.github.com/facebook/react/pull/37168">#37168</a>,
<a
href="https://redirect.github.com/facebook/react/pull/37167">#37167</a>,
<a
href="https://redirect.github.com/facebook/react/pull/37166">#37166</a>,
<a
href="https://redirect.github.com/facebook/react/pull/37165">#37165</a>,
<a
href="https://redirect.github.com/facebook/react/pull/37164">#37164</a>,
<a
href="https://redirect.github.com/facebook/react/pull/37163">#37163</a>,
<a
href="https://redirect.github.com/facebook/react/pull/37162">#37162</a>,
<a
href="https://redirect.github.com/facebook/react/pull/37161">#37161</a>,
<a
href="https://redirect.github.com/facebook/react/pull/37160">#37160</a>,
<a
href="https://redirect.github.com/facebook/react/pull/37125">#37125</a>,
<a
href="https://redirect.github.com/facebook/react/pull/37063">#37063</a>)</li>
</ul>
<h2>New React DOM Features</h2>
<ul>
<li><code>browser()</code>: a new <code>react-dom</code> API that
returns a usable which errors during server rendering and resolves in
the browser. <code>use(browser())</code> inside a
<code>&lt;Suspense&gt;</code> boundary marks a subtree as browser-only
without reporting a recoverable error (<a
href="https://github.com/gnoff"><code>@​gnoff</code></a>: <a
href="https://redirect.github.com/facebook/react/pull/37143">#37143</a>,
<a
href="https://redirect.github.com/facebook/react/pull/37241">#37241</a>)
<ul>
<li>Added an <code>onBrowserBailout</code> option to the
<code>react-dom/server</code> APIs to observe when a subtree defers to
the browser (<a href="https://github.com/gnoff"><code>@​gnoff</code></a>
<a
href="https://redirect.github.com/facebook/react/pull/37193">#37193</a>)</li>
</ul>
</li>
</ul>
<h2>Notable changes</h2>
<ul>
<li>Enable Trusted Types API integration (<a
href="https://github.com/rickhanlonii"><code>@​rickhanlonii</code></a>
<a
href="https://redirect.github.com/facebook/react/pull/35816">#35816</a>)</li>
<li>Transitions now render independently instead of being entangled into
a single render, so a slow transition no longer holds up unrelated ones
(<a href="https://github.com/acdlite"><code>@​acdlite</code></a> <a
href="https://redirect.github.com/facebook/react/pull/37290">#37290</a>)</li>
<li>Added a DEV-only warning when a component appears to have been
unblocked by calling <code>use()</code> conditionally (<a
href="https://github.com/hoxyq"><code>@​hoxyq</code></a>, <a
href="https://github.com/eps1lon"><code>@​eps1lon</code></a>: <a
href="https://redirect.github.com/facebook/react/pull/37104">#37104</a>,
<a
href="https://redirect.github.com/facebook/react/pull/37203">#37203</a>,
<a
href="https://redirect.github.com/facebook/react/pull/37491">#37491</a>)</li>
</ul>
<h2>All Changes</h2>
<h3>React</h3>
<ul>
<li>Fast Refresh Fixes
<ul>
<li>Fix Fast Refresh to find and remount edits to components wrapped
behind <code>lazy()</code> (<a
href="https://github.com/sophiebits"><code>@​sophiebits</code></a> <a
href="https://redirect.github.com/facebook/react/pull/36965">#36965</a>)</li>
<li>Fix Fast Refresh so edits to a <code>memo()</code> comparison
function take effect (<a
href="https://github.com/sophiebits"><code>@​sophiebits</code></a> <a
href="https://redirect.github.com/facebook/react/pull/36964">#36964</a>)</li>
<li>Fix Fast Refresh crash when an edit changes the kind of a
component's type (<a
href="https://github.com/sophiebits"><code>@​sophiebits</code></a> <a
href="https://redirect.github.com/facebook/react/pull/36963">#36963</a>)</li>
<li>Unify hot reload type resolution for Fast Refresh (<a
href="https://github.com/sophiebits"><code>@​sophiebits</code></a> <a
href="https://redirect.github.com/facebook/react/pull/36962">#36962</a>)</li>
<li>Fix Fast Refresh to remount correctly when an edit changes the
component kind (<a
href="https://github.com/sophiebits"><code>@​sophiebits</code></a> <a
href="https://redirect.github.com/facebook/react/pull/36950">#36950</a>)</li>
<li>Double invoke effects in StrictMode after Fast Refresh (<a
href="https://github.com/eps1lon"><code>@​eps1lon</code></a> <a
href="https://redirect.github.com/facebook/react/pull/35962">#35962</a>)</li>
</ul>
</li>
<li>Performance Track Fixes
<ul>
<li>Prevent crash when accessing <code>$$typeof</code> in Performance
Tracks (<a href="https://github.com/eps1lon"><code>@​eps1lon</code></a>
<a
href="https://redirect.github.com/facebook/react/pull/35679">#35679</a>)</li>
<li>Handle non-string function names in Performance Tracks (<a
href="https://github.com/eps1lon"><code>@​eps1lon</code></a> <a
href="https://redirect.github.com/facebook/react/pull/35659">#35659</a>)</li>
<li>Use minus (<code>-</code>) instead of en dash for removed props in
Performance Tracks (<a
href="https://github.com/eps1lon"><code>@​eps1lon</code></a> <a
href="https://redirect.github.com/facebook/react/pull/35649">#35649</a>)</li>
<li>Handle arrays with bigints in deep objects in Performance Tracks (<a
href="https://github.com/eps1lon"><code>@​eps1lon</code></a> <a
href="https://redirect.github.com/facebook/react/pull/35648">#35648</a>)</li>
<li>Don't enumerate typed array props in Performance Tracks in DEV (<a
href="https://github.com/UditDewan"><code>@​UditDewan</code></a> <a
href="https://redirect.github.com/facebook/react/pull/36913">#36913</a>)</li>
<li>Bail out of diffing wide objects and arrays in Performance Tracks
(<a href="https://github.com/eps1lon"><code>@​eps1lon</code></a> <a
href="https://redirect.github.com/facebook/react/pull/34742">#34742</a>)</li>
<li>Clear potentially large performance measures in DEV (<a
href="https://github.com/hoxyq"><code>@​hoxyq</code></a> <a
href="https://redirect.github.com/facebook/react/pull/34803">#34803</a>)</li>
<li>Fix missing else branch for renders with no props change in
Performance Tracks (<a
href="https://github.com/hoxyq"><code>@​hoxyq</code></a> <a
href="https://redirect.github.com/facebook/react/pull/34837">#34837</a>)</li>
</ul>
</li>
<li>Activity Fixes
<ul>
<li>Fix <code>useSyncExternalStore</code> missing store mutations that
happened while an Activity tree was hidden (<a
href="https://github.com/sophiebits"><code>@​sophiebits</code></a> <a
href="https://redirect.github.com/facebook/react/pull/36947">#36947</a>)</li>
<li>Hide portal contents when an Activity is hidden (<a
href="https://github.com/acdlite"><code>@​acdlite</code></a> <a
href="https://redirect.github.com/facebook/react/pull/35091">#35091</a>)</li>
<li>Prevent metadata hoisting in hidden <code>&lt;Activity&gt;</code>
trees (<a
href="https://github.com/ronnakamoto"><code>@​ronnakamoto</code></a> <a
href="https://redirect.github.com/facebook/react/pull/34983">#34983</a>)</li>
<li>Prevent errors thrown inside a hidden Activity from escaping to the
visible UI (<a
href="https://github.com/acdlite"><code>@​acdlite</code></a> <a
href="https://redirect.github.com/facebook/react/pull/35074">#35074</a>)</li>
<li>Don't unhide a node if a direct parent Offscreen is still hidden (<a
href="https://github.com/sebmarkbage"><code>@​sebmarkbage</code></a> <a
href="https://redirect.github.com/facebook/react/pull/34821">#34821</a>)</li>
<li>Don't show internal <code>&lt;Offscreen&gt;</code> component in
error messages (<a
href="https://github.com/rickhanlonii"><code>@​rickhanlonii</code></a>
<a
href="https://redirect.github.com/facebook/react/pull/35763">#35763</a>)</li>
</ul>
</li>
<li>Warn in DEV when a component appears to have been unblocked by a
conditional <code>use()</code> (<a
href="https://github.com/hoxyq"><code>@​hoxyq</code></a>, <a
href="https://github.com/eps1lon"><code>@​eps1lon</code></a>: <a
href="https://redirect.github.com/facebook/react/pull/37104">#37104</a>,
<a
href="https://redirect.github.com/facebook/react/pull/37203">#37203</a>,
<a
href="https://redirect.github.com/facebook/react/pull/37491">#37491</a>)</li>
<li>Render transitions independently instead of entangling them into a
single render (<a
href="https://github.com/acdlite"><code>@​acdlite</code></a> <a
href="https://redirect.github.com/facebook/react/pull/37290">#37290</a>)</li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/react/react/blob/main/CHANGELOG.md">react-dom's
changelog</a>.</em></p>
<blockquote>
<h2>19.3.0 (September 9, 2026)</h2>
<h3>New React Features</h3>
<ul>
<li><code>&lt;ViewTransition /&gt;</code>: Adds <code>&lt;ViewTransition
/&gt;</code> and <code>addTransitionType</code> APIs to power View
Transition animations in React (<a
href="https://github.com/sebmarkbage"><code>@​sebmarkbage</code></a>, <a
href="https://github.com/jackpope"><code>@​jackpope</code></a>, <a
href="https://github.com/gaearon"><code>@​gaearon</code></a>: <a
href="https://redirect.github.com/facebook/react/pull/31975">#31975</a>,
<a
href="https://redirect.github.com/facebook/react/pull/31987">#31987</a>,
<a
href="https://redirect.github.com/facebook/react/pull/31996">#31996</a>,
<a
href="https://redirect.github.com/facebook/react/pull/31999">#31999</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32001">#32001</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32002">#32002</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32028">#32028</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32029">#32029</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32031">#32031</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32034">#32034</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32038">#32038</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32041">#32041</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32050">#32050</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32090">#32090</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32105">#32105</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32254">#32254</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32379">#32379</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32422">#32422</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32462">#32462</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32540">#32540</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32545">#32545</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32585">#32585</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32599">#32599</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32611">#32611</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32612">#32612</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32617">#32617</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32651">#32651</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32653">#32653</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32656">#32656</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32664">#32664</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32699">#32699</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32723">#32723</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32734">#32734</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32751">#32751</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32752">#32752</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32760">#32760</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32761">#32761</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32764">#32764</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32772">#32772</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32790">#32790</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32819">#32819</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32820">#32820</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32822">#32822</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32833">#32833</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32849">#32849</a>,
<a
href="https://redirect.github.com/facebook/react/pull/33094">#33094</a>,
<a
href="https://redirect.github.com/facebook/react/pull/33191">#33191</a>,
<a
href="https://redirect.github.com/facebook/react/pull/33200">#33200</a>,
<a
href="https://redirect.github.com/facebook/react/pull/33206">#33206</a>,
<a
href="https://redirect.github.com/facebook/react/pull/33293">#33293</a>,
<a
href="https://redirect.github.com/facebook/react/pull/33330">#33330</a>,
<a
href="https://redirect.github.com/facebook/react/pull/33331">#33331</a>,
<a
href="https://redirect.github.com/facebook/react/pull/33332">#33332</a>,
<a
href="https://redirect.github.com/facebook/react/pull/33357">#33357</a>,
<a
href="https://redirect.github.com/facebook/react/pull/33362">#33362</a>,
<a
href="https://redirect.github.com/facebook/react/pull/33433">#33433</a>,
<a
href="https://redirect.github.com/facebook/react/pull/33576">#33576</a>,
<a
href="https://redirect.github.com/facebook/react/pull/34374">#34374</a>,
<a
href="https://redirect.github.com/facebook/react/pull/34450">#34450</a>,
<a
href="https://redirect.github.com/facebook/react/pull/34481">#34481</a>,
<a
href="https://redirect.github.com/facebook/react/pull/34500">#34500</a>,
<a
href="https://redirect.github.com/facebook/react/pull/34502">#34502</a>,
<a
href="https://redirect.github.com/facebook/react/pull/34510">#34510</a>,
<a
href="https://redirect.github.com/facebook/react/pull/34511">#34511</a>,
<a
href="https://redirect.github.com/facebook/react/pull/34539">#34539</a>,
<a
href="https://redirect.github.com/facebook/react/pull/35567">#35567</a>,
<a
href="https://redirect.github.com/facebook/react/pull/35564">#35564</a>,
<a
href="https://redirect.github.com/facebook/react/pull/35485">#35485</a>,
<a
href="https://redirect.github.com/facebook/react/pull/35380">#35380</a>,
<a
href="https://redirect.github.com/facebook/react/pull/35063">#35063</a>,
<a
href="https://redirect.github.com/facebook/react/pull/35060">#35060</a>,
<a
href="https://redirect.github.com/facebook/react/pull/34676">#34676</a>,
<a
href="https://redirect.github.com/facebook/react/pull/36917">#36917</a>,
<a
href="https://redirect.github.com/facebook/react/pull/35337">#35337</a>,
<a
href="https://redirect.github.com/facebook/react/pull/35520">#35520</a>)</li>
<li>Fragment Refs: Add Refs to <code>&lt;Fragment /&gt;</code> to
support composable platform behavior (<a
href="https://github.com/jackpope"><code>@​jackpope</code></a>, <a
href="https://github.com/sebmarkbage"><code>@​sebmarkbage</code></a>, <a
href="https://github.com/eps1lon"><code>@​eps1lon</code></a>, <a
href="https://github.com/Dhakshin2007"><code>@​Dhakshin2007</code></a>,
<a href="https://github.com/chirokas"><code>@​chirokas</code></a>, <a
href="https://github.com/teamleaderleo"><code>@​teamleaderleo</code></a>,
<a
href="https://github.com/fallintoplace"><code>@​fallintoplace</code></a>:
<a
href="https://redirect.github.com/facebook/react/pull/32465">#32465</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32613">#32613</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32619">#32619</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32654">#32654</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32660">#32660</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32682">#32682</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32722">#32722</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32813">#32813</a>,
<a
href="https://redirect.github.com/facebook/react/pull/32814">#32814</a>,
<a
href="https://redirect.github.com/facebook/react/pull/33056">#33056</a>,
<a
href="https://redirect.github.com/facebook/react/pull/33058">#33058</a>,
<a
href="https://redirect.github.com/facebook/react/pull/33093">#33093</a>,
<a
href="https://redirect.github.com/facebook/react/pull/34069">#34069</a>,
<a href="https://redirect...

_Description has been truncated_

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-19 23:19:07 -04:00
dependabot[bot] 83f86242b4 build(deps): bump the docker-base-image-digests group with 2 updates (#1348)
> [!WARNING]
> Cooldown could not be applied because no publication date was
available from the registry.
>

Bumps the docker-base-image-digests group with 2 updates: node and
[astral-sh/uv](https://github.com/astral-sh/uv).

Updates `node` from `50c8e8c` to `ebfe2f9`

Updates `astral-sh/uv` from 0.12.13 to 0.12.16
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/astral-sh/uv/releases">astral-sh/uv's
releases</a>.</em></p>
<blockquote>
<h2>0.12.16</h2>
<h2>Release Notes</h2>
<p>Released on 2026-09-17.</p>
<h3>Python</h3>
<ul>
<li>Add Pyodide 314.0.7, 0.29.5, and 0.27.8 (<a
href="https://redirect.github.com/astral-sh/uv/pull/21741">#21741</a>)</li>
</ul>
<h3>Enhancements</h3>
<ul>
<li>Verify downloaded wheels and source distributions against hashes
supplied by package indexes (<a
href="https://redirect.github.com/astral-sh/uv/pull/21562">#21562</a>)</li>
<li>Allow <code>build-constraint-dependencies</code> entries to include
hashes for verifying downloaded build dependencies (<a
href="https://redirect.github.com/astral-sh/uv/pull/21467">#21467</a>)</li>
<li>Honor Darwin <code>platform_release</code> markers in
<code>required-environments</code> using macOS wheel deployment targets
(<a
href="https://redirect.github.com/astral-sh/uv/pull/21766">#21766</a>)</li>
<li>Reject unsupported Git URL schemes while parsing lockfiles instead
of panicking during frozen exports (<a
href="https://redirect.github.com/astral-sh/uv/pull/21779">#21779</a>)</li>
</ul>
<h3>Preview features</h3>
<ul>
<li>Support <code>lock-without-metadata</code> across all dependency
types while retaining <code>package.metadata</code> for remote URL
dependencies to enable offline validation (<a
href="https://redirect.github.com/astral-sh/uv/pull/21163">#21163</a>)</li>
<li>Honor configured and command-line index settings, including
credentials, in <code>uv upgrade</code> (<a
href="https://redirect.github.com/astral-sh/uv/pull/21776">#21776</a>)</li>
<li>Allow <code>uv check</code> to run in projects that are not managed
by uv and outside workspaces (<a
href="https://redirect.github.com/astral-sh/uv/pull/21777">#21777</a>)</li>
<li>Respect <code>--python</code> and <code>UV_PYTHON</code> when
selecting the Python version for <code>uv check</code> (<a
href="https://redirect.github.com/astral-sh/uv/pull/21744">#21744</a>)</li>
</ul>
<h3>Bug fixes</h3>
<ul>
<li>Redact Azure shared access signatures from displayed and logged URLs
(<a
href="https://redirect.github.com/astral-sh/uv/pull/21755">#21755</a>)</li>
<li>Check archive sizes from <code>pylock.toml</code> before reusing
cached distributions (<a
href="https://redirect.github.com/astral-sh/uv/pull/21609">#21609</a>)</li>
<li>Keep user-authored local dependency paths relative in lockfiles when
backend metadata reports absolute paths (<a
href="https://redirect.github.com/astral-sh/uv/pull/20631">#20631</a>)</li>
<li>Use the bundled <code>uv_build</code> backend only when its version
matches active version pins (<a
href="https://redirect.github.com/astral-sh/uv/pull/21742">#21742</a>)</li>
<li>Handle malformed index URLs without panicking when credentials are
configured (<a
href="https://redirect.github.com/astral-sh/uv/pull/21784">#21784</a>)</li>
<li>Report a configuration error instead of panicking for proxy URLs
without a host (<a
href="https://redirect.github.com/astral-sh/uv/pull/21781">#21781</a>)</li>
<li>Return a credential-redacted error instead of panicking when a URL
cannot be converted to a path (<a
href="https://redirect.github.com/astral-sh/uv/pull/21783">#21783</a>)</li>
</ul>
<h2>Install uv 0.12.16</h2>
<h3>Install prebuilt binaries via shell script</h3>
<pre lang="sh"><code>curl --proto '=https' --tlsv1.2 -LsSf
https://releases.astral.sh/github/uv/releases/download/0.12.16/uv-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/uv/releases/download/0.12.16/uv-installer.ps1
| iex&quot;
</code></pre>
<h2>Download uv 0.12.16</h2>
<p>|  File  | Platform | Checksum |</p>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/astral-sh/uv/blob/main/CHANGELOG.md">astral-sh/uv's
changelog</a>.</em></p>
<blockquote>
<h2>0.12.16</h2>
<p>Released on 2026-09-17.</p>
<h3>Python</h3>
<ul>
<li>Add Pyodide 314.0.7, 0.29.5, and 0.27.8 (<a
href="https://redirect.github.com/astral-sh/uv/pull/21741">#21741</a>)</li>
</ul>
<h3>Enhancements</h3>
<ul>
<li>Verify downloaded wheels and source distributions against hashes
supplied by package indexes (<a
href="https://redirect.github.com/astral-sh/uv/pull/21562">#21562</a>)</li>
<li>Allow <code>build-constraint-dependencies</code> entries to include
hashes for verifying downloaded build dependencies (<a
href="https://redirect.github.com/astral-sh/uv/pull/21467">#21467</a>)</li>
<li>Honor Darwin <code>platform_release</code> markers in
<code>required-environments</code> using macOS wheel deployment targets
(<a
href="https://redirect.github.com/astral-sh/uv/pull/21766">#21766</a>)</li>
<li>Reject unsupported Git URL schemes while parsing lockfiles instead
of panicking during frozen exports (<a
href="https://redirect.github.com/astral-sh/uv/pull/21779">#21779</a>)</li>
</ul>
<h3>Preview features</h3>
<ul>
<li>Support <code>lock-without-metadata</code> across all dependency
types while retaining <code>package.metadata</code> for remote URL
dependencies to enable offline validation (<a
href="https://redirect.github.com/astral-sh/uv/pull/21163">#21163</a>)</li>
<li>Honor configured and command-line index settings, including
credentials, in <code>uv upgrade</code> (<a
href="https://redirect.github.com/astral-sh/uv/pull/21776">#21776</a>)</li>
<li>Allow <code>uv check</code> to run in projects that are not managed
by uv and outside workspaces (<a
href="https://redirect.github.com/astral-sh/uv/pull/21777">#21777</a>)</li>
<li>Respect <code>--python</code> and <code>UV_PYTHON</code> when
selecting the Python version for <code>uv check</code> (<a
href="https://redirect.github.com/astral-sh/uv/pull/21744">#21744</a>)</li>
</ul>
<h3>Bug fixes</h3>
<ul>
<li>Redact Azure shared access signatures from displayed and logged URLs
(<a
href="https://redirect.github.com/astral-sh/uv/pull/21755">#21755</a>)</li>
<li>Check archive sizes from <code>pylock.toml</code> before reusing
cached distributions (<a
href="https://redirect.github.com/astral-sh/uv/pull/21609">#21609</a>)</li>
<li>Keep user-authored local dependency paths relative in lockfiles when
backend metadata reports absolute paths (<a
href="https://redirect.github.com/astral-sh/uv/pull/20631">#20631</a>)</li>
<li>Use the bundled <code>uv_build</code> backend only when its version
matches active version pins (<a
href="https://redirect.github.com/astral-sh/uv/pull/21742">#21742</a>)</li>
<li>Handle malformed index URLs without panicking when credentials are
configured (<a
href="https://redirect.github.com/astral-sh/uv/pull/21784">#21784</a>)</li>
<li>Report a configuration error instead of panicking for proxy URLs
without a host (<a
href="https://redirect.github.com/astral-sh/uv/pull/21781">#21781</a>)</li>
<li>Return a credential-redacted error instead of panicking when a URL
cannot be converted to a path (<a
href="https://redirect.github.com/astral-sh/uv/pull/21783">#21783</a>)</li>
</ul>
<h2>0.12.15</h2>
<p>Released on 2026-09-15.</p>
<h3>Performance</h3>
<ul>
<li>Speed up cold-cache resolution and HTTP cache revalidation by
batching cache writes (<a
href="https://redirect.github.com/astral-sh/uv/pull/21675">#21675</a>)</li>
</ul>
<h3>Bug fixes</h3>
<ul>
<li>Fix regressions in <code>0.12.14</code> when installing to symlinked
destinations or using <code>uv pip install --target .</code> (<a
href="https://redirect.github.com/astral-sh/uv/pull/21699">#21699</a>)</li>
</ul>
<h2>0.12.14</h2>
<p>Released on 2026-09-15.</p>
<h3>Enhancements</h3>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/astral-sh/uv/commit/761ff1379b3b79f61fc8d421dfe4fe064834e084"><code>761ff13</code></a>
Bump version to 0.12.16 (<a
href="https://redirect.github.com/astral-sh/uv/issues/21809">#21809</a>)</li>
<li><a
href="https://github.com/astral-sh/uv/commit/a2f820ad0cebb7118f7686be3619d48df5121f6e"><code>a2f820a</code></a>
Assign release pull requests to the workflow initiator (<a
href="https://redirect.github.com/astral-sh/uv/issues/21808">#21808</a>)</li>
<li><a
href="https://github.com/astral-sh/uv/commit/6dffe7e03898409c04e2f46436bffa60abbae465"><code>6dffe7e</code></a>
Ignore <code>UV_NATIVE_TLS</code> when <code>UV_SYSTEM_CERTS</code> is
set (<a
href="https://redirect.github.com/astral-sh/uv/issues/21805">#21805</a>)</li>
<li><a
href="https://github.com/astral-sh/uv/commit/7bc36767ae3ea0d45e3cc6f219765fdec2992a33"><code>7bc3676</code></a>
Avoid warning when both <code>native-tls</code> and
<code>system-certs</code> are configured (<a
href="https://redirect.github.com/astral-sh/uv/issues/21806">#21806</a>)</li>
<li><a
href="https://github.com/astral-sh/uv/commit/07b838a33179967b87d513825b02504a75b21b3a"><code>07b838a</code></a>
Avoid warning when both <code>UV_NATIVE_TLS</code> and
<code>UV_SYSTEM_CERTS</code> are set (<a
href="https://redirect.github.com/astral-sh/uv/issues/21788">#21788</a>)</li>
<li><a
href="https://github.com/astral-sh/uv/commit/5d64ede21e9e835e59ecd85329ebefd43faa8211"><code>5d64ede</code></a>
Remove Hash API (<a
href="https://redirect.github.com/astral-sh/uv/issues/21786">#21786</a>)</li>
<li><a
href="https://github.com/astral-sh/uv/commit/9ddc4308551335e9c2ab6571ea0cf529eccf3e76"><code>9ddc430</code></a>
Move shared thread initialization into uv-threads (<a
href="https://redirect.github.com/astral-sh/uv/issues/21746">#21746</a>)</li>
<li><a
href="https://github.com/astral-sh/uv/commit/ef1e0689b4612796d7781ebeae7866028c6c9e82"><code>ef1e068</code></a>
Make Git stamping opt-in for development builds (<a
href="https://redirect.github.com/astral-sh/uv/issues/21750">#21750</a>)</li>
<li><a
href="https://github.com/astral-sh/uv/commit/6ce09770cec30b30cdc66c3595517dce2b112562"><code>6ce0977</code></a>
Avoid panics for non-base index URLs (<a
href="https://redirect.github.com/astral-sh/uv/issues/21784">#21784</a>)</li>
<li><a
href="https://github.com/astral-sh/uv/commit/47f19ec154f325ade7d6045c4e785531c24635f7"><code>47f19ec</code></a>
Return an error for failed VerbatimUrl path conversions (<a
href="https://redirect.github.com/astral-sh/uv/issues/21783">#21783</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/astral-sh/uv/compare/0.12.13...0.12.16">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-19 23:18:59 -04:00
splitsec2andInfiniteAvenger 44f4e13cce refactor: extract the per-source release search out of /api/releases (#1355)
The `/api/releases` route carries an inner `_search_source_releases`
helper that builds the search plan for one source, logs the planned
query type, runs the search and turns
`SourceUnavailableError`/operational errors into an error message
instead of raising. Anything outside the route that wants to search one
source with exactly those semantics has to go through Flask today.

This moves that helper into `shelfmark/core/release_search.py` as
`search_source_releases()` and has the route delegate to it. Behaviour
is unchanged: same plan construction (including the caller's `user_id`,
so per-user default languages still apply), same logging, same
error-to-message handling.

It is the refactor half of #1047 by @InfiniteAvenger, split out on its
own as you asked for other PRs (#1318). Their authorship is preserved on
the commit; I rebased it onto current `main` and added tests.

## Verification

- `tests/core/test_release_search.py`: unknown source → `"Unknown
source: …"`, `SourceUnavailableError` and operational errors →
`"<source>: <error>"`, success path forwards `expand_search` /
`content_type` and returns the source instance, the plan receives
languages / manual query / indexers / `user_id`. (These tests are
type-annotated; happy to strip the annotations if you prefer the suite's
bare style.)
- Full suite, ruff, ruff format, basedpyright, vulture green; the
existing `/api/releases` route tests are unchanged and pass.

Co-authored-by: InfiniteAvenger <calebewest02@gmail.com>
2026-09-19 23:16:53 -04:00
Marcelo Rodrigo b7002a6eca feat: Add TorBox client support and settings integration (#1342)
Add **TorBox** as a torrent download client for Prowlarr releases.

Users can select `TorBox` in the download client settings, configure it
with the new `TORBOX_API_KEY` environment variable, and verify their
credentials with the connection test button.

The integration supports both magnet links and `.torrent` files. It
tracks the torrent lifecycle through TorBox, downloads supported book
and audiobook files from the TorBox CDN, preserves safe nested file
paths, and cleans up remote and local download state.

Important: Shared HTTP download logs omit full download URLs and
URL-bearing exception text to avoid exposing credentials, following best
practices. This applies to all clients that use the shared
`download_url()` path; URLs remain available to the HTTP operations
themselves.

---
There is already related work in progress in #1173, which includes both
torrent and direct-download support for TorBox.

This PR is not intended to replace or compete with that contribution. It
offers the tested torrent client functionality as a smaller, focused
change that can make TorBox available to the community sooner. The
direct-download integration proposed in #1173 remains valuable and could
be reviewed or introduced separately.

Automated tests cover configuration, connection validation, magnet and
torrent-file submission, API errors, status and progress handling, file
retrieval, path traversal protection, cancellation, cleanup, and
sensitive URL redaction.

I also validated the complete flow locally with several magnet links and
`.torrent` downloads. TorBox processed the torrents and Shelfmark
downloaded the resulting files as expected.

AI was used to help with the implementation, with human validation. This
PR and long description? Took me some good minutes at night after work,
but gives me joy to open this PR to share with the community this
improvement.
2026-09-19 23:15:57 -04:00
Paul Rimmer aafce2be1d Added the ability to sort direct search results by Most downloads (#1351)
Now that we know about the AA Downloads stats for search results
(#1336), this PR allows sorting them locally by "Most downloads" without
doing another AA search. Not needing to do an AA search for this is
faster than the other sorts which do new AA searches asking for their
results to be sorted a certain way. Also added the "Settings->Search
Mode->Default Sort Order->Most downloads" option. Universal downloads do
not allow for results sorting so I did not include this feature there.

NOTE: While adding this feature I noticed that shelfmark only looks at
the first page of AA search results. So switching between different sort
orders may result in different sets of results with the same search
term.

Here's a couple of pics showing the new sort option:

<img width="1186" height="759"
alt="search-mode-sort-order-most-downloads"
src="https://github.com/user-attachments/assets/c43fe2e7-84ba-48cd-a850-383f1af0d28f"
/>

<img width="1172" height="828"
alt="direct-search-results-most-downloads"
src="https://github.com/user-attachments/assets/4e240180-6a9d-4ce1-ab86-006b286570dd"
/>

Coded with llama.cpp, opencode and 🤖
2026-09-19 23:12:36 -04:00
dependabot[bot] cdd001bdd9 build(deps): bump python-socketio from 5.16.4 to 5.17.0 in the python-deps group (#1347)
Bumps the python-deps group with 1 update:
[python-socketio](https://github.com/sponsors/miguelgrinberg).

Updates `python-socketio` from 5.16.4 to 5.17.0
<details>
<summary>Commits</summary>
<ul>
<li>See full diff in <a
href="https://github.com/sponsors/miguelgrinberg/commits">compare
view</a></li>
</ul>
</details>
<br />


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

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

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

---

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

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


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-18 00:14:55 -04:00
CaliBrain e5dd34ae0e fix: unbreak main and follow up on the Blackhole handoff review (#1346)
DownloadHistoryService.record_download and updated the single production
caller, but not the eleven in the test suite, leaving main red with 32
failures. Pass None, which is what the pre-#1336 behaviour recorded.

For the Blackhole handoff (#1345): add_download publishes the torrent
before the cancel check runs, and BlackholeClient.remove() is a no-op,
so
the watcher picks the file up regardless. Reporting a bare "Cancelled"
hid
that from the user. Name the completed handoff in the cancellation
message
instead, drop the _handle_cancelled_download call whose usenet branch
cannot apply to a handoff-only client, and record why the orchestrator
no
longer verifies HandoffResult.path.

Finally, make tests/direct_download a package: test_libgen_extract.py
imports tests.libgen.sample_html across test directories, so without an
__init__.py pytest named its modules by bare basename and a same-named
module elsewhere would collide.
2026-09-17 16:27:53 -04:00
dependabot[bot] 38a429acc8 build(deps): bump the python-deps group across 1 directory with 5 updates (#1344)
Bumps the python-deps group with 5 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [tqdm](https://github.com/tqdm/tqdm) | `4.70.0` | `4.70.1` |
| [seleniumbase](https://github.com/seleniumbase/SeleniumBase) |
`4.53.7` | `4.54.5` |
| [basedpyright](https://github.com/detachhead/basedpyright) | `1.40.0`
| `1.40.1` |
| [prek](https://github.com/j178/prek) | `0.5.2` | `0.5.3` |
| [ruff](https://github.com/astral-sh/ruff) | `0.16.6` | `0.16.7` |


Updates `tqdm` from 4.70.0 to 4.70.1
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/tqdm/tqdm/releases">tqdm's
releases</a>.</em></p>
<blockquote>
<h2>tqdm v4.70.1 stable</h2>
<ul>
<li><code>contrib.concurrent</code>: fix no-len iterables (<a
href="https://redirect.github.com/tqdm/tqdm/issues/1830">#1830</a> &lt;-
<a
href="https://redirect.github.com/tqdm/tqdm/issues/1828">#1828</a>)</li>
<li>tests: major overhaul (<a
href="https://redirect.github.com/tqdm/tqdm/issues/1819">#1819</a>)</li>
<li>update AI policy in PR template</li>
<li>misc lint &amp; tidy</li>
<li>CI: bump workflow actions &amp; pre-commit hooks</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/tqdm/tqdm/commit/9cf5a12b1f955468a17f0ba3c59092b23e4258ac"><code>9cf5a12</code></a>
Merge pull request <a
href="https://redirect.github.com/tqdm/tqdm/issues/1830">#1830</a> from
eaubin/master</li>
<li><a
href="https://github.com/tqdm/tqdm/commit/24b9e1e08a097f1c30f2d235b63f9eb25ba47974"><code>24b9e1e</code></a>
misc tidy</li>
<li><a
href="https://github.com/tqdm/tqdm/commit/2a9e4e82ddba07c5dd6d126e23a4745b76ea49c8"><code>2a9e4e8</code></a>
contrib.concurrent: fix no-len iterables</li>
<li><a
href="https://github.com/tqdm/tqdm/commit/8d6ff8de5a9066de77d0a9df3e80a022a3dcf146"><code>8d6ff8d</code></a>
Merge pull request <a
href="https://redirect.github.com/tqdm/tqdm/issues/1819">#1819</a> from
tqdm/devel</li>
<li><a
href="https://github.com/tqdm/tqdm/commit/bed379609c2c8300671c784c8f85a185c8cf6fb3"><code>bed3796</code></a>
tests: major overhaul</li>
<li><a
href="https://github.com/tqdm/tqdm/commit/4d3d3194d5467e0701bc0f4b836a735273ef0868"><code>4d3d319</code></a>
AI policy</li>
<li><a
href="https://github.com/tqdm/tqdm/commit/5dcb022b55553b60ed2131b64aa5bf1e65470df1"><code>5dcb022</code></a>
minor syntax update</li>
<li><a
href="https://github.com/tqdm/tqdm/commit/309effe4244bf45427c5add7f1bcf33f98a7b421"><code>309effe</code></a>
tests: pre-commit coverage</li>
<li><a
href="https://github.com/tqdm/tqdm/commit/604193aa03b5f5c0ad5f3d441951f0fbb515f1e4"><code>604193a</code></a>
tests: slight tidy</li>
<li><a
href="https://github.com/tqdm/tqdm/commit/17f1cac07c89943eb0e6082875b3821205308d4f"><code>17f1cac</code></a>
CI: bump workflow actions</li>
<li>See full diff in <a
href="https://github.com/tqdm/tqdm/compare/v4.70.0...v4.70.1">compare
view</a></li>
</ul>
</details>
<br />

Updates `seleniumbase` from 4.53.7 to 4.54.5
<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.54.5 - MCP Server: Patch 14</h2>
<h2>MCP Server: Patch 14</h2>
<ul>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/753846cca13b6ead76603781b09f725b3236ae79">Update
the MCP Server</a>
--&gt; Going for a top tool score in Glama.</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/fda62a2b83b9de176342f1c21c5f72032ffd4628">Update
the MCP Server ReadMe</a></li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/b6cd1e7c787cbe2c580e624e3576112d0a0de959">Refresh
optional Python dependencies</a></li>
</ul>
<h2>What's Changed</h2>
<ul>
<li>MCP Server: Patch 14 by <a
href="https://github.com/mdmintz"><code>@​mdmintz</code></a> in <a
href="https://redirect.github.com/seleniumbase/SeleniumBase/pull/4501">seleniumbase/SeleniumBase#4501</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/seleniumbase/SeleniumBase/compare/v4.54.4...v4.54.5">https://github.com/seleniumbase/SeleniumBase/compare/v4.54.4...v4.54.5</a></p>
<h2>4.54.4 - MCP Server: Patch 13</h2>
<h2>MCP Server: Patch 13</h2>
<ul>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/ec41b905a0202e13e9ed0b2f767cd755ea0f891c">Update
CDP Mode (add timeout to a few methods)</a>
--&gt; (One of the ReadMe files should've been committed in the next
commit with a different message)</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/eaef20040fe624f2b51e369e1ea8bf5c34bb4d9c">Update
the MCP Server</a>
--&gt; (The MCP ReadMe was updated in the previous commit with the wrong
commit message)</li>
</ul>
<h2>What's Changed</h2>
<ul>
<li>MCP Server: Patch 13 by <a
href="https://github.com/mdmintz"><code>@​mdmintz</code></a> in <a
href="https://redirect.github.com/seleniumbase/SeleniumBase/pull/4500">seleniumbase/SeleniumBase#4500</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/seleniumbase/SeleniumBase/compare/v4.54.3...v4.54.4">https://github.com/seleniumbase/SeleniumBase/compare/v4.54.3...v4.54.4</a></p>
<h2>4.54.3 - MCP Server: Patch 12</h2>
<h2>MCP Server: Patch 12</h2>
<ul>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/0d04ec82f7b2fa22bd84a67ad07a119c08a62d06">Update
the MCP Server</a></li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/f4a86268b1177044e0a4d03348f5fab76838b96b">Update
the MCP Server ReadMe</a></li>
</ul>
<h2>What's Changed</h2>
<ul>
<li>MCP Server: Patch 12 by <a
href="https://github.com/mdmintz"><code>@​mdmintz</code></a> in <a
href="https://redirect.github.com/seleniumbase/SeleniumBase/pull/4499">seleniumbase/SeleniumBase#4499</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/seleniumbase/SeleniumBase/compare/v4.54.2...v4.54.3">https://github.com/seleniumbase/SeleniumBase/compare/v4.54.2...v4.54.3</a></p>
<h2>4.54.2 - MCP Server: Patch 11</h2>
<h2>MCP Server: Patch 11</h2>
<ul>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/7ea1bbfdae22951c3914a902c46a8520253eaf05">Update
CDP Mode</a></li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/1aa89a962424c6f3b930449844665a3fa2628d55">Update
the MCP Server</a></li>
</ul>
<h2>What's Changed</h2>
<ul>
<li>MCP Server: Patch 11 by <a
href="https://github.com/mdmintz"><code>@​mdmintz</code></a> in <a
href="https://redirect.github.com/seleniumbase/SeleniumBase/pull/4498">seleniumbase/SeleniumBase#4498</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/seleniumbase/SeleniumBase/compare/v4.54.1...v4.54.2">https://github.com/seleniumbase/SeleniumBase/compare/v4.54.1...v4.54.2</a></p>
<h2>4.54.1 - MCP Server: Patch 10</h2>
<h2>MCP Server: Patch 10</h2>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/3de06e5e22a2f6fe629f47bf5859bc74eebcae48"><code>3de06e5</code></a>
Merge pull request <a
href="https://redirect.github.com/seleniumbase/SeleniumBase/issues/4501">#4501</a>
from seleniumbase/mcp-server-patch-14</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/4c0aaa7ca4fae594cc2826d3bdbb08cb91ea8ab3"><code>4c0aaa7</code></a>
Version 4.54.5</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/b6cd1e7c787cbe2c580e624e3576112d0a0de959"><code>b6cd1e7</code></a>
Refresh optional Python dependencies</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/37cffca4f6333d87a9f86a3c1322922ddec85d0b"><code>37cffca</code></a>
Update MCP Server versioning</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/fda62a2b83b9de176342f1c21c5f72032ffd4628"><code>fda62a2</code></a>
Update the MCP Server ReadMe</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/753846cca13b6ead76603781b09f725b3236ae79"><code>753846c</code></a>
Update the MCP Server</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/82e3b51cc6941649dda4eed08d7b3efc0eac734a"><code>82e3b51</code></a>
Merge pull request <a
href="https://redirect.github.com/seleniumbase/SeleniumBase/issues/4500">#4500</a>
from seleniumbase/mcp-server-patch-13</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/bc14832f52674fdf9ee574d87bd7b6fed6ae2f8e"><code>bc14832</code></a>
Version 4.54.4</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/8945f2d745770ca06f316e990cd971e76476a1d1"><code>8945f2d</code></a>
Refresh MCP Server versioning</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/eaef20040fe624f2b51e369e1ea8bf5c34bb4d9c"><code>eaef200</code></a>
Update the MCP Server</li>
<li>Additional commits viewable in <a
href="https://github.com/seleniumbase/SeleniumBase/compare/v4.53.7...v4.54.5">compare
view</a></li>
</ul>
</details>
<br />

Updates `basedpyright` from 1.40.0 to 1.40.1
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/detachhead/basedpyright/releases">basedpyright's
releases</a>.</em></p>
<blockquote>
<h2>v1.40.1 (pyright 1.1.414)</h2>
<h2>What's Changed</h2>
<ul>
<li>Merge 1.1.414 by <a
href="https://github.com/DetachHead"><code>@​DetachHead</code></a> in <a
href="https://redirect.github.com/DetachHead/basedpyright/pull/1883">DetachHead/basedpyright#1883</a></li>
<li>fix multi-line display of indented lines in builtin docstrings by <a
href="https://github.com/tommyming"><code>@​tommyming</code></a> in <a
href="https://redirect.github.com/DetachHead/basedpyright/pull/1882">DetachHead/basedpyright#1882</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/DetachHead/basedpyright/compare/v1.40.0...v1.40.1">https://github.com/DetachHead/basedpyright/compare/v1.40.0...v1.40.1</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/DetachHead/basedpyright/commit/e7fca69a08abb7cb28fc6bc5e30e24a387066694"><code>e7fca69</code></a>
1.40.1</li>
<li><a
href="https://github.com/DetachHead/basedpyright/commit/f7b6ad01edff0b53cd523a176c99ba62bf44156e"><code>f7b6ad0</code></a>
add checks when there are multiline indents for doc strings, update (<a
href="https://redirect.github.com/detachhead/basedpyright/issues/1882">#1882</a>)</li>
<li><a
href="https://github.com/DetachHead/basedpyright/commit/1bc4879cb2a1cffea9ec6009eaa290b301ba4414"><code>1bc4879</code></a>
fix broken docs links</li>
<li><a
href="https://github.com/DetachHead/basedpyright/commit/845bfe14f2862b3747c8146ebd53bef7644368e8"><code>845bfe1</code></a>
move and baseline basedpyright errors in new build scripts from
upstream</li>
<li><a
href="https://github.com/DetachHead/basedpyright/commit/a9189ef5e15182e0627dd5a0f28fd2cede4f2bbc"><code>a9189ef</code></a>
prettier fix</li>
<li><a
href="https://github.com/DetachHead/basedpyright/commit/756bfaa06709cd32c100a3fe3d15156ff3faef6a"><code>756bfaa</code></a>
fix <code>build:extension:dev</code> script</li>
<li><a
href="https://github.com/DetachHead/basedpyright/commit/a7a2efdab70c919436ff593bdbd8c3b4acaa96b0"><code>a7a2efd</code></a>
syncpack fix</li>
<li><a
href="https://github.com/DetachHead/basedpyright/commit/5f6749f16bc38608fbf6f0d48b216320676996b8"><code>5f6749f</code></a>
fix tests</li>
<li><a
href="https://github.com/DetachHead/basedpyright/commit/bb9b24fddf007c547baafbd013270f2a6cb7a2cb"><code>bb9b24f</code></a>
fix zizmor errors in new workflows from upstream</li>
<li><a
href="https://github.com/DetachHead/basedpyright/commit/b8ccd0e8cd6cc79be5158e7c4ec2691d9c332594"><code>b8ccd0e</code></a>
fix compile errors from merge</li>
<li>Additional commits viewable in <a
href="https://github.com/detachhead/basedpyright/compare/v1.40.0...v1.40.1">compare
view</a></li>
</ul>
</details>
<br />

Updates `prek` from 0.5.2 to 0.5.3
<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.3</h2>
<h2>Release Notes</h2>
<p>Released on 2026-09-13.</p>
<h3>Enhancements</h3>
<ul>
<li>Add PEP 740 attestations for PyPI releases (<a
href="https://redirect.github.com/j178/prek/pull/2705">#2705</a>)</li>
<li>Add a <code>check-jsonc</code> builtin hook (<a
href="https://redirect.github.com/j178/prek/pull/2682">#2682</a>)</li>
<li>Allow disabling automatic uv installation (<a
href="https://redirect.github.com/j178/prek/pull/2702">#2702</a>)</li>
</ul>
<h3>Bug fixes</h3>
<ul>
<li>Fix Julia additional dependency specifiers (<a
href="https://redirect.github.com/j178/prek/pull/2703">#2703</a>)</li>
<li>Update <code>granit-parser</code> to fix YAML flow indentation (<a
href="https://redirect.github.com/j178/prek/pull/2707">#2707</a>)</li>
</ul>
<h3>Contributors</h3>
<ul>
<li><a
href="https://github.com/clbarnes"><code>@​clbarnes</code></a></li>
<li><a href="https://github.com/j178"><code>@​j178</code></a></li>
<li><a
href="https://github.com/tisonkun"><code>@​tisonkun</code></a></li>
</ul>
<h2>Install prek 0.5.3</h2>
<h3>Install prebuilt binaries via shell script</h3>
<pre lang="sh"><code>curl --proto '=https' --tlsv1.2 -LsSf
https://github.com/j178/prek/releases/download/v0.5.3/prek-installer.sh
| sh
</code></pre>
<h3>Install prebuilt binaries via powershell script</h3>
<pre lang="sh"><code>powershell -ExecutionPolicy Bypass -c &quot;irm
https://github.com/j178/prek/releases/download/v0.5.3/prek-installer.ps1
| iex&quot;
</code></pre>
<h3>Install prebuilt binaries via Homebrew</h3>
<pre lang="sh"><code>brew install prek
</code></pre>
<h2>Download prek 0.5.3</h2>
<table>
<thead>
<tr>
<th>File</th>
<th>Platform</th>
<th>Checksum</th>
</tr>
</thead>
<tbody>
<tr>
<td><a
href="https://github.com/j178/prek/releases/download/v0.5.3/prek-aarch64-apple-darwin.tar.gz">prek-aarch64-apple-darwin.tar.gz</a></td>
<td>Apple Silicon macOS</td>
<td><a
href="https://github.com/j178/prek/releases/download/v0.5.3/prek-aarch64-apple-darwin.tar.gz.sha256">checksum</a></td>
</tr>
<tr>
<td><a
href="https://github.com/j178/prek/releases/download/v0.5.3/prek-x86_64-apple-darwin.tar.gz">prek-x86_64-apple-darwin.tar.gz</a></td>
<td>Intel macOS</td>
<td><a
href="https://github.com/j178/prek/releases/download/v0.5.3/prek-x86_64-apple-darwin.tar.gz.sha256">checksum</a></td>
</tr>
<tr>
<td><a
href="https://github.com/j178/prek/releases/download/v0.5.3/prek-aarch64-pc-windows-msvc.zip">prek-aarch64-pc-windows-msvc.zip</a></td>
<td>ARM64 Windows</td>
<td><a
href="https://github.com/j178/prek/releases/download/v0.5.3/prek-aarch64-pc-windows-msvc.zip.sha256">checksum</a></td>
</tr>
<tr>
<td><a
href="https://github.com/j178/prek/releases/download/v0.5.3/prek-x86_64-pc-windows-msvc.zip">prek-x86_64-pc-windows-msvc.zip</a></td>
<td>x64 Windows</td>
<td><a
href="https://github.com/j178/prek/releases/download/v0.5.3/prek-x86_64-pc-windows-msvc.zip.sha256">checksum</a></td>
</tr>
</tbody>
</table>
<!-- 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.3</h2>
<p>Released on 2026-09-13.</p>
<h3>Enhancements</h3>
<ul>
<li>Add PEP 740 attestations for PyPI releases (<a
href="https://redirect.github.com/j178/prek/pull/2705">#2705</a>)</li>
<li>Add a <code>check-jsonc</code> builtin hook (<a
href="https://redirect.github.com/j178/prek/pull/2682">#2682</a>)</li>
<li>Allow disabling automatic uv installation (<a
href="https://redirect.github.com/j178/prek/pull/2702">#2702</a>)</li>
</ul>
<h3>Bug fixes</h3>
<ul>
<li>Fix Julia additional dependency specifiers (<a
href="https://redirect.github.com/j178/prek/pull/2703">#2703</a>)</li>
<li>Update <code>granit-parser</code> to fix YAML flow indentation (<a
href="https://redirect.github.com/j178/prek/pull/2707">#2707</a>)</li>
</ul>
<h3>Contributors</h3>
<ul>
<li><a
href="https://github.com/clbarnes"><code>@​clbarnes</code></a></li>
<li><a href="https://github.com/j178"><code>@​j178</code></a></li>
<li><a
href="https://github.com/tisonkun"><code>@​tisonkun</code></a></li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/j178/prek/commit/b7eb6027125de7e3b67dd94039a4f69abf3a5fb0"><code>b7eb602</code></a>
Bump version to 0.5.3 (<a
href="https://redirect.github.com/j178/prek/issues/2708">#2708</a>)</li>
<li><a
href="https://github.com/j178/prek/commit/4644f81e1c86eeb8e22a1ba4aaa6dec99b4266c5"><code>4644f81</code></a>
Update granit-parser to fix YAML flow indentation (<a
href="https://redirect.github.com/j178/prek/issues/2707">#2707</a>)</li>
<li><a
href="https://github.com/j178/prek/commit/87f2ea44c258dc6606ca5556a7b3ff0d9a0a601e"><code>87f2ea4</code></a>
Use self-repository syntax in CI workflows</li>
<li><a
href="https://github.com/j178/prek/commit/f39abae3cafd9ff245843b790fdd9b7c2e933b5c"><code>f39abae</code></a>
Add PEP 740 attestations for PyPI releases (<a
href="https://redirect.github.com/j178/prek/issues/2705">#2705</a>)</li>
<li><a
href="https://github.com/j178/prek/commit/f4924dccacd86afab22ae45e634d7f9ca12dd59e"><code>f4924dc</code></a>
Allow disabling automatic uv installation (<a
href="https://redirect.github.com/j178/prek/issues/2702">#2702</a>)</li>
<li><a
href="https://github.com/j178/prek/commit/156c0cec12847513ba764d1d87543ab6d0785c64"><code>156c0ce</code></a>
Fix Julia additional dependency specifiers (<a
href="https://redirect.github.com/j178/prek/issues/2703">#2703</a>)</li>
<li><a
href="https://github.com/j178/prek/commit/c3ede77b83e7d578e31d076863b755b296256a21"><code>c3ede77</code></a>
Add a <code>check-jsonc</code> builtin hook (<a
href="https://redirect.github.com/j178/prek/issues/2682">#2682</a>)</li>
<li><a
href="https://github.com/j178/prek/commit/c0b9749c3ee43f42efd0116624a7922feded3fc2"><code>c0b9749</code></a>
Verify checksums for PyPI/mirror wheel installs (<a
href="https://redirect.github.com/j178/prek/issues/2688">#2688</a>)</li>
<li><a
href="https://github.com/j178/prek/commit/fbe5c66737e679f31018dd438d16774c19c6774d"><code>fbe5c66</code></a>
Update prek hooks (<a
href="https://redirect.github.com/j178/prek/issues/2690">#2690</a>)</li>
<li><a
href="https://github.com/j178/prek/commit/4687a2c57f8ae58cf0e66b65baf49ea72ccb62ba"><code>4687a2c</code></a>
Fix <code>check-hooks-apply</code> for builtin Windows filename
checks</li>
<li>Additional commits viewable in <a
href="https://github.com/j178/prek/compare/v0.5.2...v0.5.3">compare
view</a></li>
</ul>
</details>
<br />

Updates `ruff` from 0.16.6 to 0.16.7
<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.7</h2>
<h2>Release Notes</h2>
<p>Released on 2026-09-10.</p>
<h3>Preview features</h3>
<ul>
<li>[<code>ruff</code>] Add rule for default values on method receivers
(<code>RUF077</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/26700">#26700</a>)</li>
<li>[<code>ruff</code>] Recognize <code>re.prefixmatch</code>
(<code>RUF039</code>, <code>RUF055</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/28311">#28311</a>)</li>
</ul>
<h3>Bug fixes</h3>
<ul>
<li>Alternate nested quotes inside format spec interpolations (<a
href="https://redirect.github.com/astral-sh/ruff/pull/28259">#28259</a>)</li>
<li>[<code>flake8-implicit-str-concat</code>] Mark fix unsafe when it
creates a docstring (<code>ISC003</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/27981">#27981</a>)</li>
<li>[<code>flake8-tidy-imports</code>] Skip fixes for multi-member
imports (<code>TID254</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/26584">#26584</a>)</li>
<li>[<code>pylint</code>] Gate <code>ImportCycleError</code> on Python
3.15 (<code>PLW0133</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/28310">#28310</a>)</li>
</ul>
<h3>Rule changes</h3>
<ul>
<li>Correct <code>D211</code> and <code>D203</code> rule conflict
diagnostic (<a
href="https://redirect.github.com/astral-sh/ruff/pull/28444">#28444</a>)</li>
<li>Recognize <code>slice</code> and <code>frozendict</code> generics
(<a
href="https://redirect.github.com/astral-sh/ruff/pull/28477">#28477</a>)</li>
<li>Stop defining <code>__cached__</code> for Python 3.15 (<a
href="https://redirect.github.com/astral-sh/ruff/pull/28476">#28476</a>)</li>
<li>[<code>pyupgrade</code>] Stop recommending removed
<code>typing.no_type_check_decorator</code> (<code>UP035</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/28475">#28475</a>)</li>
</ul>
<h3>Performance</h3>
<ul>
<li>Reuse parser name lookups when interning (<a
href="https://redirect.github.com/astral-sh/ruff/pull/28399">#28399</a>)</li>
<li>Speed up inherited configuration resolution (<a
href="https://redirect.github.com/astral-sh/ruff/pull/28299">#28299</a>)</li>
</ul>
<h3>Documentation</h3>
<ul>
<li>Fix <code>line-length</code> path in <code>--config</code> example
(<a
href="https://redirect.github.com/astral-sh/ruff/pull/28392">#28392</a>)</li>
<li>Remove the &quot;Who’s Using Ruff?&quot; list (<a
href="https://redirect.github.com/astral-sh/ruff/pull/28455">#28455</a>)</li>
</ul>
<h3>Other changes</h3>
<ul>
<li>Embed archive checksums in the shell installer (<a
href="https://redirect.github.com/astral-sh/ruff/pull/28281">#28281</a>)</li>
</ul>
<h3>Contributors</h3>
<ul>
<li><a
href="https://github.com/The-Compiler"><code>@​The-Compiler</code></a></li>
<li><a
href="https://github.com/mdiniz97"><code>@​mdiniz97</code></a></li>
<li><a href="https://github.com/zsol"><code>@​zsol</code></a></li>
<li><a
href="https://github.com/gorewilliams"><code>@​gorewilliams</code></a></li>
<li><a
href="https://github.com/RafaelJohn9"><code>@​RafaelJohn9</code></a></li>
<li><a href="https://github.com/qatcod"><code>@​qatcod</code></a></li>
<li><a href="https://github.com/zanieb"><code>@​zanieb</code></a></li>
<li><a
href="https://github.com/MichaReiser"><code>@​MichaReiser</code></a></li>
<li><a href="https://github.com/ntBre"><code>@​ntBre</code></a></li>
<li><a
href="https://github.com/charliermarsh"><code>@​charliermarsh</code></a></li>
</ul>
<!-- 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.7</h2>
<p>Released on 2026-09-10.</p>
<h3>Preview features</h3>
<ul>
<li>[<code>ruff</code>] Add rule for default values on method receivers
(<code>RUF077</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/26700">#26700</a>)</li>
<li>[<code>ruff</code>] Recognize <code>re.prefixmatch</code>
(<code>RUF039</code>, <code>RUF055</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/28311">#28311</a>)</li>
</ul>
<h3>Bug fixes</h3>
<ul>
<li>Alternate nested quotes inside format spec interpolations (<a
href="https://redirect.github.com/astral-sh/ruff/pull/28259">#28259</a>)</li>
<li>[<code>flake8-implicit-str-concat</code>] Mark fix unsafe when it
creates a docstring (<code>ISC003</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/27981">#27981</a>)</li>
<li>[<code>flake8-tidy-imports</code>] Skip fixes for multi-member
imports (<code>TID254</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/26584">#26584</a>)</li>
<li>[<code>pylint</code>] Gate <code>ImportCycleError</code> on Python
3.15 (<code>PLW0133</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/28310">#28310</a>)</li>
</ul>
<h3>Rule changes</h3>
<ul>
<li>Correct <code>D211</code> and <code>D203</code> rule conflict
diagnostic (<a
href="https://redirect.github.com/astral-sh/ruff/pull/28444">#28444</a>)</li>
<li>Recognize <code>slice</code> and <code>frozendict</code> generics
(<a
href="https://redirect.github.com/astral-sh/ruff/pull/28477">#28477</a>)</li>
<li>Stop defining <code>__cached__</code> for Python 3.15 (<a
href="https://redirect.github.com/astral-sh/ruff/pull/28476">#28476</a>)</li>
<li>[<code>pyupgrade</code>] Stop recommending removed
<code>typing.no_type_check_decorator</code> (<code>UP035</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/28475">#28475</a>)</li>
</ul>
<h3>Performance</h3>
<ul>
<li>Reuse parser name lookups when interning (<a
href="https://redirect.github.com/astral-sh/ruff/pull/28399">#28399</a>)</li>
<li>Speed up inherited configuration resolution (<a
href="https://redirect.github.com/astral-sh/ruff/pull/28299">#28299</a>)</li>
</ul>
<h3>Documentation</h3>
<ul>
<li>Fix <code>line-length</code> path in <code>--config</code> example
(<a
href="https://redirect.github.com/astral-sh/ruff/pull/28392">#28392</a>)</li>
<li>Remove the &quot;Who’s Using Ruff?&quot; list (<a
href="https://redirect.github.com/astral-sh/ruff/pull/28455">#28455</a>)</li>
</ul>
<h3>Other changes</h3>
<ul>
<li>Embed archive checksums in the shell installer (<a
href="https://redirect.github.com/astral-sh/ruff/pull/28281">#28281</a>)</li>
</ul>
<h3>Contributors</h3>
<ul>
<li><a
href="https://github.com/The-Compiler"><code>@​The-Compiler</code></a></li>
<li><a
href="https://github.com/mdiniz97"><code>@​mdiniz97</code></a></li>
<li><a href="https://github.com/zsol"><code>@​zsol</code></a></li>
<li><a
href="https://github.com/gorewilliams"><code>@​gorewilliams</code></a></li>
<li><a
href="https://github.com/RafaelJohn9"><code>@​RafaelJohn9</code></a></li>
<li><a href="https://github.com/qatcod"><code>@​qatcod</code></a></li>
<li><a href="https://github.com/zanieb"><code>@​zanieb</code></a></li>
<li><a
href="https://github.com/MichaReiser"><code>@​MichaReiser</code></a></li>
<li><a href="https://github.com/ntBre"><code>@​ntBre</code></a></li>
<li><a
href="https://github.com/charliermarsh"><code>@​charliermarsh</code></a></li>
<li><a
href="https://github.com/nightt5879"><code>@​nightt5879</code></a></li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/astral-sh/ruff/commit/b5dba861cc38e3f7fb4524c9ceba3e01a474ea13"><code>b5dba86</code></a>
Bump version to 0.16.7 (<a
href="https://redirect.github.com/astral-sh/ruff/issues/28496">#28496</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/5992d0504697d86565d8fc3a4d8245a5f4047d24"><code>5992d05</code></a>
Install rustfmt before linting releases (<a
href="https://redirect.github.com/astral-sh/ruff/issues/28495">#28495</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/1713a1f4325494d883a080d590a25a1946f399e8"><code>1713a1f</code></a>
ensure prepare release changes pass prek (<a
href="https://redirect.github.com/astral-sh/ruff/issues/28488">#28488</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/18cdbb4f3d14058794420e758864795f55336334"><code>18cdbb4</code></a>
use scoped token for release workflow (<a
href="https://redirect.github.com/astral-sh/ruff/issues/28484">#28484</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/c3813a501faf887fd01948c98bb8d58ad26488bd"><code>c3813a5</code></a>
add a workflow for preparing releases (<a
href="https://redirect.github.com/astral-sh/ruff/issues/28486">#28486</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/00948c00a671b81f5358af9f038436bcbb993b38"><code>00948c0</code></a>
Remove the &quot;Who’s Using Ruff?&quot; list (<a
href="https://redirect.github.com/astral-sh/ruff/issues/28455">#28455</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/86a2eba7b48e3c10386f7ab8a5425c7275d2b427"><code>86a2eba</code></a>
[<code>pyupgrade</code>] Stop recommending removed
<code>typing.no_type_check_decorator</code> (`UP...</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/609e184aa35f0034b7ef63e3e04327081c484af6"><code>609e184</code></a>
Stop defining <code>__cached__</code> for Python 3.15 (<a
href="https://redirect.github.com/astral-sh/ruff/issues/28476">#28476</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/859ff2f01670c43ffff1ea597c8a2e375ada0fbe"><code>859ff2f</code></a>
[ty] Track symlinked directory status in listings (<a
href="https://redirect.github.com/astral-sh/ruff/issues/28482">#28482</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/77f653825800ddaf3bc221ae6db49a500e1002d5"><code>77f6538</code></a>
Use paid GitHub-hosted runners for Linux (<a
href="https://redirect.github.com/astral-sh/ruff/issues/28478">#28478</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/astral-sh/ruff/compare/0.16.6...0.16.7">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-17 15:53:10 -04:00
Vinicius Gabriel c53545d9fe Add configurable word separator for naming templates (#1333)
Closes #1230

## What
Adds a "Word Separator" setting (Space / Dot / Underscore / Hyphen /
Custom) that replaces internal whitespace in each naming-template
placeholder's rendered value — e.g. `{Author}` renders
"Arthur.Conan.Doyle" instead of "Arthur Conan Doyle" when Dot is
selected.

This follows option 2 from the issue rather than inventing new
dotted-keyword template syntax (`{Author.}`), since it's a smaller
surface: one setting applies uniformly across all four templates
(books/audiobooks × rename/organize) instead of needing a parallel token
for every existing one.

## How it works
- Literal characters typed into the template itself (e.g. the `.` in
`{Author}.-.{Title}`) are never touched — only whitespace *inside* a
placeholder's resolved value is affected.
- Default is "Space", which is a no-op: existing templates produce
byte-identical output after this change (verified via the existing test
suite, unmodified, still passing).

## Where
- `shelfmark/core/naming.py` — `word_separator` param on
`parse_naming_template` / `build_library_path`.
- `shelfmark/download/postprocess/policy.py` — `get_word_separator()`,
mirroring the existing `get_file_organization()` accessor.
- `shelfmark/download/postprocess/transfer.py` — wires the resolved
separator through the four existing template-rendering call sites.
- `shelfmark/config/settings.py` — new `Word Separator` / `Custom Word
Separator` fields next to the existing naming-template fields.
- `src/frontend/.../namingTemplatePreview.ts` +
`NamingTemplateField.tsx` — the settings UI has its own TS mirror of the
Python renderer for the live preview; updated it in lockstep so the
preview doesn't lie about what the separator will actually do.
- Tests added on both sides (pytest + vitest).

## Testing
- `uv run pytest tests/core/test_naming.py
tests/core/test_destination_file_organization.py` — all pass, including
new cases.
- `uv run pytest` (full suite) — same pre-existing failures as on `main`
before this change (browser/network-dependent bypass & e2e tests
unrelated to this diff), everything else green.
- `uv run ruff check` / `ruff format --check` / `basedpyright` — clean.
- `npm run lint` / `format:check` / `typecheck` / `test:unit` (196
tests) — clean.
2026-09-17 15:52:21 -04:00
Atirna 8f608f2e64 fix(download): complete consumed Blackhole handoffs (#1345)
A Blackhole watcher can consume the torrent before Shelfmark checks it,
leaving the task in error even though the handoff succeeded. Complete
the handoff when `add_download` successfully publishes the file, and
stop requiring a `HandoffResult` path to remain present.

Follow-up to #1312.

## Verification

- A watcher that immediately reads and removes the torrent receives the
exact bytes. The task changes from ERROR before this fix to COMPLETE
afterward, without running book postprocessing.
- The consumed-file regression fails on current main and passes here.
Resident files, write failures, cancellation, magnet rejection and
normal downloads remain covered: 81 focused tests pass.
- Ruff lint and formatting pass for the changed files.
2026-09-17 15:51:20 -04:00
David Young 2bb84a17a2 Extract archives when zip/rar are enabled as supported formats (#1343)
The default audiobook formats include `zip` and `rar`.
`scan_directory_tree` checks the supported-format list before checking
for archives, so a downloaded archive lands in `book_files` and is
imported as-is. The extraction branch in `collect_directory_files` is
never reached.

This keeps archives out of `book_files`, so they always take the archive
path: extracted when extraction is allowed, imported as-is when it isn't
(unchanged).

Tests added in `tests/download/test_postprocess_scan_archives.py`; three
of the four fail without the change.
2026-09-17 15:50:52 -04:00
Paul Rimmer 2ed2e9a5d4 Feature: Add Download counts to search result displays and Download sidebar (#1336)
Having the download counts from AA as an input on choosing which of the
many search results to pick for downloading is useful. This PR makes the
downloads numbers available on all the search result pages and also on
the Download sidebar after the user presses a download button for a
Direct Download.

I have also included a SKILL.md and associated download_books.py that
can be used just as a reference or with an LLM harness for automation.
The Downloads info is used by the script to pick which search result to
download out of the many available.

Since a picture is worth a thousand words:

<img width="1225" height="812" alt="search-results-with-downloads"
src="https://github.com/user-attachments/assets/e108ebe2-4cad-45e2-bb6a-d4f49b502de9"
/>

<img width="443" height="267" alt="download-sidebar-with-downloads"
src="https://github.com/user-attachments/assets/9ce9e6c4-dcf5-4f7c-ab22-71850398b534"
/>

Coded with llama.cpp and 🤖
2026-09-17 15:49:57 -04:00
Alex Guerrieri c6b70a6844 fix(sources): send a Referer when fetching libgen ads.php pages (#1340)
## What

libgen.li's `ads.php?md5=` now returns an **empty `200`** to any request
without a `Referer` — an anti-hotlinking check the mirrors added
recently. Both libgen paths fetch it without one, so the page comes back
blank and the download silently fails while **search keeps working**
(which is exactly why it looks like rate-limiting or mirror drift rather
than a bug).

Same one-line cause, two call sites: the Libgen search source
(`libgen/scraper.py:fetch_page`) and the AA-md5 → libgen fallback
(`direct_download/annas_archive.py:_extract_libgen_download_url`). Fix:
send a same-origin `Referer: <scheme>://<host>/` on the `ads.php` fetch
in both.

## Worth a look in review

- **The referer goes on the *resolution* fetch, not the download.**
`download_url(..., referer=...)` was already correct — the blank page
happens one step earlier, at the `ads.php` GET.
- Reproduced against live mirrors: `ads.php` returns `Content-Length: 0`
bare, the full page with a `Referer`, and resolvable files download
valid bytes again.

Regression tests in `tests/libgen/` and `tests/direct_download/` assert
the header on both paths. Lint/format/typecheck clean.

Follow-up to #1326.
2026-09-17 15:47:45 -04:00
CaliBrain 35b89b0d78 fix(sources): restore Direct Download search errors and language matches (#1339)
Fixes two regressions from the provider-driven refactor (#1337). First,
the composite search caught RuntimeError, TypeError, ValueError and
request errors from each provider and returned an empty list, so a
failed search looked like one with no hits. It now raises the first
provider failure when no provider returned releases. Second, the shared
parser re-matched every row's language locally, dropping rows Anna's
Archive had already matched with &lang= (free-text cells like 'English,
French' or 'unknown'). parse_search_items gains a filter_languages
option, which AA turns off, so AA's own language-from-path filter is
again the only local one.
2026-09-14 01:30:05 -04:00
Tom a5cd9f0bfb refactor: make direct download provider-driven (#1337)
This is the refactor for the download handler
2026-09-14 01:16:53 -04:00
Alex Guerrieri af21d1da1f feat(sources): add Libgen as a direct catalogue search source (#1326)
## What

Adds **Libgen as a search source**. Today Libgen is only a download
mirror (reached by an Anna's Archive md5), so anything in Libgen but not
in AA's search index is invisible — and that's where most of the CBZ/CBR
comics and manga live. A Libgen search for *One Piece*, for instance,
turns up ~99 volumes that AA search never shows.

It's a self-contained `release_sources/libgen/` package (source +
handler + settings) plus one line to register it. **No changes to
`direct_download.py`** — it reuses the existing `ads.php → get.php`
resolution and the mirrors already configured in `LIBGEN_MIRROR_URLS`.
Plain HTTP, no bypasser needed (libgen.li isn't behind DDoS-Guard).
Opt-in via a settings toggle.

## Worth a look in review

- **`source_id` is `libgen:<md5>`, not the bare md5.** The download
queue keys on `task_id` (= `source_id`), and `direct_download` already
uses the bare md5. Since AA indexes a lot of Libgen, the same md5 shows
up from both sources — a bare id would collide in the queue. The handler
strips the prefix before downloading.
- **Reachable like the other non-default sources** (Prowlarr,
AudiobookBay, …): it appears in the per-book release search, not the
free-text box (that stays wired to `direct_download`).

Tests in `tests/libgen/` cover parsing (both row layouts), the source,
the handler, and `get_record`. Lint/format/typecheck clean.
2026-09-14 01:08:08 -04:00
CaliBrain 1b17fe179a fix(irc): rank a surname-only result as partial, not wrong (#1332) (#1334)
"David Petrie" as "D. Petrie", then ranked the answer by the full name
to
recover the precision the surname gave up. The two halves disagreed.

author_affinity needs two agreeing tokens before it calls a name the
same
person, so "Petrie" - the name on the filenames a surname search exists
to
reach - matched one and came back AUTHOR_MISMATCH. It therefore sorted
below
"Unknown" and level with "Gordon Petrie", a different author who merely
shares
the surname. The widened query pulled those rows in and the ranker
buried them.

Falling short of agreement is now separated from disagreeing with it. A
name
whose every token fits the one asked for is an abbreviation of it and
ranks
AUTHOR_PARTIAL, between agreement and "no author reported"; a name
carrying a
token that fits nothing still ranks AUTHOR_MISMATCH. Nothing that agreed
before
changes tier - "Homer"/"Homer Simpson" is still a match, since the extra
token
must not demote a mononym that already met its one-token requirement -
so
Prowlarr's #1293 ordering is unchanged except that a tracker listing a
bare
surname stops being read as the wrong author.

Measured on the issue's own case, wanted "David Petrie":

  before: D Petrie, Unknown, Petrie, Gordon Petrie
  after:  D Petrie, Petrie, Unknown, Gordon Petrie

Second fix, same release: a book with no title posted the surname on its
own.
_build_query fell back to book.search_title or book.title, which is
empty on
exactly the path where the plan has no title variants, so the line
reaching the
channel was "@search Petrie" - not a search for anything, and the kind
of bare
over-broad post is_available refuses unaddressed queries to avoid. It
now
returns "" and the existing "No search query could be built" guard takes
it.

Tested with make python-lint, python-format, python-dead-code,
python-typecheck
and python-test.
2026-09-11 22:26:28 -04:00
Zoltán Szabó 35037b35fd fix(irc): search by surname, and rank the answer by author (#1331) (#1332)
Fixes #1331.

A search bot ANDs every term against a filename, so the given name is
the term
that empties the result set. Measured against irchighway's #ebooks:
"Revelations
David Petrie" is answered "no results", "Revelations Petrie" returns 9
matches,
6 of which parse, all filed as "D Petrie".

The query now carries the title and the surname, read off the search
variant so
the ISBN fallback and a manual query - which set author="" on purpose -
keep
their current shape.

Title-only, the shape #1295 settled on for Prowlarr, does not transfer:
the bot
caps an answer at 1000 matches, and a bare "Revelations" hits that cap
with 923
parsed rows across 500 authors, so the cap itself can drop the wanted
book. The
surname is the token the two spellings share and it keeps the answer
small.

The full author then orders what comes back, reusing author_affinity
from #1295,
since a surname also matches a different author who shares it. It sits
under
server availability the way indexer priority does in #1295: a download
addresses
one named bot and waits 120s for it, so a match from a bot that has left
the
channel must not outrank a mismatch that can answer. Ranking runs on the
way out
rather than before the cache, because one query identity is shared by
every book
that produced that query.

Two things found while testing:

- The parser writes the literal "Unknown" when a filename has no " - "
separator
  (parser.py:168). Ranked literally that sorts as a wrong author, so
author_affinity's middle tier was unreachable here; it is now read as
absent.
  5 of those 923 rows are affected.
- author_affinity moves to shelfmark/core/author_match.py, unchanged, so
IRC
does not import from the Prowlarr package. Prowlarr behaviour is
untouched and
  its tests pass as they are.

The three IRC assertions in the #1252 regression file move to the
surname form.
The invariant they pin - one contributor's name reaches the query, never
the
whole credit list - is unchanged.

Tested with make python-lint, python-format, python-dead-code,
python-typecheck
and python-test, and end to end against irchighway with the patched
source: it
posts "Revelations Petrie" and returns 6 releases.
2026-09-11 22:14:37 -04:00
dependabot[bot] 8c902d7f7a build(deps-dev): bump vitest from 4.1.11 to 5.0.0 in /src/frontend (#1330)
Bumps
[vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest)
from 4.1.11 to 5.0.0.
<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>v5.0.0</h2>
<p>Vitest 5 is officially out! This release focuses on performance and
brings a lot of new features while fixing long-standing bugs. See our <a
href="https://vitest.dev/blog/vitest-5.html">blog post</a> for the
official announcement.</p>
<h3>   🚨 Breaking Changes</h3>
<ul>
<li>Replace <code>loupe.inspect</code> with pretty-format  -  by <a
href="https://github.com/hi-ogawa"><code>@​hi-ogawa</code></a>,
<strong>Claude Opus 5 (1M context)</strong> and <strong>OpenAI
Codex</strong> in <a
href="https://redirect.github.com/vitest-dev/vitest/issues/9609">vitest-dev/vitest#9609</a>
<a href="https://github.com/vitest-dev/vitest/commit/3f802da4b"><!-- raw
HTML omitted -->(3f802)<!-- raw HTML omitted --></a></li>
<li>Remove quotes from string values in <code>test.for/each</code> title
<code>$</code> variable (take 2)  -  by <a
href="https://github.com/hi-ogawa"><code>@​hi-ogawa</code></a> in <a
href="https://redirect.github.com/vitest-dev/vitest/issues/10170">vitest-dev/vitest#10170</a>
<a href="https://github.com/vitest-dev/vitest/commit/04d37e9d7"><!-- raw
HTML omitted -->(04d37)<!-- raw HTML omitted --></a></li>
<li>Default <code>attachmentsDir</code> from
<code>.vitest-attachements/</code> to <code>.vitest/attachments/</code>
 -  by <a
href="https://github.com/MdSadiqMd"><code>@​MdSadiqMd</code></a> in <a
href="https://redirect.github.com/vitest-dev/vitest/issues/10186">vitest-dev/vitest#10186</a>
<a href="https://github.com/vitest-dev/vitest/commit/1ba7338c3"><!-- raw
HTML omitted -->(1ba73)<!-- raw HTML omitted --></a></li>
<li>Remove <code>sequential</code> test/suite options in favor of
<code>concurrent</code>  -  by <a
href="https://github.com/hi-ogawa"><code>@​hi-ogawa</code></a> and
<strong>OpenAI Codex</strong> in <a
href="https://redirect.github.com/vitest-dev/vitest/issues/10198">vitest-dev/vitest#10198</a>
<a href="https://github.com/vitest-dev/vitest/commit/9229f2edc"><!-- raw
HTML omitted -->(9229f)<!-- raw HTML omitted --></a></li>
<li>Represent locator as an object instead of a string  -  by <a
href="https://github.com/sheremet-va"><code>@​sheremet-va</code></a> in
<a
href="https://redirect.github.com/vitest-dev/vitest/issues/10212">vitest-dev/vitest#10212</a>
<a href="https://github.com/vitest-dev/vitest/commit/80f07edf6"><!-- raw
HTML omitted -->(80f07)<!-- raw HTML omitted --></a></li>
<li>Inline <code>expect</code> package  -  by <a
href="https://github.com/sheremet-va"><code>@​sheremet-va</code></a> in
<a
href="https://redirect.github.com/vitest-dev/vitest/issues/10221">vitest-dev/vitest#10221</a>
<a href="https://github.com/vitest-dev/vitest/commit/ad16223e7"><!-- raw
HTML omitted -->(ad162)<!-- raw HTML omitted --></a></li>
<li>Remove deprecated entry points  -  by <a
href="https://github.com/sheremet-va"><code>@​sheremet-va</code></a> in
<a
href="https://redirect.github.com/vitest-dev/vitest/issues/10222">vitest-dev/vitest#10222</a>
<a href="https://github.com/vitest-dev/vitest/commit/994c6ddb9"><!-- raw
HTML omitted -->(994c6)<!-- raw HTML omitted --></a></li>
<li>Require Node.js 22 and Vite 6.4  -  by <a
href="https://github.com/sheremet-va"><code>@​sheremet-va</code></a> in
<a
href="https://redirect.github.com/vitest-dev/vitest/issues/10178">vitest-dev/vitest#10178</a>
<a href="https://github.com/vitest-dev/vitest/commit/3876283e8"><!-- raw
HTML omitted -->(38762)<!-- raw HTML omitted --></a></li>
<li>Fail <code>expect.poll</code> when function didn't resolve in time
 -  by <a href="https://github.com/hi-ogawa"><code>@​hi-ogawa</code></a>
and <strong>OpenAI Codex</strong> in <a
href="https://redirect.github.com/vitest-dev/vitest/issues/10233">vitest-dev/vitest#10233</a>
<a href="https://github.com/vitest-dev/vitest/commit/4df048c11"><!-- raw
HTML omitted -->(4df04)<!-- raw HTML omitted --></a></li>
<li>Throw an error if hoistable methods are outside the top level scope
 -  by <a
href="https://github.com/sheremet-va"><code>@​sheremet-va</code></a> in
<a
href="https://redirect.github.com/vitest-dev/vitest/issues/10460">vitest-dev/vitest#10460</a>
<a href="https://github.com/vitest-dev/vitest/commit/d0b4fddcb"><!-- raw
HTML omitted -->(d0b4f)<!-- raw HTML omitted --></a></li>
<li><code>toHaveTextContent</code> is strict, add
<code>toMatchTextContent</code> as alternative  -  by <a
href="https://github.com/sheremet-va"><code>@​sheremet-va</code></a> in
<a
href="https://redirect.github.com/vitest-dev/vitest/issues/10473">vitest-dev/vitest#10473</a>
<a href="https://github.com/vitest-dev/vitest/commit/18f303079"><!-- raw
HTML omitted -->(18f30)<!-- raw HTML omitted --></a></li>
<li>Don't lookup config file from ancestor directories  -  by <a
href="https://github.com/hi-ogawa"><code>@​hi-ogawa</code></a>,
<strong>OpenAI Codex</strong> and <strong>Hiroshi Ogawa</strong> in <a
href="https://redirect.github.com/vitest-dev/vitest/issues/10428">vitest-dev/vitest#10428</a>
<a href="https://github.com/vitest-dev/vitest/commit/945d9090e"><!-- raw
HTML omitted -->(945d9)<!-- raw HTML omitted --></a></li>
<li>Inline <code>@vitest/runner</code> package, do not publish it
anymore  -  by <a
href="https://github.com/sheremet-va"><code>@​sheremet-va</code></a> in
<a
href="https://redirect.github.com/vitest-dev/vitest/issues/10511">vitest-dev/vitest#10511</a>
<a href="https://github.com/vitest-dev/vitest/commit/6d6e46b1e"><!-- raw
HTML omitted -->(6d6e4)<!-- raw HTML omitted --></a></li>
<li>Allow mutating happy-dom/jsdom window object  -  by <a
href="https://github.com/hi-ogawa"><code>@​hi-ogawa</code></a>,
<strong>Hiroshi Ogawa</strong> and <strong>OpenAI Codex</strong> in <a
href="https://redirect.github.com/vitest-dev/vitest/issues/10373">vitest-dev/vitest#10373</a>
<a href="https://github.com/vitest-dev/vitest/commit/206e8cff8"><!-- raw
HTML omitted -->(206e8)<!-- raw HTML omitted --></a></li>
<li>Expose <code>concurrencyId</code>/<code>workerId</code> on
TestModule's diagnostics, make id 1-based  -  by <a
href="https://github.com/sheremet-va"><code>@​sheremet-va</code></a> in
<a
href="https://redirect.github.com/vitest-dev/vitest/issues/10516">vitest-dev/vitest#10516</a>
<a href="https://github.com/vitest-dev/vitest/commit/bdd985433"><!-- raw
HTML omitted -->(bdd98)<!-- raw HTML omitted --></a></li>
<li>Add <code>screenshotDirectory</code> config to
<code>browser.expect.toMatchScreenshot</code>  -  by <a
href="https://github.com/macarie"><code>@​macarie</code></a> in <a
href="https://redirect.github.com/vitest-dev/vitest/issues/10592">vitest-dev/vitest#10592</a>
<a href="https://github.com/vitest-dev/vitest/commit/a60ded0fb"><!-- raw
HTML omitted -->(a60de)<!-- raw HTML omitted --></a></li>
<li>Update <code>@sinonjs/fake-timers</code> and support mocking
<code>Temporal</code>  -  by <a
href="https://github.com/hi-ogawa"><code>@​hi-ogawa</code></a>,
<strong>Hiroshi Ogawa</strong> and <strong>OpenCode
(gpt-5.6-sol)</strong> in <a
href="https://redirect.github.com/vitest-dev/vitest/issues/10654">vitest-dev/vitest#10654</a>
<a href="https://github.com/vitest-dev/vitest/commit/f8b1532fe"><!-- raw
HTML omitted -->(f8b15)<!-- raw HTML omitted --></a></li>
<li>Remove webdriverio package  -  by <a
href="https://github.com/sheremet-va"><code>@​sheremet-va</code></a> in
<a
href="https://redirect.github.com/vitest-dev/vitest/issues/10675">vitest-dev/vitest#10675</a>
<a href="https://github.com/vitest-dev/vitest/commit/5fed68f72"><!-- raw
HTML omitted -->(5fed6)<!-- raw HTML omitted --></a></li>
<li>Clear mocks by default before each test  -  by <a
href="https://github.com/sheremet-va"><code>@​sheremet-va</code></a> in
<a
href="https://redirect.github.com/vitest-dev/vitest/issues/10613">vitest-dev/vitest#10613</a>
<a href="https://github.com/vitest-dev/vitest/commit/0f6463bf2"><!-- raw
HTML omitted -->(0f646)<!-- raw HTML omitted --></a></li>
<li>Don't emit localStorage warnings on Node 26, fail gracefully when
worker fails to start  -  by <a
href="https://github.com/sheremet-va"><code>@​sheremet-va</code></a> in
<a
href="https://redirect.github.com/vitest-dev/vitest/issues/10293">vitest-dev/vitest#10293</a>
<a href="https://github.com/vitest-dev/vitest/commit/334edef92"><!-- raw
HTML omitted -->(334ed)<!-- raw HTML omitted --></a></li>
<li>Separate config resolution from the server creation  -  by <a
href="https://github.com/sheremet-va"><code>@​sheremet-va</code></a> in
<a
href="https://redirect.github.com/vitest-dev/vitest/issues/10554">vitest-dev/vitest#10554</a>
<a href="https://github.com/vitest-dev/vitest/commit/1c0ec3444"><!-- raw
HTML omitted -->(1c0ec)<!-- raw HTML omitted --></a></li>
<li>Inline projects extend the root config by default  -  by <a
href="https://github.com/sheremet-va"><code>@​sheremet-va</code></a> in
<a
href="https://redirect.github.com/vitest-dev/vitest/issues/10750">vitest-dev/vitest#10750</a>
<a href="https://github.com/vitest-dev/vitest/commit/fec001ad3"><!-- raw
HTML omitted -->(fec00)<!-- raw HTML omitted --></a></li>
<li>Enable mocking Temporal without fake timers  -  by <a
href="https://github.com/fabon-f"><code>@​fabon-f</code></a>,
<strong>Hiroshi Ogawa</strong> and <strong>OpenCode
(gpt-5.6-sol)</strong> in <a
href="https://redirect.github.com/vitest-dev/vitest/issues/10757">vitest-dev/vitest#10757</a>
<a href="https://github.com/vitest-dev/vitest/commit/ac2d46b42"><!-- raw
HTML omitted -->(ac2d4)<!-- raw HTML omitted --></a></li>
<li>Support nested projects  -  by <a
href="https://github.com/sheremet-va"><code>@​sheremet-va</code></a> in
<a
href="https://redirect.github.com/vitest-dev/vitest/issues/10846">vitest-dev/vitest#10846</a>
<a href="https://github.com/vitest-dev/vitest/commit/ec367cf2a"><!-- raw
HTML omitted -->(ec367)<!-- raw HTML omitted --></a></li>
<li>Use <code>&gt;</code> as separator in <code>-t</code>, calculate
<code>only</code> once  -  by <a
href="https://github.com/sheremet-va"><code>@​sheremet-va</code></a> in
<a
href="https://redirect.github.com/vitest-dev/vitest/issues/10686">vitest-dev/vitest#10686</a>
<a href="https://github.com/vitest-dev/vitest/commit/a0b20bc86"><!-- raw
HTML omitted -->(a0b20)<!-- raw HTML omitted --></a></li>
<li>Fail the test when an asynchronous assertion is not awaited  -  by
<a href="https://github.com/sheremet-va"><code>@​sheremet-va</code></a>
in <a
href="https://redirect.github.com/vitest-dev/vitest/issues/10868">vitest-dev/vitest#10868</a>
<a href="https://github.com/vitest-dev/vitest/commit/86d4a9da9"><!-- raw
HTML omitted -->(86d4a)<!-- raw HTML omitted --></a></li>
<li>Share the Vite server between inline projects  -  by <a
href="https://github.com/sheremet-va"><code>@​sheremet-va</code></a> in
<a
href="https://redirect.github.com/vitest-dev/vitest/issues/10848">vitest-dev/vitest#10848</a>
<a href="https://github.com/vitest-dev/vitest/commit/d87c96ee4"><!-- raw
HTML omitted -->(d87c9)<!-- raw HTML omitted --></a></li>
<li>Parse files statically in vitest list by default  -  by <a
href="https://github.com/sheremet-va"><code>@​sheremet-va</code></a> in
<a
href="https://redirect.github.com/vitest-dev/vitest/issues/11088">vitest-dev/vitest#11088</a>
<a href="https://github.com/vitest-dev/vitest/commit/51e949416"><!-- raw
HTML omitted -->(51e94)<!-- raw HTML omitted --></a></li>
<li><strong>benchmark</strong>:
<ul>
<li>Rewrite the public API  -  by <a
href="https://github.com/sheremet-va"><code>@​sheremet-va</code></a> in
<a
href="https://redirect.github.com/vitest-dev/vitest/issues/10113">vitest-dev/vitest#10113</a>
<a href="https://github.com/vitest-dev/vitest/commit/19f6e8947"><!-- raw
HTML omitted -->(19f6e)<!-- raw HTML omitted --></a></li>
</ul>
</li>
<li><strong>browser</strong>:
<ul>
<li>Iframe scale  -  by <a
href="https://github.com/macarie"><code>@​macarie</code></a> in <a
href="https://redirect.github.com/vitest-dev/vitest/issues/9745">vitest-dev/vitest#9745</a>
<a href="https://github.com/vitest-dev/vitest/commit/b639852cc"><!-- raw
HTML omitted -->(b6398)<!-- raw HTML omitted --></a></li>
<li>Enable <code>locators.exact</code> by default  -  by <a
href="https://github.com/sheremet-va"><code>@​sheremet-va</code></a> in
<a
href="https://redirect.github.com/vitest-dev/vitest/issues/10430">vitest-dev/vitest#10430</a>
<a href="https://github.com/vitest-dev/vitest/commit/e203202f9"><!-- raw
HTML omitted -->(e2032)<!-- raw HTML omitted --></a></li>
<li>Require <code>sessionId</code> for orchestrator html request  -  by
<a href="https://github.com/hi-ogawa"><code>@​hi-ogawa</code></a>,
<strong>Hiroshi Ogawa</strong> and <strong>OpenAI Codex</strong> in <a
href="https://redirect.github.com/vitest-dev/vitest/issues/10522">vitest-dev/vitest#10522</a>
<a href="https://github.com/vitest-dev/vitest/commit/79b7d8fcc"><!-- raw
HTML omitted -->(79b7d)<!-- raw HTML omitted --></a></li>
<li>Save failure screenshots in <code>attachmentsDir</code>  -  by <a
href="https://github.com/macarie"><code>@​macarie</code></a> in <a
href="https://redirect.github.com/vitest-dev/vitest/issues/10917">vitest-dev/vitest#10917</a>
<a href="https://github.com/vitest-dev/vitest/commit/3b5bbd8b4"><!-- raw
HTML omitted -->(3b5bb)<!-- raw HTML omitted --></a></li>
</ul>
</li>
<li><strong>coverage</strong>:
<ul>
<li><code>include/exclude</code> globs too eager  -  by <a
href="https://github.com/AriPerkkio"><code>@​AriPerkkio</code></a> in <a
href="https://redirect.github.com/vitest-dev/vitest/issues/9818">vitest-dev/vitest#9818</a>
<a href="https://github.com/vitest-dev/vitest/commit/edacb0fd4"><!-- raw
HTML omitted -->(edacb)<!-- raw HTML omitted --></a></li>
<li>Allow <code>thresholds.perFile</code> to accept an object  -  by <a
href="https://github.com/vladlenskiy"><code>@​vladlenskiy</code></a> and
<a href="https://github.com/AriPerkkio"><code>@​AriPerkkio</code></a> in
<a
href="https://redirect.github.com/vitest-dev/vitest/issues/10190">vitest-dev/vitest#10190</a>
<a href="https://github.com/vitest-dev/vitest/commit/13b78d98b"><!-- raw
HTML omitted -->(13b78)<!-- raw HTML omitted --></a></li>
</ul>
</li>
<li><strong>expect</strong>:
<ul>
<li>Fix <code>toThrow(&quot;&quot;)</code> behavior by reverting <a
href="https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest/issues/6710">#6710</a>
 -  by <a href="https://github.com/hi-ogawa"><code>@​hi-ogawa</code></a>
in <a
href="https://redirect.github.com/vitest-dev/vitest/issues/9643">vitest-dev/vitest#9643</a>
and <a
href="https://redirect.github.com/vitest-dev/vitest/issues/6710">vitest-dev/vitest#6710</a>
<a href="https://github.com/vitest-dev/vitest/commit/6c3e4bdbf"><!-- raw
HTML omitted -->(6c3e4)<!-- raw HTML omitted --></a></li>
</ul>
</li>
<li><strong>mocker</strong>:
<ul>
<li>Deserialize automock as automock  -  by <a
href="https://github.com/nami8824"><code>@​nami8824</code></a> in <a
href="https://redirect.github.com/vitest-dev/vitest/issues/10192">vitest-dev/vitest#10192</a>
<a href="https://github.com/vitest-dev/vitest/commit/2f892712d"><!-- raw
HTML omitted -->(2f892)<!-- raw HTML omitted --></a></li>
</ul>
</li>
<li><strong>reporters</strong>:
<ul>
<li><code>blob</code> reporter and <code>--merge-reports</code> default
to <code>.vitest/blob/</code>  -  by <a
href="https://github.com/AriPerkkio"><code>@​AriPerkkio</code></a> in <a
href="https://redirect.github.com/vitest-dev/vitest/issues/10232">vitest-dev/vitest#10232</a>
<a href="https://github.com/vitest-dev/vitest/commit/d22b029ae"><!-- raw
HTML omitted -->(d22b0)<!-- raw HTML omitted --></a></li>
<li>Write json and junit reporter output files to <code>.vitest</code>
by default  -  by <a
href="https://github.com/hi-ogawa"><code>@​hi-ogawa</code></a>,
<strong>Hiroshi Ogawa</strong>, <strong>OpenCode (gpt-5.6-sol)</strong>
and <a
href="https://github.com/AriPerkkio"><code>@​AriPerkkio</code></a> in <a
href="https://redirect.github.com/vitest-dev/vitest/issues/10621">vitest-dev/vitest#10621</a>
<a href="https://github.com/vitest-dev/vitest/commit/58577290a"><!-- raw
HTML omitted -->(58577)<!-- raw HTML omitted --></a></li>
</ul>
</li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/vitest-dev/vitest/commit/f441c6fab25e579c5b7dd3dd50538416f415fbae"><code>f441c6f</code></a>
chore: release v5.0.0 (<a
href="https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest/issues/11130">#11130</a>)</li>
<li><a
href="https://github.com/vitest-dev/vitest/commit/d46a7472266d5bb43595ea51dcdd64ab0f560f12"><code>d46a747</code></a>
fix: treat test.describe as a suite during static collection (<a
href="https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest/issues/11128">#11128</a>)</li>
<li><a
href="https://github.com/vitest-dev/vitest/commit/584cf308437069385b0fb905fa3ff7d7b7f65a90"><code>584cf30</code></a>
fix: add a warning if inline project has duplicate plugins due to
unexpected ...</li>
<li><a
href="https://github.com/vitest-dev/vitest/commit/f08ce4b7144542af128dcb884150c42074223653"><code>f08ce4b</code></a>
fix: apply queued mocks from doMock() in queue order (fixes <a
href="https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest/issues/10706">#10706</a>)
(<a
href="https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest/issues/11127">#11127</a>)</li>
<li><a
href="https://github.com/vitest-dev/vitest/commit/897f51fd2493046c52ec9539b7d02fe3763bd63e"><code>897f51f</code></a>
chore: release v5.0.0-rc.4 (<a
href="https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest/issues/11107">#11107</a>)</li>
<li><a
href="https://github.com/vitest-dev/vitest/commit/1339b0655dc4679aeb8f905bacee83b6f76f6b23"><code>1339b06</code></a>
chore(deps): update all non-major dependencies (<a
href="https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest/issues/11104">#11104</a>)</li>
<li><a
href="https://github.com/vitest-dev/vitest/commit/51e9494166d8d0108a621fce80c41b6dba760bae"><code>51e9494</code></a>
feat!: parse files statically in vitest list by default (<a
href="https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest/issues/11088">#11088</a>)</li>
<li><a
href="https://github.com/vitest-dev/vitest/commit/2122ffdfb42d86c9a2f1238100d6a031312cae32"><code>2122ffd</code></a>
fix: propagate --maxWorkers to projects (<a
href="https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest/issues/11102">#11102</a>)</li>
<li><a
href="https://github.com/vitest-dev/vitest/commit/dc10f5f8fb69b026afcf56f8a7a9dd36bd964d73"><code>dc10f5f</code></a>
fix(browser): report the action error when a task times out (<a
href="https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest/issues/11101">#11101</a>)</li>
<li><a
href="https://github.com/vitest-dev/vitest/commit/d4fe1986fe334d22940039e815ccdbcc907baa6a"><code>d4fe198</code></a>
feat: promote clearCache out of experimental (<a
href="https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest/issues/11086">#11086</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/vitest-dev/vitest/commits/v5.0.0/packages/vitest">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=vitest&package-manager=npm_and_yarn&previous-version=4.1.11&new-version=5.0.0)](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-09-11 02:07:57 -04:00
dependabot[bot] 68de5de241 build(deps-dev): bump the npm-deps group in /src/frontend with 4 updates (#1329)
Bumps the npm-deps group in /src/frontend with 4 updates:
[@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node),
[@types/react-dom](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react-dom),
[oxfmt](https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt) and
[oxlint](https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint).

Updates `@types/node` from 26.4.0 to 26.5.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.5 to 19.2.7
<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 `oxfmt` from 0.65.0 to 0.67.0
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/oxc-project/oxc/blob/main/npm/oxfmt/CHANGELOG.md">oxfmt's
changelog</a>.</em></p>
<blockquote>
<h2>[0.67.0] - 2026-09-07</h2>
<h3>🛡️ Security</h3>
<ul>
<li>9a612d5 deps: Update npm packages (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt/issues/26178">#26178</a>)
(renovate[bot])</li>
</ul>
<h2>[0.64.0] - 2026-08-18</h2>
<h3>🚀 Features</h3>
<ul>
<li>c07fe7c oxfmt: Support <code>experimentalOperatorPosition</code> (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt/issues/25643">#25643</a>)
(leaysgur)</li>
</ul>
<h3>📚 Documentation</h3>
<ul>
<li>fed6681 oxfmt: Skip expanding overrides options (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt/issues/25572">#25572</a>)
(leaysgur)</li>
</ul>
<h2>[0.62.0] - 2026-08-03</h2>
<h3>🐛 Bug Fixes</h3>
<ul>
<li>e6e584b oxfmt: Type jsdoc. enum options (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt/issues/25008">#25008</a>)
(leaysgur)</li>
</ul>
<h2>[0.59.0] - 2026-07-13</h2>
<h3>🐛 Bug Fixes</h3>
<ul>
<li>415fe1e oxfmt: Error on ignorePatterns that cannot match files
outside the config directory (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt/issues/24286">#24286</a>)
(leaysgur)</li>
</ul>
<h2>[0.55.0] - 2026-06-15</h2>
<h3>🚀 Features</h3>
<ul>
<li>9a2788b linter/unicorn: Implement <code>prefer-export-from</code>
rule (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt/issues/22935">#22935</a>)
(AliceLanniste)</li>
</ul>
<h2>[0.54.0] - 2026-06-08</h2>
<h3>📚 Documentation</h3>
<ul>
<li>dadafe3 oxlint, oxfmt: Mention migrate skills in npm READMEs (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt/issues/22965">#22965</a>)
(Boshen)</li>
<li>f88961a oxfmt: Annotate each config option with supported languages
(<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt/issues/22953">#22953</a>)
(leaysgur)</li>
</ul>
<h2>[0.52.0] - 2026-05-26</h2>
<h3>🚀 Features</h3>
<ul>
<li>16b8058 oxfmt: Support <code>vite-plus/resolveConfig</code> for
vite.config.ts (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt/issues/22454">#22454</a>)
(leaysgur)</li>
</ul>
<h2>[0.50.0] - 2026-05-15</h2>
<h3>🐛 Bug Fixes</h3>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/oxc-project/oxc/commit/b4da00b621ec2f6f67ed218f5366c45ed325331b"><code>b4da00b</code></a>
release(apps): oxlint v1.82.0 &amp;&amp; oxfmt v0.67.0 (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt/issues/26384">#26384</a>)</li>
<li><a
href="https://github.com/oxc-project/oxc/commit/9a612d57e5f1ad9ee6f9f0d8d67692cd430f7ecf"><code>9a612d5</code></a>
chore(deps): update npm packages (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt/issues/26178">#26178</a>)</li>
<li><a
href="https://github.com/oxc-project/oxc/commit/0b4e2e67f4193e7ebfcc64982275eb583ae82c83"><code>0b4e2e6</code></a>
release(apps): oxlint v1.81.0 &amp;&amp; oxfmt v0.66.0 (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt/issues/26199">#26199</a>)</li>
<li><a
href="https://github.com/oxc-project/oxc/commit/5ff57f83c049e338db8b2435dd92f660bfe19ed4"><code>5ff57f8</code></a>
fix(formatter/sort-imports): handle custom side effect groups (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt/issues/26217">#26217</a>)</li>
<li><a
href="https://github.com/oxc-project/oxc/commit/63bc313934bf1fe94f8e0e6e59336f6036b8506e"><code>63bc313</code></a>
chore(npm): update funding URL (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt/issues/26066">#26066</a>)</li>
<li>See full diff in <a
href="https://github.com/oxc-project/oxc/commits/oxfmt_v0.67.0/npm/oxfmt">compare
view</a></li>
</ul>
</details>
<br />

Updates `oxlint` from 1.80.0 to 1.82.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.82.0 &amp; oxfmt v0.67.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.82.0">Oxlint
v1.82.0</a></li>
<li><a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/blob/HEAD/#oxfmt-v0.67.0">Oxfmt
v0.67.0</a></li>
</ul>
<h2>Oxlint v1.82.0</h2>
<h3>💥 BREAKING CHANGES</h3>
<ul>
<li>66744f0 parser: [<strong>BREAKING</strong>] Rename
<code>panicked</code> to <code>fatal_error</code> in
<code>ParserReturn</code> (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/26382">#26382</a>)
(overlookmotel)</li>
</ul>
<h3>🚀 Features</h3>
<ul>
<li>6a0e19c linter/eslint/no-unmodified-loop-condition: Support
<code>checkConditionalExpressions</code> option (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/26249">#26249</a>)
(camc314)</li>
</ul>
<h3>🐛 Bug Fixes</h3>
<ul>
<li>aa38ddf linter/unicorn/numeric-separators-style: Correct schema
defaults (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/26393">#26393</a>)
(camc314)</li>
<li>562dd14 jsx-a11y/anchor-has-content: Allow anchors passed as custom
component props (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/26360">#26360</a>)
(Mikhail Baev)</li>
<li>253cdb2 linter/eslint/id-length: Exempt TS interface/type-literal
members with never (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/26310">#26310</a>)
(Pearce Ropion)</li>
<li>8c7ea76 ast_visit: Account for trimmed source offsets (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/26223">#26223</a>)
(Cameron)</li>
<li>5f9cffc linter/typescript/no-explicit-any: Fix rest parameter to
<code>unknown[]</code> (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/26279">#26279</a>)
(camc314)</li>
<li>4de9fa3 linter/react/jsx-no-literals: Check nested JSX expression
literals (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/26253">#26253</a>)
(camc314)</li>
<li>07851b9 parser: Fix debug assert failure when lexer error with
tokens enabled (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/26229">#26229</a>)
(overlookmotel)</li>
<li>9cee146 oxlint/lsp: Don't try to parse fix message, always fallback
(<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/26204">#26204</a>)
(Sysix)</li>
<li>a662c40 linter/eslint/no-useless-assignment: Handle destructuring
assignments (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/25925">#25925</a>)
(camc314)</li>
<li>304bd9a linter/eslint/no-array-constructor: Handle ASI hazards in
fixer (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/26166">#26166</a>)
(Hamody We)</li>
<li>b41bb35 linter: Qualify rules in rules output (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/26250">#26250</a>)
(camc314)</li>
<li>895c685 linter/typescript/no-extraneous-class: Avoid deleting class
expressions (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/26231">#26231</a>)
(camc314)</li>
<li>4ba33c8 linter/eslint/no-unused-vars: Handle stored arrow references
(<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/26225">#26225</a>)
(camc314)</li>
</ul>
<h3>🛡️ Security</h3>
<ul>
<li>9a612d5 deps: Update npm packages (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/26178">#26178</a>)
(renovate[bot])</li>
</ul>
<h2>Oxfmt v0.67.0</h2>
<h3>💥 BREAKING CHANGES</h3>
<ul>
<li>66744f0 parser: [<strong>BREAKING</strong>] Rename
<code>panicked</code> to <code>fatal_error</code> in
<code>ParserReturn</code> (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/26382">#26382</a>)
(overlookmotel)</li>
<li>2c9a947 parser: [<strong>BREAKING</strong>] Reduce
<code>MAX_LEN</code> to 256 bytes below <code>u32::MAX</code> (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/26352">#26352</a>)
(overlookmotel)</li>
</ul>
<h3>🚀 Features</h3>
<ul>
<li>68e12ab formatter_css: Follow the oxc-css-parser's acceptance line
(<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/26337">#26337</a>)
(leaysgur)</li>
</ul>
<h3>🐛 Bug Fixes</h3>
<ul>
<li>f7acdc0 formatter: Treat a JSDoc cast target as opaque in chain
layouts (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/26375">#26375</a>)
(leaysgur)</li>
<li>104061b formatter: Keep JSDoc cast parens with a comment inside them
(<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/26374">#26374</a>)
(leaysgur)</li>
<li>3d00a76 formatter_css: Keep comment inside important and simple-vars
colon (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/26370">#26370</a>)
(leaysgur)</li>
</ul>
<!-- 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>
<h2>[1.82.0] - 2026-09-07</h2>
<h3>🚀 Features</h3>
<ul>
<li>6a0e19c linter/eslint/no-unmodified-loop-condition: Support
<code>checkConditionalExpressions</code> option (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/26249">#26249</a>)
(camc314)</li>
</ul>
<h2>[1.81.0] - 2026-08-31</h2>
<h3>📚 Documentation</h3>
<ul>
<li>d5be037 linter/typescript/switch-exhaustiveness-check: Clarify
default case comment pattern (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/26100">#26100</a>)
(camc314)</li>
</ul>
<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>
<h2>[1.78.0] - 2026-08-10</h2>
<h3>🚀 Features</h3>
<ul>
<li>ccb8fe8 linter/jsdoc: Implement <code>no-blank-blocks</code> rule
(<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/25207">#25207</a>)
(Mikhail Baev)</li>
<li>d4a897c linter/eslint: Implement <code>one-var</code> rule (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/24470">#24470</a>)
(Cole Ellison)</li>
<li>5ab9340 linter/jsx-a11y/anchor-has-content: Add options to match
eslint (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/24571">#24571</a>)
(Cole Ellison)</li>
</ul>
<h3>🐛 Bug Fixes</h3>
<ul>
<li>9573937 linter/typescript: Validate <code>ban-ts-comment</code>
description_format (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/25320">#25320</a>)
(Mikhail Baev)</li>
</ul>
<h2>[1.77.0] - 2026-08-03</h2>
<h3>🐛 Bug Fixes</h3>
<ul>
<li>5c0fa61 linter/eslint/no-warning-comments: Unify config structs and
remove manual options docs (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/25151">#25151</a>)
(Mikhail Baev)</li>
</ul>
<h3>📚 Documentation</h3>
<ul>
<li>9dc7756 linter/typescript/no-unnecessary-condition: Clarify options
(<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/25110">#25110</a>)
(camc314)</li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/oxc-project/oxc/commit/b4da00b621ec2f6f67ed218f5366c45ed325331b"><code>b4da00b</code></a>
release(apps): oxlint v1.82.0 &amp;&amp; oxfmt v0.67.0 (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/26384">#26384</a>)</li>
<li><a
href="https://github.com/oxc-project/oxc/commit/aa38ddfbea657a9d332f86e42e235ff8cbdcac6d"><code>aa38ddf</code></a>
fix(linter/unicorn/numeric-separators-style): correct schema defaults
(<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/26393">#26393</a>)</li>
<li><a
href="https://github.com/oxc-project/oxc/commit/6a0e19cfaae35ec882de6bc8dde7668d1b0cc58e"><code>6a0e19c</code></a>
feat(linter/eslint/no-unmodified-loop-condition): support
`checkConditionalEx...</li>
<li><a
href="https://github.com/oxc-project/oxc/commit/0b4e2e67f4193e7ebfcc64982275eb583ae82c83"><code>0b4e2e6</code></a>
release(apps): oxlint v1.81.0 &amp;&amp; oxfmt v0.66.0 (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/26199">#26199</a>)</li>
<li><a
href="https://github.com/oxc-project/oxc/commit/d5be0375e6303202fcd6d34fa520103eceea0238"><code>d5be037</code></a>
docs(linter/typescript/switch-exhaustiveness-check): clarify default
case com...</li>
<li><a
href="https://github.com/oxc-project/oxc/commit/63bc313934bf1fe94f8e0e6e59336f6036b8506e"><code>63bc313</code></a>
chore(npm): update funding URL (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/26066">#26066</a>)</li>
<li>See full diff in <a
href="https://github.com/oxc-project/oxc/commits/oxlint_v1.82.0/npm/oxlint">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-11 02:07:23 -04:00
dependabot[bot] 9eb47989ec build(deps): bump the docker-base-image-digests group with 2 updates (#1328)
> [!WARNING]
> Cooldown could not be applied because no publication date was
available from the registry.
>

Bumps the docker-base-image-digests group with 2 updates: node and
[astral-sh/uv](https://github.com/astral-sh/uv).

Updates `node` from `e67514e` to `50c8e8c`

Updates `astral-sh/uv` from 0.12.9 to 0.12.13
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/astral-sh/uv/releases">astral-sh/uv's
releases</a>.</em></p>
<blockquote>
<h2>0.12.13</h2>
<h2>Release Notes</h2>
<p>Released on 2026-09-10.</p>
<h3>Python</h3>
<ul>
<li>Add GraalPy 3.13.0 (<a
href="https://redirect.github.com/astral-sh/uv/pull/21431">#21431</a>)</li>
</ul>
<h3>Enhancements</h3>
<ul>
<li>Verify hashes when downloading PEP 658 metadata sidecars (<a
href="https://redirect.github.com/astral-sh/uv/pull/21563">#21563</a>)</li>
</ul>
<h3>Preview features</h3>
<ul>
<li>Respect <code>ty</code> exclusions when <code>uv check</code>
automatically selects members of a virtual workspace (<a
href="https://redirect.github.com/astral-sh/uv/pull/21555">#21555</a>)</li>
</ul>
<h3>Performance</h3>
<ul>
<li>Avoid full wheel downloads during resolution by reusing supported
hashes from direct URL fragments when metadata is available separately
(<a
href="https://redirect.github.com/astral-sh/uv/pull/21279">#21279</a>)</li>
</ul>
<h3>Bug fixes</h3>
<ul>
<li>Edit Windows entry-point launcher resources in memory to support
Nano Server and reduce antivirus contention (<a
href="https://redirect.github.com/astral-sh/uv/pull/18713">#18713</a>)</li>
<li>Prefer <code>core-metadata</code> over legacy aliases in JSON index
responses (<a
href="https://redirect.github.com/astral-sh/uv/pull/21563">#21563</a>)</li>
</ul>
<h2>Install uv 0.12.13</h2>
<h3>Install prebuilt binaries via shell script</h3>
<pre lang="sh"><code>curl --proto '=https' --tlsv1.2 -LsSf
https://releases.astral.sh/github/uv/releases/download/0.12.13/uv-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/uv/releases/download/0.12.13/uv-installer.ps1
| iex&quot;
</code></pre>
<h2>Download uv 0.12.13</h2>
<table>
<thead>
<tr>
<th>File</th>
<th>Platform</th>
<th>Checksum</th>
</tr>
</thead>
<tbody>
<tr>
<td><a
href="https://releases.astral.sh/github/uv/releases/download/0.12.13/uv-aarch64-apple-darwin.tar.gz">uv-aarch64-apple-darwin.tar.gz</a></td>
<td>Apple Silicon macOS</td>
<td><a
href="https://releases.astral.sh/github/uv/releases/download/0.12.13/uv-aarch64-apple-darwin.tar.gz.sha256">checksum</a></td>
</tr>
<tr>
<td><a
href="https://releases.astral.sh/github/uv/releases/download/0.12.13/uv-x86_64-apple-darwin.tar.gz">uv-x86_64-apple-darwin.tar.gz</a></td>
<td>Intel macOS</td>
<td><a
href="https://releases.astral.sh/github/uv/releases/download/0.12.13/uv-x86_64-apple-darwin.tar.gz.sha256">checksum</a></td>
</tr>
<tr>
<td><a
href="https://releases.astral.sh/github/uv/releases/download/0.12.13/uv-aarch64-pc-windows-msvc.zip">uv-aarch64-pc-windows-msvc.zip</a></td>
<td>ARM64 Windows</td>
<td><a
href="https://releases.astral.sh/github/uv/releases/download/0.12.13/uv-aarch64-pc-windows-msvc.zip.sha256">checksum</a></td>
</tr>
<tr>
<td><a
href="https://releases.astral.sh/github/uv/releases/download/0.12.13/uv-i686-pc-windows-msvc.zip">uv-i686-pc-windows-msvc.zip</a></td>
<td>x86 Windows</td>
<td><a
href="https://releases.astral.sh/github/uv/releases/download/0.12.13/uv-i686-pc-windows-msvc.zip.sha256">checksum</a></td>
</tr>
<tr>
<td><a
href="https://releases.astral.sh/github/uv/releases/download/0.12.13/uv-x86_64-pc-windows-msvc.zip">uv-x86_64-pc-windows-msvc.zip</a></td>
<td>x64 Windows</td>
<td><a
href="https://releases.astral.sh/github/uv/releases/download/0.12.13/uv-x86_64-pc-windows-msvc.zip.sha256">checksum</a></td>
</tr>
<tr>
<td><a
href="https://releases.astral.sh/github/uv/releases/download/0.12.13/uv-aarch64-unknown-linux-gnu.tar.gz">uv-aarch64-unknown-linux-gnu.tar.gz</a></td>
<td>ARM64 Linux</td>
<td><a
href="https://releases.astral.sh/github/uv/releases/download/0.12.13/uv-aarch64-unknown-linux-gnu.tar.gz.sha256">checksum</a></td>
</tr>
</tbody>
</table>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/astral-sh/uv/blob/main/CHANGELOG.md">astral-sh/uv's
changelog</a>.</em></p>
<blockquote>
<h2>0.12.13</h2>
<p>Released on 2026-09-10.</p>
<h3>Python</h3>
<ul>
<li>Add GraalPy 3.13.0 (<a
href="https://redirect.github.com/astral-sh/uv/pull/21431">#21431</a>)</li>
</ul>
<h3>Enhancements</h3>
<ul>
<li>Verify hashes when downloading PEP 658 metadata sidecars (<a
href="https://redirect.github.com/astral-sh/uv/pull/21563">#21563</a>)</li>
</ul>
<h3>Preview features</h3>
<ul>
<li>Respect <code>ty</code> exclusions when <code>uv check</code>
automatically selects members of a virtual workspace (<a
href="https://redirect.github.com/astral-sh/uv/pull/21555">#21555</a>)</li>
</ul>
<h3>Performance</h3>
<ul>
<li>Avoid full wheel downloads during resolution by reusing supported
hashes from direct URL fragments when metadata is available separately
(<a
href="https://redirect.github.com/astral-sh/uv/pull/21279">#21279</a>)</li>
</ul>
<h3>Bug fixes</h3>
<ul>
<li>Edit Windows entry-point launcher resources in memory to support
Nano Server and reduce antivirus contention (<a
href="https://redirect.github.com/astral-sh/uv/pull/18713">#18713</a>)</li>
<li>Prefer <code>core-metadata</code> over legacy aliases in JSON index
responses (<a
href="https://redirect.github.com/astral-sh/uv/pull/21563">#21563</a>)</li>
</ul>
<h2>0.12.12</h2>
<p>Released on 2026-09-09.</p>
<p>The executables in our macOS and Windows release archives and
<code>uv</code> and <code>uv_build</code> wheels are now code-signed.
macOS executables are signed with an Apple Developer ID certificate and
notarized by Apple. Windows executables have timestamped Authenticode
signatures from Azure Artifact Signing. This enables verification of the
release publisher and binary integrity, supports publisher-based
allowlisting, and should reduce security warnings and antivirus false
positives.</p>
<h3>Bug fixes</h3>
<ul>
<li>Exclude distributions uploaded after the <code>exclude-newer</code>
cutoff from lockfiles and generated requirement hashes (<a
href="https://redirect.github.com/astral-sh/uv/pull/21539">#21539</a>)</li>
</ul>
<h2>0.12.11</h2>
<p>Released on 2026-09-08.</p>
<h3>Preview features</h3>
<ul>
<li>Generate missing artifact hashes when exporting
<code>pylock.toml</code> files to ensure they conform to PEP 751 (<a
href="https://redirect.github.com/astral-sh/uv/pull/20146">#20146</a>)</li>
<li>Warn when <code>pylock.toml</code> artifact hash tables are empty,
which will be rejected in a future uv release (<a
href="https://redirect.github.com/astral-sh/uv/pull/21462">#21462</a>)</li>
</ul>
<h3>Performance</h3>
<ul>
<li>Speed up installs that overwrite existing files by eliminating
per-file temporary directories for atomic hard-link, symlink, and
reflink replacements (<a
href="https://redirect.github.com/astral-sh/uv/pull/21478">#21478</a>)</li>
<li>Speed up installs that merge copied wheels into existing
environments by replacing per-file temporary directories with adjacent
temporary files (<a
href="https://redirect.github.com/astral-sh/uv/pull/21468">#21468</a>)</li>
<li>Speed up local wheel installs by replacing the shared ZIP cursor
lock with positioned reads (<a
href="https://redirect.github.com/astral-sh/uv/pull/21500">#21500</a>)</li>
<li>Speed up local wheel installs by reusing ZIP readers and buffers
across extracted files (<a
href="https://redirect.github.com/astral-sh/uv/pull/21499">#21499</a>)</li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/astral-sh/uv/commit/0ebbd9274a55a8a53a13970be3b97e4209598e17"><code>0ebbd92</code></a>
Bump version to 0.12.13 (<a
href="https://redirect.github.com/astral-sh/uv/issues/21594">#21594</a>)</li>
<li><a
href="https://github.com/astral-sh/uv/commit/17ccae34815e249c66e53abe21916c3c0b6413e5"><code>17ccae3</code></a>
Use separate Depot projects for Docker development builds (<a
href="https://redirect.github.com/astral-sh/uv/issues/21591">#21591</a>)</li>
<li><a
href="https://github.com/astral-sh/uv/commit/8c18e15bd36dff0e17509cc68c3721798ae1ec2b"><code>8c18e15</code></a>
Use the workflow revision for PR security review configuration (<a
href="https://redirect.github.com/astral-sh/uv/issues/21592">#21592</a>)</li>
<li><a
href="https://github.com/astral-sh/uv/commit/9ebb1f6a72db257a689049ee060b7cf93638aada"><code>9ebb1f6</code></a>
Clarify release pipeline job names (<a
href="https://redirect.github.com/astral-sh/uv/issues/21561">#21561</a>)</li>
<li><a
href="https://github.com/astral-sh/uv/commit/d87450d82b3941507e48ac8b57f72a9d00709289"><code>d87450d</code></a>
Omit unused <code>exclude-newer-package</code> entries from script locks
(<a
href="https://redirect.github.com/astral-sh/uv/issues/21589">#21589</a>)</li>
<li><a
href="https://github.com/astral-sh/uv/commit/911f3a208c104b07b2f4da3a2283564d9a95c403"><code>911f3a2</code></a>
Refactor release artifact handling (<a
href="https://redirect.github.com/astral-sh/uv/issues/21556">#21556</a>)</li>
<li><a
href="https://github.com/astral-sh/uv/commit/a712e811024933ec441667659d6df73c04f5b678"><code>a712e81</code></a>
Separate Docker release builds from publishing (<a
href="https://redirect.github.com/astral-sh/uv/issues/21586">#21586</a>)</li>
<li><a
href="https://github.com/astral-sh/uv/commit/4196dae60a0cc542bd43c0188f8b4c6dbfa0df98"><code>4196dae</code></a>
Use xhigh effort for PR security review (<a
href="https://redirect.github.com/astral-sh/uv/issues/21530">#21530</a>)</li>
<li><a
href="https://github.com/astral-sh/uv/commit/a51624b4791456260f72068ae878796233f72809"><code>a51624b</code></a>
Reduce PR security review reporting work (<a
href="https://redirect.github.com/astral-sh/uv/issues/21528">#21528</a>)</li>
<li><a
href="https://github.com/astral-sh/uv/commit/63e28b69821b4b55b90287d799cbf97ff6ba0f74"><code>63e28b6</code></a>
Use <code>editpe</code> for trampoline resource edits (<a
href="https://redirect.github.com/astral-sh/uv/issues/18713">#18713</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/astral-sh/uv/compare/0.12.9...0.12.13">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-11 02:07:12 -04:00
Austin Brogle 99e0cfde3d fix: prevent Anna's Archive download countdown resets by preserving browser sessions (#1325)
## Observed bug

Anna's Archive slow-download pages can return a JavaScript countdown
before a download link is available. The internal browser returns that
waiting-room HTML and closes its incognito session. The downloader then
sleeps and fetches the URL again, which can create a new queue session
and **restart the countdown instead of reaching the download link**.

## Fix

- **Preserve the queue session:** keep the original browser tab open
while the site's own countdown and automatic navigation finish. HTTP 200
and cached-cookie waiting-room responses enter the same flow.
- **Return a consistent page:** capture HTML and readiness together in
one browser evaluation so navigation cannot pair a new page's status
with stale protection-page HTML. Share cache validation and
page-readiness rules across their callers.
- **Keep waiting cancellable and bounded:** poll cancellation while a
slow browser read remains pending, rather than repeatedly cancelling and
reissuing it. Apply a **300-second waiting-room limit** within the
existing browser watchdog.
- **Report queue timeouts accurately:** preserve the timeout across the
helper-process boundary and stop the solve without restarting the
browser or rotating mirrors.

Waiting-room detection is limited to Anna's Archive `/slow_download/`
pages containing an actual `.js-partner-countdown` element. The site
controls the countdown and refresh. External-bypasser behavior and
file-transfer timeouts are unchanged; the PR adds no deployment
configuration or dependencies.

## Validation

Validated at `c81e0a2`:

| Check | Result |
| --- | --- |
| Full Linux unit suite | **2,978 passed** on Python 3.14 in a non-root
environment with entrypoint test stubs enabled |
| Focused regression coverage | **30 passed**, covering countdown
completion, zero timers, navigation, both cookie-cache paths,
cancellation, stuck queues, slow reads, and timeout propagation |
| Navigation-race regression | Fails against the previous PR
implementation and passes with the fix |
| Python static checks | Ruff lint/format, BasedPyright for backend and
tests, and Vulture passed |
| Real Chromium fixture | Queue cookie persisted through 1.5-second DOM
reads and one automatic refresh; the CDP connection survived multiple
polling intervals |
| Live source check | Observed **19 → 14 → 9 → 4 → download link** while
retaining the browser session; the patched browser path also completed
the waiting room |

Full unit-suite command:

```sh
pytest tests/ -n 2 --tb=short -m "not integration and not e2e"
```

The live check validates waiting-room completion and link resolution.
Remote file-host availability remains a separate concern. The unit suite
emitted two existing Authlib deprecation warnings.
2026-09-11 00:51:48 -04:00
Jorge Lima c45d342931 fix(prowlarr): skip indexers in Prowlarr failure back-off (#1324)
## What

Read `/api/v1/indexerstatus` once per search and skip indexers whose
`disabledTill` is still ahead. Skipped is neither attempted nor failed.
One client method, one counter on `_IndexerSearchOutcome`, ten tests.

## Why

Prowlarr's own search leaves out an indexer it has disabled after
repeated failures. Shelfmark queries each indexer through its Torznab
endpoint, which answers 429 instead:

```
Prowlarr Torznab error response: <error code="429" description="Indexer is disabled till 09/09/2026 15:00:34 due to recent failures." />
Prowlarr: 1 of 5 indexer searches failed (indexer 2 search failed: 429 Client Error: Too Many Requests ...)
Release search failed for source prowlarr: 1 of 5 indexer searches failed (...)
```

That counted as a failed search, so with one indexer in back-off and the
other four answering empty, `/api/releases?source=prowlarr` returned 503
for every book for the length of the back-off (one hour here).

`/api/v1/indexerstatus` on Prowlarr 2.5.2:

```json
[{"indexerId": 2, "disabledTill": "2026-09-09T15:00:34Z", "mostRecentFailure": "2026-09-09T14:00:34Z", "initialFailure": "2026-09-09T14:00:34Z"}]
```

## Behaviour

| indexers | before | after |
|---|---|---|
| 1 in back-off, 4 answer empty | 503 "1 of 5 indexer searches failed" |
"No releases found" |
| 1 in back-off, 1 answers with releases | releases | releases, one
Torznab call fewer |
| 1 in back-off, 1 times out, 3 answer empty | "1 of 5 failed" | "1 of 4
failed" |
| every indexer in back-off | 503 "5 of 5 failed" | "every indexer is
disabled by Prowlarr after recent failures (until ...)" |
| status endpoint unreachable | n/a | as before, nothing skipped |

Auto-expand no longer retries a pass in which nothing was asked.

## Tests

`uv run pytest tests/prowlarr`: 563 passed, 42 skipped. `ruff check` and
`ruff format` clean.
2026-09-11 00:49:33 -04:00
oliver 1e3fd48b8b fix: share rotating log file handlers (#1316)
This patch shares (for each log file) the `RotatingFileHandler` for
logging across all modules, reducing the number of open file descriptors
from ~78 to 1.

I had originally assumed this issue was a resource leak, but it seems to
just be a large fixed number of file descriptors. So this change mostly
just (1) shrinks the number of open file descriptors to a reasonable
level and (2) prevents two modules in the same process competing to
write to a log file.
2026-09-11 00:48:22 -04:00
Vinicius GabrielandClaude Sonnet 5 c576003319 feat(naming): add {FirstAuthor} template token (#1322)
Closes #930.

## What

New `{FirstAuthor}` naming-template token. It renders only the first
author when metadata lists several ("Author1, Author2, Author3"), so
multi-author books can be filed alongside the rest of that author's work
instead of getting their own "Author1, Author2, ..." folder.

```
{Author}       -> Terry Pratchett, Neil Gaiman
{FirstAuthor}  -> Terry Pratchett
```

## How

- Added to `KNOWN_TOKENS` in `shelfmark/core/naming.py`, positioned
before `author` so `{FirstAuthor}` isn't parsed as literal `First` +
`{Author}`.
- Derived inside `parse_naming_template` from the existing `Author`
value (split on `,` / `;`), so every caller — folder transfer, rename,
the settings preview — picks it up with no extra wiring. An explicit
`FirstAuthor` key in the metadata still wins if one is ever passed.
- `{Author}` behaviour is unchanged.
- Frontend `namingTemplatePreview.ts` token list + `KNOWN_TOKENS` kept
in lockstep (there's a test enforcing that), with a matching
`firstAuthor` helper.
- Settings field descriptions + `docs/environment-variables.md` list the
new token.

## Known limitation

A lone author written `Last, First` is split on the comma too and
renders as `Last` — the source metadata doesn't mark which form it is.
Called out in the token help text and covered by a test. `{Author}`
remains available for anyone who wants the raw string.

## Checks

- `make python-test` — 2963 passed
- `make python-lint` / `make python-format` / `make python-typecheck` /
vulture — clean
- `make frontend-test` — 187 passed · `frontend-lint` /
`frontend-format` / `frontend-typecheck` — clean

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 00:47:37 -04:00
Atirna 265da07d7f feat(download): add Blackhole torrent handoff (#1312)
## Why

Blackhole users need Shelfmark to hand a torrent file to their existing
downloader instead of importing the downloaded book itself.

## Change

- add Blackhole as a torrent client with a configurable watched
directory
- prefer a fetched `.torrent` file for Blackhole while preserving magnet
preference for other clients
- complete the queue task after the handoff without invoking book
post-processing

## Verification

- `uv run pytest -q tests/prowlarr/test_blackhole_client.py
tests/prowlarr/test_handler.py tests/newznab/test_handler.py
tests/download/test_orchestrator_lifecycle.py`
- `uv run basedpyright shelfmark/download/clients/blackhole.py
shelfmark/download/clients/__init__.py
shelfmark/download/clients/base_handler.py
shelfmark/download/clients/settings.py
shelfmark/download/orchestrator.py shelfmark/release_sources/__init__.py
shelfmark/release_sources/prowlarr/utils.py
shelfmark/release_sources/prowlarr/handler.py
shelfmark/release_sources/newznab/handler.py
tests/prowlarr/test_blackhole_client.py tests/prowlarr/test_handler.py
tests/newznab/test_handler.py
tests/download/test_orchestrator_lifecycle.py`

Fixes #1229
2026-09-11 00:46:47 -04:00
Nicholas Velten 96d1b7c33a Keep default filters out of the URL hash (#1314)
Follow up to #1311, per your "no use keeping empty / default values in
the url".

A plain author search was coming out as

```
#q=herbert&search_by=author&sort=relevance&lang=default&format=epub&format=mobi&format=azw3&format=fb2&format=djvu&format=cbz&format=cbr
```

The builder serialized every advanced filter regardless of whether the
user had touched it. Now `sort` drops when it equals the sort the app
would apply anyway (the provider default in Universal, the configured
one in Direct), `lang` drops while it is still the `default` sentinel,
and `format` drops when the selection matches `supported_formats` from
the config. Formats are compared as a set, so reselecting everything in
a different order still counts as default. Anything the user actually
changed stays.

That also makes the comment I left in the builder true: a default-state
URL now carries no hash at all.

The second commit is an ordering bug that omitting defaults made
visible. `loadConfig`'s `initial` branch seeds `formats` from
`supported_formats` and `sort` from the mode default. The URL bootstrap
is gated on config being loaded, so it normally runs after that and wins
on its own, but nothing guarantees only one `initial` load happens, and
a second one landing after the bootstrap resets `formats` to the full
supported list and drops the sort the link asked for. StrictMode
double-invokes the mount effect that triggers it, so it reproduces in
development: `#q=dune&format=epub&lang=en` intermittently loses its
`format=epub`. The seeding is now skipped once the bootstrap has
applied, so a link's filters win over the defaults they were meant to
override.

Five new unit tests on the builder. I also drove both the dev server and
a production build in Chromium with `/api` mocked:
`#search_by=author&q=herbert` settles at `#q=herbert&search_by=author`,
typing a plain query gives `#q=dune`, direct mode at rest carries no
hash, and `#q=dune&format=epub&lang=en` keeps both filters in the hash
and in the request. That last one was 3 for 5 on the dev server before
the second commit and 5 for 5 after.
2026-09-11 00:36:55 -04:00
dependabot[bot] 6addae9d7c build(deps): bump the python-deps group across 1 directory with 4 updates (#1327)
Bumps the python-deps group with 4 updates in the / directory:
[seleniumbase](https://github.com/seleniumbase/SeleniumBase),
[basedpyright](https://github.com/detachhead/basedpyright),
[prek](https://github.com/j178/prek) and
[ruff](https://github.com/astral-sh/ruff).

Updates `seleniumbase` from 4.53.5 to 4.53.7
<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.53.7 - MCP Server: Patch 7</h2>
<h2>MCP Server: Patch 7</h2>
<ul>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/698672b1d14d397c8fabef17c14c36efb01047fa">Update
the MCP Server</a></li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/f5c40a2de161745c0de5130ad7479dc823aaf08e">Refresh
Python dependencies</a></li>
</ul>
<h2>What's Changed</h2>
<ul>
<li>MCP Server: Patch 7 by <a
href="https://github.com/mdmintz"><code>@​mdmintz</code></a> in <a
href="https://redirect.github.com/seleniumbase/SeleniumBase/pull/4493">seleniumbase/SeleniumBase#4493</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/seleniumbase/SeleniumBase/compare/v4.53.6...v4.53.7">https://github.com/seleniumbase/SeleniumBase/compare/v4.53.6...v4.53.7</a></p>
<h2>4.53.6 - MCP Server: Patch 6</h2>
<h2>MCP Server: Patch 6</h2>
<ul>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/b477cb359f466e5d6f712c6c30a03150076e1420">Update
the MCP server</a></li>
</ul>
<h2>What's Changed</h2>
<ul>
<li>MCP Server: Patch 6 by <a
href="https://github.com/mdmintz"><code>@​mdmintz</code></a> in <a
href="https://redirect.github.com/seleniumbase/SeleniumBase/pull/4491">seleniumbase/SeleniumBase#4491</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/seleniumbase/SeleniumBase/compare/v4.53.5...v4.53.6">https://github.com/seleniumbase/SeleniumBase/compare/v4.53.5...v4.53.6</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/4ee7dfc4ae83c19385f5ac129f2cda0cfa863d80"><code>4ee7dfc</code></a>
Merge pull request <a
href="https://redirect.github.com/seleniumbase/SeleniumBase/issues/4493">#4493</a>
from seleniumbase/mcp-server-patch-7</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/a9c9253eec504274d3dbe19202ec858201fe4fd8"><code>a9c9253</code></a>
Version 4.53.7</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/f5c40a2de161745c0de5130ad7479dc823aaf08e"><code>f5c40a2</code></a>
Refresh Python dependencies</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/698672b1d14d397c8fabef17c14c36efb01047fa"><code>698672b</code></a>
Update the MCP Server</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/db0d4041dde54142a2dd9810b23b30b6aa40fd97"><code>db0d404</code></a>
Merge pull request <a
href="https://redirect.github.com/seleniumbase/SeleniumBase/issues/4491">#4491</a>
from seleniumbase/mcp-server-patch-6</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/7b835cfbad6becff033a9ae059b84775f42a4e2e"><code>7b835cf</code></a>
Version 4.53.6</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/b477cb359f466e5d6f712c6c30a03150076e1420"><code>b477cb3</code></a>
Update the MCP server</li>
<li>See full diff in <a
href="https://github.com/seleniumbase/SeleniumBase/compare/v4.53.5...v4.53.7">compare
view</a></li>
</ul>
</details>
<br />

Updates `basedpyright` from 1.39.10 to 1.40.0
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/detachhead/basedpyright/releases">basedpyright's
releases</a>.</em></p>
<blockquote>
<h2>v1.40.0 (pyright 1.1.412)</h2>
<h2>What's Changed</h2>
<ul>
<li>drop support for python 3.8 and 3.9 in the pypi package, which are
EOL by <a
href="https://github.com/DetachHead"><code>@​DetachHead</code></a> in <a
href="https://redirect.github.com/DetachHead/basedpyright/pull/1876">DetachHead/basedpyright#1876</a>
<ul>
<li>users who still need to use unsupported Python versions can still
install <a href="https://npmjs.com/basedpyright">the basedpyright npm
package</a></li>
</ul>
</li>
<li>fix rendering of newlines for builtin docstrings by <a
href="https://github.com/tommyming"><code>@​tommyming</code></a> in <a
href="https://redirect.github.com/DetachHead/basedpyright/pull/1871">DetachHead/basedpyright#1871</a></li>
<li>migrate the docs site to zensical by <a
href="https://github.com/DetachHead"><code>@​DetachHead</code></a> in <a
href="https://redirect.github.com/DetachHead/basedpyright/pull/1795">DetachHead/basedpyright#1795</a></li>
<li>move locale env variable docs to the more appropriate
&quot;Environment Variables&quot; page by <a
href="https://github.com/DetachHead"><code>@​DetachHead</code></a> in <a
href="https://redirect.github.com/DetachHead/basedpyright/pull/1878">DetachHead/basedpyright#1878</a></li>
<li>Support new Python Environments API when enabled by <a
href="https://github.com/Weidav"><code>@​Weidav</code></a> in <a
href="https://redirect.github.com/DetachHead/basedpyright/pull/1860">DetachHead/basedpyright#1860</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a href="https://github.com/tommyming"><code>@​tommyming</code></a>
made their first contribution in <a
href="https://redirect.github.com/DetachHead/basedpyright/pull/1871">DetachHead/basedpyright#1871</a></li>
<li><a href="https://github.com/Weidav"><code>@​Weidav</code></a> made
their first contribution in <a
href="https://redirect.github.com/DetachHead/basedpyright/pull/1860">DetachHead/basedpyright#1860</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/DetachHead/basedpyright/compare/v1.39.10...v1.40.0">https://github.com/DetachHead/basedpyright/compare/v1.39.10...v1.40.0</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/DetachHead/basedpyright/commit/d64b2274480bc64c146f5b387127d4bb35eb403e"><code>d64b227</code></a>
1.40.0</li>
<li><a
href="https://github.com/DetachHead/basedpyright/commit/1a9ec37d0cffde93059b5993fb094f7dc9e91242"><code>1a9ec37</code></a>
Support new Python Environments API when enabled (<a
href="https://redirect.github.com/detachhead/basedpyright/issues/1860">#1860</a>)</li>
<li><a
href="https://github.com/DetachHead/basedpyright/commit/37ab66d1bcc6c3e00b391f50b1da9a313e785a96"><code>37ab66d</code></a>
move locale env variable docs to the more appropriate &quot;Environment
Variables&quot;...</li>
<li><a
href="https://github.com/DetachHead/basedpyright/commit/db9be3ceda3b101695574b24761e1bf42b0c5701"><code>db9be3c</code></a>
zensical (<a
href="https://redirect.github.com/detachhead/basedpyright/issues/1795">#1795</a>)</li>
<li><a
href="https://github.com/DetachHead/basedpyright/commit/f408bd7ab1adb419fd1986ccc3a79723e29f4c8e"><code>f408bd7</code></a>
fix rendering of newlines for builtin docstrings (<a
href="https://redirect.github.com/detachhead/basedpyright/issues/1871">#1871</a>)</li>
<li><a
href="https://github.com/DetachHead/basedpyright/commit/0310a8d3cf6346978ea511e4fb9b1d857fc837d8"><code>0310a8d</code></a>
drop support for python 3.8 and 3.9 in the pypi package, which are
EOL.</li>
<li><a
href="https://github.com/DetachHead/basedpyright/commit/8a06544547fd26b32fe0f3ff334ce125ba8ca5e7"><code>8a06544</code></a>
bump gg deps and python deps</li>
<li><a
href="https://github.com/DetachHead/basedpyright/commit/01ffa7fa80ece700dc0e824de7da761f6554e5fa"><code>01ffa7f</code></a>
bump gg to fix download indicator interfering with primer output</li>
<li><a
href="https://github.com/DetachHead/basedpyright/commit/d5db35dd53d3af48809e5d04e1d0714113363092"><code>d5db35d</code></a>
workaround gg download progress indicator interfering with primer
output</li>
<li><a
href="https://github.com/DetachHead/basedpyright/commit/dc53c61a95a5d60a348033ed4fba09e3f73d7cba"><code>dc53c61</code></a>
fix primer</li>
<li>Additional commits viewable in <a
href="https://github.com/detachhead/basedpyright/compare/v1.39.10...v1.40.0">compare
view</a></li>
</ul>
</details>
<br />

Updates `prek` from 0.5.0 to 0.5.2
<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.2</h2>
<h2>Release Notes</h2>
<p>Released on 2026-09-03.</p>
<h3>Enhancements</h3>
<ul>
<li>Allow unknown tags by default in <code>check-yaml</code> (<a
href="https://redirect.github.com/j178/prek/pull/2678">#2678</a>)</li>
</ul>
<h3>Contributors</h3>
<ul>
<li><a href="https://github.com/j178"><code>@​j178</code></a></li>
</ul>
<h2>Install prek 0.5.2</h2>
<h3>Install prebuilt binaries via shell script</h3>
<pre lang="sh"><code>curl --proto '=https' --tlsv1.2 -LsSf
https://github.com/j178/prek/releases/download/v0.5.2/prek-installer.sh
| sh
</code></pre>
<h3>Install prebuilt binaries via powershell script</h3>
<pre lang="sh"><code>powershell -ExecutionPolicy Bypass -c &quot;irm
https://github.com/j178/prek/releases/download/v0.5.2/prek-installer.ps1
| iex&quot;
</code></pre>
<h3>Install prebuilt binaries via Homebrew</h3>
<pre lang="sh"><code>brew install prek
</code></pre>
<h2>Download prek 0.5.2</h2>
<table>
<thead>
<tr>
<th>File</th>
<th>Platform</th>
<th>Checksum</th>
</tr>
</thead>
<tbody>
<tr>
<td><a
href="https://github.com/j178/prek/releases/download/v0.5.2/prek-aarch64-apple-darwin.tar.gz">prek-aarch64-apple-darwin.tar.gz</a></td>
<td>Apple Silicon macOS</td>
<td><a
href="https://github.com/j178/prek/releases/download/v0.5.2/prek-aarch64-apple-darwin.tar.gz.sha256">checksum</a></td>
</tr>
<tr>
<td><a
href="https://github.com/j178/prek/releases/download/v0.5.2/prek-x86_64-apple-darwin.tar.gz">prek-x86_64-apple-darwin.tar.gz</a></td>
<td>Intel macOS</td>
<td><a
href="https://github.com/j178/prek/releases/download/v0.5.2/prek-x86_64-apple-darwin.tar.gz.sha256">checksum</a></td>
</tr>
<tr>
<td><a
href="https://github.com/j178/prek/releases/download/v0.5.2/prek-aarch64-pc-windows-msvc.zip">prek-aarch64-pc-windows-msvc.zip</a></td>
<td>ARM64 Windows</td>
<td><a
href="https://github.com/j178/prek/releases/download/v0.5.2/prek-aarch64-pc-windows-msvc.zip.sha256">checksum</a></td>
</tr>
<tr>
<td><a
href="https://github.com/j178/prek/releases/download/v0.5.2/prek-x86_64-pc-windows-msvc.zip">prek-x86_64-pc-windows-msvc.zip</a></td>
<td>x64 Windows</td>
<td><a
href="https://github.com/j178/prek/releases/download/v0.5.2/prek-x86_64-pc-windows-msvc.zip.sha256">checksum</a></td>
</tr>
<tr>
<td><a
href="https://github.com/j178/prek/releases/download/v0.5.2/prek-aarch64-unknown-linux-gnu.tar.gz">prek-aarch64-unknown-linux-gnu.tar.gz</a></td>
<td>ARM64 Linux</td>
<td><a
href="https://github.com/j178/prek/releases/download/v0.5.2/prek-aarch64-unknown-linux-gnu.tar.gz.sha256">checksum</a></td>
</tr>
<tr>
<td><a
href="https://github.com/j178/prek/releases/download/v0.5.2/prek-x86_64-unknown-linux-gnu.tar.gz">prek-x86_64-unknown-linux-gnu.tar.gz</a></td>
<td>x64 Linux</td>
<td><a
href="https://github.com/j178/prek/releases/download/v0.5.2/prek-x86_64-unknown-linux-gnu.tar.gz.sha256">checksum</a></td>
</tr>
<tr>
<td><a
href="https://github.com/j178/prek/releases/download/v0.5.2/prek-aarch64-unknown-linux-musl.tar.gz">prek-aarch64-unknown-linux-musl.tar.gz</a></td>
<td>ARM64 MUSL Linux</td>
<td><a
href="https://github.com/j178/prek/releases/download/v0.5.2/prek-aarch64-unknown-linux-musl.tar.gz.sha256">checksum</a></td>
</tr>
<tr>
<td><a
href="https://github.com/j178/prek/releases/download/v0.5.2/prek-x86_64-unknown-linux-musl.tar.gz">prek-x86_64-unknown-linux-musl.tar.gz</a></td>
<td>x64 MUSL Linux</td>
<td><a
href="https://github.com/j178/prek/releases/download/v0.5.2/prek-x86_64-unknown-linux-musl.tar.gz.sha256">checksum</a></td>
</tr>
</tbody>
</table>
<h2>Verifying GitHub Artifact Attestations</h2>
<p>The artifacts in this release have attestations generated with GitHub
Artifact Attestations. These can be verified by using the <a
href="https://cli.github.com/manual/gh_attestation_verify">GitHub
CLI</a>:</p>
<pre lang="sh"><code>&lt;/tr&gt;&lt;/table&gt; 
</code></pre>
</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.2</h2>
<p>Released on 2026-09-03.</p>
<h3>Enhancements</h3>
<ul>
<li>Allow unknown tags by default in <code>check-yaml</code> (<a
href="https://redirect.github.com/j178/prek/pull/2678">#2678</a>)</li>
</ul>
<h3>Contributors</h3>
<ul>
<li><a href="https://github.com/j178"><code>@​j178</code></a></li>
</ul>
<h2>0.5.1</h2>
<p>Released on 2026-09-01.</p>
<h3>Enhancements</h3>
<ul>
<li>Add <code>--hide-status &lt;passed|failed|skipped&gt;</code> for
hook reports (<a
href="https://redirect.github.com/j178/prek/pull/2644">#2644</a>)</li>
<li>Add <code>prek init</code> for repository setup (<a
href="https://redirect.github.com/j178/prek/pull/2636">#2636</a>)</li>
<li>Apply hook <code>env</code> during environment creation (<a
href="https://redirect.github.com/j178/prek/pull/2650">#2650</a>)</li>
<li>Disable error snippets in <code>check-yaml</code> diagnostics (<a
href="https://redirect.github.com/j178/prek/pull/2664">#2664</a>)</li>
<li>Show hooks excluded by skip selectors (<a
href="https://redirect.github.com/j178/prek/pull/2645">#2645</a>)</li>
<li>Support Pixi for Conda environments (<a
href="https://redirect.github.com/j178/prek/pull/2667">#2667</a>)</li>
<li>Support <code>cargo-binstall</code> for Rust CLI dependencies (<a
href="https://redirect.github.com/j178/prek/pull/2658">#2658</a>)</li>
<li>Warn about unused keys in user settings (<a
href="https://redirect.github.com/j178/prek/pull/2665">#2665</a>)</li>
</ul>
<h3>Bug fixes</h3>
<ul>
<li>Reject unsupported YAML tags in check-yaml (<a
href="https://redirect.github.com/j178/prek/pull/2656">#2656</a>)</li>
</ul>
<h3>Documentation</h3>
<ul>
<li>Clarify local hook documentation (<a
href="https://redirect.github.com/j178/prek/pull/2640">#2640</a>)</li>
<li>Clarify pre-commit command compatibility (<a
href="https://redirect.github.com/j178/prek/pull/2635">#2635</a>)</li>
<li>Document automatic PR fixes with autofix.ci (<a
href="https://redirect.github.com/j178/prek/pull/2643">#2643</a>)</li>
<li>Document check-yaml unsafe support (<a
href="https://redirect.github.com/j178/prek/pull/2632">#2632</a>)</li>
<li>Improve setup and workflow documentation (<a
href="https://redirect.github.com/j178/prek/pull/2637">#2637</a>)</li>
</ul>
<h3>Other changes</h3>
<ul>
<li>Drop low-usage release targets (<a
href="https://redirect.github.com/j178/prek/pull/2651">#2651</a>)</li>
<li>Generate a prek manifest JSON schema (<a
href="https://redirect.github.com/j178/prek/pull/2648">#2648</a>)</li>
</ul>
<h3>Contributors</h3>
<ul>
<li><a href="https://github.com/j178"><code>@​j178</code></a></li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/j178/prek/commit/bfaa03cc998a43b1092e7ea918d0c9437a3ade64"><code>bfaa03c</code></a>
Bump version to 0.5.2 (<a
href="https://redirect.github.com/j178/prek/issues/2679">#2679</a>)</li>
<li><a
href="https://github.com/j178/prek/commit/f0ce4c249ef17f9be0162e3c42ed765d073619ff"><code>f0ce4c2</code></a>
Allow unknown tags by default in <code>check-yaml</code> (<a
href="https://redirect.github.com/j178/prek/issues/2678">#2678</a>)</li>
<li><a
href="https://github.com/j178/prek/commit/78bb3fe7d901289b3f45c5a5280147860d87cf30"><code>78bb3fe</code></a>
Show verbose builtin details by default (<a
href="https://redirect.github.com/j178/prek/issues/2676">#2676</a>)</li>
<li><a
href="https://github.com/j178/prek/commit/57ea73d2ac28ae8d13fdf25c98c9b4b092a64945"><code>57ea73d</code></a>
Add <code>--allow-unknown-tags</code> to <code>check-yaml</code> to
disable tag checks (<a
href="https://redirect.github.com/j178/prek/issues/2675">#2675</a>)</li>
<li><a
href="https://github.com/j178/prek/commit/695f3d0d7f113ccbbcad6555103107d6866e6b28"><code>695f3d0</code></a>
Show hook selectors first in prek run help (<a
href="https://redirect.github.com/j178/prek/issues/2673">#2673</a>)</li>
<li><a
href="https://github.com/j178/prek/commit/edf43d5014da3566f5ea2f4bdee4f578f9381386"><code>edf43d5</code></a>
Reapply &quot;Reject unsupported YAML tags in check-yaml (<a
href="https://redirect.github.com/j178/prek/issues/2656">#2656</a>)&quot;
(<a
href="https://redirect.github.com/j178/prek/issues/2672">#2672</a>)</li>
<li><a
href="https://github.com/j178/prek/commit/ed225b182dba642745ee1f79e185b6afa1928420"><code>ed225b1</code></a>
Bump version to 0.5.2 (<a
href="https://redirect.github.com/j178/prek/issues/2671">#2671</a>)</li>
<li><a
href="https://github.com/j178/prek/commit/de6e61481c610de0836b2628407bfaa353aa3fcd"><code>de6e614</code></a>
Revert unsupported YAML tag rejection in check-yaml (<a
href="https://redirect.github.com/j178/prek/issues/2670">#2670</a>)</li>
<li><a
href="https://github.com/j178/prek/commit/10a896eb63d60dd1175cc7b10ff0798e7070e56f"><code>10a896e</code></a>
Bump version to 0.5.1 (<a
href="https://redirect.github.com/j178/prek/issues/2669">#2669</a>)</li>
<li><a
href="https://github.com/j178/prek/commit/864870940e0d246aa2ce9c3a9a693092e3f8002f"><code>8648709</code></a>
Support Pixi for Conda environments (<a
href="https://redirect.github.com/j178/prek/issues/2667">#2667</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/j178/prek/compare/v0.5.0...v0.5.2">compare
view</a></li>
</ul>
</details>
<br />

Updates `ruff` from 0.16.5 to 0.16.6
<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.6</h2>
<h2>Release Notes</h2>
<p>Released on 2026-09-03.</p>
<h3>Preview features</h3>
<ul>
<li>Move <code>pytest-fixture-autouse</code> to the
<code>restriction</code> category (<a
href="https://redirect.github.com/astral-sh/ruff/pull/28219">#28219</a>)</li>
<li>[<code>flake8-pytest-style</code>] Add an autofix for
<code>PT020</code> (<a
href="https://redirect.github.com/astral-sh/ruff/pull/27993">#27993</a>)</li>
<li>[<code>flake8-tidy-imports</code>] Prevent fix loop between
<code>TID254</code> and <code>TID255</code> (<a
href="https://redirect.github.com/astral-sh/ruff/pull/28262">#28262</a>)</li>
<li>[<code>isort</code>] Exclude pragma comments from line length
calculation (<code>I001</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/27313">#27313</a>)</li>
</ul>
<h3>Bug fixes</h3>
<ul>
<li>Validate unary expressions when parsing (<a
href="https://redirect.github.com/astral-sh/ruff/pull/28233">#28233</a>)</li>
<li>[<code>flake8-async</code>, <code>pylint</code>] Recognize
<code>builtins.open</code> (<code>ASYNC230</code>, <code>PLW1514</code>)
(<a
href="https://redirect.github.com/astral-sh/ruff/pull/28021">#28021</a>)</li>
<li>[<code>flake8-bugbear</code>] Fix panic on <code>match</code>
subjects (<code>B031</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/27781">#27781</a>)</li>
<li>[<code>flake8-datetimez</code>] Reject <code>tzinfo=None</code> for
<code>datetime</code> bounds (<code>DTZ901</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/28022">#28022</a>)</li>
<li>[<code>flake8-pytest-style</code>] Avoid duplicate
<code>PT017</code> diagnostics (<a
href="https://redirect.github.com/astral-sh/ruff/pull/27918">#27918</a>)</li>
<li>[<code>ruff</code>] Remove <code>lint.external</code> hint for
Ruff-specific suppressions (<code>RUF102</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/27923">#27923</a>)</li>
</ul>
<h3>Rule changes</h3>
<ul>
<li>[<code>flake8-use-pathlib</code>] Add display-only fix for
<code>os.listdir</code> (<code>PTH208</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/28027">#28027</a>)</li>
</ul>
<h3>Documentation</h3>
<ul>
<li>Add another example and glob reference for
<code>lint.per-file-ignores</code> (<a
href="https://redirect.github.com/astral-sh/ruff/pull/28106">#28106</a>)</li>
<li>Add duplicate work guidance (<a
href="https://redirect.github.com/astral-sh/ruff/pull/28229">#28229</a>)</li>
<li>[<code>flake8-async</code>] Document thread offloading
(<code>ASYNC240</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/28008">#28008</a>)</li>
<li>[<code>pyupgrade</code>] Clarify default <code>encoding</code>
argument handling (<code>UP012</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/27315">#27315</a>)</li>
</ul>
<h3>Other changes</h3>
<ul>
<li>Allow unary plus in match patterns on Python 3.15 (<a
href="https://redirect.github.com/astral-sh/ruff/pull/28231">#28231</a>)</li>
</ul>
<h3>Contributors</h3>
<ul>
<li><a
href="https://github.com/flying-sheep"><code>@​flying-sheep</code></a></li>
<li><a
href="https://github.com/pikammmmm"><code>@​pikammmmm</code></a></li>
<li><a href="https://github.com/fly1d"><code>@​fly1d</code></a></li>
<li><a
href="https://github.com/waterWang"><code>@​waterWang</code></a></li>
<li><a
href="https://github.com/Pierre-Sassoulas"><code>@​Pierre-Sassoulas</code></a></li>
<li><a href="https://github.com/ntBre"><code>@​ntBre</code></a></li>
<li><a
href="https://github.com/jelle-openai"><code>@​jelle-openai</code></a></li>
<li><a
href="https://github.com/ericbuehl"><code>@​ericbuehl</code></a></li>
<li><a href="https://github.com/tjkuson"><code>@​tjkuson</code></a></li>
<li><a
href="https://github.com/AbhinavMir"><code>@​AbhinavMir</code></a></li>
<li><a
href="https://github.com/Whning0513"><code>@​Whning0513</code></a></li>
<li><a
href="https://github.com/chirizxc"><code>@​chirizxc</code></a></li>
</ul>
<!-- 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.6</h2>
<p>Released on 2026-09-03.</p>
<h3>Preview features</h3>
<ul>
<li>Move <code>pytest-fixture-autouse</code> to the
<code>restriction</code> category (<a
href="https://redirect.github.com/astral-sh/ruff/pull/28219">#28219</a>)</li>
<li>[<code>flake8-pytest-style</code>] Add an autofix for
<code>PT020</code> (<a
href="https://redirect.github.com/astral-sh/ruff/pull/27993">#27993</a>)</li>
<li>[<code>flake8-tidy-imports</code>] Prevent fix loop between
<code>TID254</code> and <code>TID255</code> (<a
href="https://redirect.github.com/astral-sh/ruff/pull/28262">#28262</a>)</li>
<li>[<code>isort</code>] Exclude pragma comments from line length
calculation (<code>I001</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/27313">#27313</a>)</li>
</ul>
<h3>Bug fixes</h3>
<ul>
<li>Validate unary expressions when parsing (<a
href="https://redirect.github.com/astral-sh/ruff/pull/28233">#28233</a>)</li>
<li>[<code>flake8-async</code>, <code>pylint</code>] Recognize
<code>builtins.open</code> (<code>ASYNC230</code>, <code>PLW1514</code>)
(<a
href="https://redirect.github.com/astral-sh/ruff/pull/28021">#28021</a>)</li>
<li>[<code>flake8-bugbear</code>] Fix panic on <code>match</code>
subjects (<code>B031</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/27781">#27781</a>)</li>
<li>[<code>flake8-datetimez</code>] Reject <code>tzinfo=None</code> for
<code>datetime</code> bounds (<code>DTZ901</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/28022">#28022</a>)</li>
<li>[<code>flake8-pytest-style</code>] Avoid duplicate
<code>PT017</code> diagnostics (<a
href="https://redirect.github.com/astral-sh/ruff/pull/27918">#27918</a>)</li>
<li>[<code>ruff</code>] Remove <code>lint.external</code> hint for
Ruff-specific suppressions (<code>RUF102</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/27923">#27923</a>)</li>
</ul>
<h3>Rule changes</h3>
<ul>
<li>[<code>flake8-use-pathlib</code>] Add display-only fix for
<code>os.listdir</code> (<code>PTH208</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/28027">#28027</a>)</li>
</ul>
<h3>Documentation</h3>
<ul>
<li>Add another example and glob reference for
<code>lint.per-file-ignores</code> (<a
href="https://redirect.github.com/astral-sh/ruff/pull/28106">#28106</a>)</li>
<li>Add duplicate work guidance (<a
href="https://redirect.github.com/astral-sh/ruff/pull/28229">#28229</a>)</li>
<li>[<code>flake8-async</code>] Document thread offloading
(<code>ASYNC240</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/28008">#28008</a>)</li>
<li>[<code>pyupgrade</code>] Clarify default <code>encoding</code>
argument handling (<code>UP012</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/27315">#27315</a>)</li>
</ul>
<h3>Other changes</h3>
<ul>
<li>Allow unary plus in match patterns on Python 3.15 (<a
href="https://redirect.github.com/astral-sh/ruff/pull/28231">#28231</a>)</li>
</ul>
<h3>Contributors</h3>
<ul>
<li><a
href="https://github.com/flying-sheep"><code>@​flying-sheep</code></a></li>
<li><a
href="https://github.com/pikammmmm"><code>@​pikammmmm</code></a></li>
<li><a href="https://github.com/fly1d"><code>@​fly1d</code></a></li>
<li><a
href="https://github.com/waterWang"><code>@​waterWang</code></a></li>
<li><a
href="https://github.com/Pierre-Sassoulas"><code>@​Pierre-Sassoulas</code></a></li>
<li><a href="https://github.com/ntBre"><code>@​ntBre</code></a></li>
<li><a
href="https://github.com/jelle-openai"><code>@​jelle-openai</code></a></li>
<li><a
href="https://github.com/ericbuehl"><code>@​ericbuehl</code></a></li>
<li><a href="https://github.com/tjkuson"><code>@​tjkuson</code></a></li>
<li><a
href="https://github.com/AbhinavMir"><code>@​AbhinavMir</code></a></li>
<li><a
href="https://github.com/Whning0513"><code>@​Whning0513</code></a></li>
<li><a
href="https://github.com/chirizxc"><code>@​chirizxc</code></a></li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/astral-sh/ruff/commit/22f65a2ab5052990503985c7c794de37598d531e"><code>22f65a2</code></a>
Bump 0.16.6 (<a
href="https://redirect.github.com/astral-sh/ruff/issues/28280">#28280</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/7cc9f1ed53e5ce175f17fde3a75fe792ac41e8cd"><code>7cc9f1e</code></a>
[ty] Document <code>CallableTypeKind</code> variants (<a
href="https://redirect.github.com/astral-sh/ruff/issues/28277">#28277</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/056ce07a895c217dab7929a616540c246cf8a078"><code>056ce07</code></a>
Add UV_LOCKED to all workflows (<a
href="https://redirect.github.com/astral-sh/ruff/issues/28261">#28261</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/6d6e35d03a72d23d3b0f23fb348692b03c9b81b6"><code>6d6e35d</code></a>
[<code>flake8-pytest-style</code>] Avoid duplicate <code>PT017</code>
diagnostics (<a
href="https://redirect.github.com/astral-sh/ruff/issues/27918">#27918</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/80b4891b64cb916dce080e8fa15af5c2179d776a"><code>80b4891</code></a>
[ty] Temporarily disable tuple types in property tests (<a
href="https://redirect.github.com/astral-sh/ruff/issues/28275">#28275</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/3f7a54d1d7b9480a18d86a8211bf676354a2f8a0"><code>3f7a54d</code></a>
[<code>flake8-tidy-imports</code>] Prevent fix loop between
<code>TID254</code> and <code>TID255</code> (<a
href="https://redirect.github.com/astral-sh/ruff/issues/28262">#28262</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/35656cf37d34f30116d4f4a9cb13853a6ad9fd0d"><code>35656cf</code></a>
[ty] Preserve deprecations on decorated callables (<a
href="https://redirect.github.com/astral-sh/ruff/issues/28256">#28256</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/d2626c866ee08495cfa0da5236e130030d37e0cf"><code>d2626c8</code></a>
[ty] Reject missing attributes on <code>type[]</code> aliases (<a
href="https://redirect.github.com/astral-sh/ruff/issues/28267">#28267</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/ee6a6d19aa0707df942febe9768488d39f383c8c"><code>ee6a6d1</code></a>
[ty] Support ParamSpecs in <code>ConstraintSet</code> (<a
href="https://redirect.github.com/astral-sh/ruff/issues/28028">#28028</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/7f6a170080dab08f754ef086369a2623c70f753d"><code>7f6a170</code></a>
[ty] Move bound APIs to constraint owners (<a
href="https://redirect.github.com/astral-sh/ruff/issues/28094">#28094</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/astral-sh/ruff/compare/0.16.5...0.16.6">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-11 00:20:11 -04:00
Nicholas Velten 46d21cafbc Deep-link Search By mode via URL hash (#1311)
Closes #1228.

Search By mode (General/Author/Title/Series/Manual), content type and
the search query now live in the URL as a hash fragment, updated live as
you search, not just parsed once on load. A shared/bookmarked link like
`#search_by=manual&q=foundation` reopens in that exact mode with the
query filled in.

Following the direction from the issue thread:
- Hash fragment instead of query string, so it stays browser side only.
- Live updates via `history.replaceState`, no history spam per
keystroke.
- Default Search By persisted in a client side cookie as fallback when
there's no hash, no user accounts needed.

Tested manually against a local build (search-by switching, hash live
update, deep link reload, cookie fallback when there's no hash, and hash
overriding a stale cookie).
2026-09-05 01:13:59 -04:00
dependabot[bot] 22aa59e567 build(deps): bump the docker-base-image-digests group with 2 updates (#1306)
> [!WARNING]
> Cooldown could not be applied because no publication date was
available from the registry.
>

Bumps the docker-base-image-digests group with 2 updates: node and
[astral-sh/uv](https://github.com/astral-sh/uv).

Updates `node` from `d32cdf6` to `e67514e`

Updates `astral-sh/uv` from 0.12.5 to 0.12.9
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/astral-sh/uv/releases">astral-sh/uv's
releases</a>.</em></p>
<blockquote>
<h2>0.12.9</h2>
<h2>Release Notes</h2>
<p>Released on 2026-09-01.</p>
<h3>Python</h3>
<ul>
<li>Add CPython 3.15.0rc2 (<a
href="https://redirect.github.com/astral-sh/uv/pull/21413">#21413</a>,
<a
href="https://redirect.github.com/astral-sh/uv/pull/21415">#21415</a>)</li>
</ul>
<h3>Enhancements</h3>
<ul>
<li>Add <code>--no-locked</code> and <code>--no-frozen</code> to disable
lock modes enabled by <code>UV_LOCKED</code> and <code>UV_FROZEN</code>
for a single invocation (<a
href="https://redirect.github.com/astral-sh/uv/pull/21408">#21408</a>)</li>
<li>Report the exact command-line lock-mode flag in warnings and errors
(<a
href="https://redirect.github.com/astral-sh/uv/pull/21402">#21402</a>)</li>
</ul>
<h3>Performance</h3>
<ul>
<li>Speed up cold wheel installs by extracting each streaming ZIP
archive in a single blocking task and reusing buffers across files (<a
href="https://redirect.github.com/astral-sh/uv/pull/21372">#21372</a>)</li>
</ul>
<h3>Bug fixes</h3>
<ul>
<li>Update <code>async_http_range_reader</code> to 0.11.1 to address a
potential memory-safety issue when reading metadata ranges from
untrusted wheels (<a
href="https://redirect.github.com/astral-sh/uv/pull/21401">#21401</a>)</li>
<li>Remove sensitive headers when redirects cross authentication realms,
including same-host redirects that change URL schemes (<a
href="https://redirect.github.com/astral-sh/uv/pull/21382">#21382</a>)</li>
<li>Redact secrets in signed URLs from retry diagnostics, including
nested request errors (<a
href="https://redirect.github.com/astral-sh/uv/pull/21381">#21381</a>)</li>
<li>Give <code>--locked</code>, <code>--frozen</code>,
<code>--check</code>, and <code>--check-exists</code> precedence over
conflicting <code>UV_LOCKED</code> and <code>UV_FROZEN</code> values (<a
href="https://redirect.github.com/astral-sh/uv/pull/21396">#21396</a>)</li>
<li>Prevent concurrent uv processes from redundantly extracting the same
local or source-built wheel (<a
href="https://redirect.github.com/astral-sh/uv/pull/21400">#21400</a>)</li>
</ul>
<h2>Install uv 0.12.9</h2>
<h3>Install prebuilt binaries via shell script</h3>
<pre lang="sh"><code>curl --proto '=https' --tlsv1.2 -LsSf
https://releases.astral.sh/github/uv/releases/download/0.12.9/uv-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/uv/releases/download/0.12.9/uv-installer.ps1
| iex&quot;
</code></pre>
<h2>Download uv 0.12.9</h2>
<table>
<thead>
<tr>
<th>File</th>
<th>Platform</th>
<th>Checksum</th>
</tr>
</thead>
<tbody>
<tr>
<td><a
href="https://releases.astral.sh/github/uv/releases/download/0.12.9/uv-aarch64-apple-darwin.tar.gz">uv-aarch64-apple-darwin.tar.gz</a></td>
<td>Apple Silicon macOS</td>
<td><a
href="https://releases.astral.sh/github/uv/releases/download/0.12.9/uv-aarch64-apple-darwin.tar.gz.sha256">checksum</a></td>
</tr>
<tr>
<td><a
href="https://releases.astral.sh/github/uv/releases/download/0.12.9/uv-x86_64-apple-darwin.tar.gz">uv-x86_64-apple-darwin.tar.gz</a></td>
<td>Intel macOS</td>
<td><a
href="https://releases.astral.sh/github/uv/releases/download/0.12.9/uv-x86_64-apple-darwin.tar.gz.sha256">checksum</a></td>
</tr>
<tr>
<td><a
href="https://releases.astral.sh/github/uv/releases/download/0.12.9/uv-aarch64-pc-windows-msvc.zip">uv-aarch64-pc-windows-msvc.zip</a></td>
<td>ARM64 Windows</td>
<td><a
href="https://releases.astral.sh/github/uv/releases/download/0.12.9/uv-aarch64-pc-windows-msvc.zip.sha256">checksum</a></td>
</tr>
<tr>
<td><a
href="https://releases.astral.sh/github/uv/releases/download/0.12.9/uv-i686-pc-windows-msvc.zip">uv-i686-pc-windows-msvc.zip</a></td>
<td>x86 Windows</td>
<td><a
href="https://releases.astral.sh/github/uv/releases/download/0.12.9/uv-i686-pc-windows-msvc.zip.sha256">checksum</a></td>
</tr>
<tr>
<td><a
href="https://releases.astral.sh/github/uv/releases/download/0.12.9/uv-x86_64-pc-windows-msvc.zip">uv-x86_64-pc-windows-msvc.zip</a></td>
<td>x64 Windows</td>
<td><a
href="https://releases.astral.sh/github/uv/releases/download/0.12.9/uv-x86_64-pc-windows-msvc.zip.sha256">checksum</a></td>
</tr>
<tr>
<td><a
href="https://releases.astral.sh/github/uv/releases/download/0.12.9/uv-aarch64-unknown-linux-gnu.tar.gz">uv-aarch64-unknown-linux-gnu.tar.gz</a></td>
<td>ARM64 Linux</td>
<td><a
href="https://releases.astral.sh/github/uv/releases/download/0.12.9/uv-aarch64-unknown-linux-gnu.tar.gz.sha256">checksum</a></td>
</tr>
</tbody>
</table>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/astral-sh/uv/blob/main/CHANGELOG.md">astral-sh/uv's
changelog</a>.</em></p>
<blockquote>
<h2>0.12.9</h2>
<p>Released on 2026-09-01.</p>
<h3>Python</h3>
<ul>
<li>Add CPython 3.15.0rc2 (<a
href="https://redirect.github.com/astral-sh/uv/pull/21413">#21413</a>,
<a
href="https://redirect.github.com/astral-sh/uv/pull/21415">#21415</a>)</li>
</ul>
<h3>Enhancements</h3>
<ul>
<li>Add <code>--no-locked</code> and <code>--no-frozen</code> to disable
lock modes enabled by <code>UV_LOCKED</code> and <code>UV_FROZEN</code>
for a single invocation (<a
href="https://redirect.github.com/astral-sh/uv/pull/21408">#21408</a>)</li>
<li>Report the exact command-line lock-mode flag in warnings and errors
(<a
href="https://redirect.github.com/astral-sh/uv/pull/21402">#21402</a>)</li>
</ul>
<h3>Performance</h3>
<ul>
<li>Speed up cold wheel installs by extracting each streaming ZIP
archive in a single blocking task and reusing buffers across files (<a
href="https://redirect.github.com/astral-sh/uv/pull/21372">#21372</a>)</li>
</ul>
<h3>Bug fixes</h3>
<ul>
<li>Update <code>async_http_range_reader</code> to 0.11.1 to address a
potential memory-safety issue when reading metadata ranges from
untrusted wheels (<a
href="https://redirect.github.com/astral-sh/uv/pull/21401">#21401</a>)</li>
<li>Remove sensitive headers when redirects cross authentication realms,
including same-host redirects that change URL schemes (<a
href="https://redirect.github.com/astral-sh/uv/pull/21382">#21382</a>)</li>
<li>Redact secrets in signed URLs from retry diagnostics, including
nested request errors (<a
href="https://redirect.github.com/astral-sh/uv/pull/21381">#21381</a>)</li>
<li>Give <code>--locked</code>, <code>--frozen</code>,
<code>--check</code>, and <code>--check-exists</code> precedence over
conflicting <code>UV_LOCKED</code> and <code>UV_FROZEN</code> values (<a
href="https://redirect.github.com/astral-sh/uv/pull/21396">#21396</a>)</li>
<li>Prevent concurrent uv processes from redundantly extracting the same
local or source-built wheel (<a
href="https://redirect.github.com/astral-sh/uv/pull/21400">#21400</a>)</li>
</ul>
<h2>0.12.8</h2>
<p>Released on 2026-08-31.</p>
<h3>Enhancements</h3>
<ul>
<li>Warn about invalid tool directories and continue upgrading valid
tools with <code>uv tool upgrade --all</code> (<a
href="https://redirect.github.com/astral-sh/uv/pull/21368">#21368</a>)</li>
</ul>
<h3>Preview features</h3>
<ul>
<li>Deduplicate identical files within and across cached wheels with the
<code>content-addressed-cache</code> preview feature (<a
href="https://redirect.github.com/astral-sh/uv/pull/21327">#21327</a>)</li>
<li>Reduce allocations while extracting content-addressed wheels by
reusing the hashing buffer across files (<a
href="https://redirect.github.com/astral-sh/uv/pull/21340">#21340</a>)</li>
<li>Speed up content-addressed cache cleanup on macOS by reading
hard-link counts in bulk (<a
href="https://redirect.github.com/astral-sh/uv/pull/21344">#21344</a>)</li>
</ul>
<h3>Performance</h3>
<ul>
<li>Prevent concurrent uv processes from downloading and extracting the
same remote wheel more than once (<a
href="https://redirect.github.com/astral-sh/uv/pull/21379">#21379</a>)</li>
<li>Speed up dependency graph construction from large lockfiles by
indexing packages during traversal (<a
href="https://redirect.github.com/astral-sh/uv/pull/21373">#21373</a>)</li>
<li>Extend indexed lockfile traversal to exports, dependency trees,
audits, and freshness checks (<a
href="https://redirect.github.com/astral-sh/uv/pull/21377">#21377</a>)</li>
<li>Speed up warm resolutions by reducing repeated marker interner work
(<a
href="https://redirect.github.com/astral-sh/uv/pull/21300">#21300</a>)</li>
</ul>
<h3>Bug fixes</h3>
<ul>
<li>Do not trust hashes from direct URLs discovered only in wheel
metadata when installing with <code>--require-hashes</code> (<a
href="https://redirect.github.com/astral-sh/uv/pull/21348">#21348</a>)</li>
<li>Use a compatible Azure Storage API version for anonymous and
authenticated requests, allowing credential retries when public access
is disabled (<a
href="https://redirect.github.com/astral-sh/uv/pull/21366">#21366</a>)</li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/astral-sh/uv/commit/9f928602938ac5cf1cd6b294a725833c16f5720e"><code>9f92860</code></a>
Bump version to 0.12.9 (<a
href="https://redirect.github.com/astral-sh/uv/issues/21422">#21422</a>)</li>
<li><a
href="https://github.com/astral-sh/uv/commit/29c546228ed6ba8970bb063960c9a767fae19236"><code>29c5462</code></a>
Skip invalid scripts in <code>uv workspace list --scripts</code> (<a
href="https://redirect.github.com/astral-sh/uv/issues/21416">#21416</a>)</li>
<li><a
href="https://github.com/astral-sh/uv/commit/bf1d7e8049017a363565185a7df7e86f33041452"><code>bf1d7e8</code></a>
Encapsulate wheel extraction directory ownership (<a
href="https://redirect.github.com/astral-sh/uv/issues/21419">#21419</a>)</li>
<li><a
href="https://github.com/astral-sh/uv/commit/6dacee042f58cea9184267ffb70c6960582bb210"><code>6dacee0</code></a>
Run streaming ZIP extraction in a blocking task (<a
href="https://redirect.github.com/astral-sh/uv/issues/21372">#21372</a>)</li>
<li><a
href="https://github.com/astral-sh/uv/commit/91d1ebf1b4ea1fc00928b412a9da23639aacb086"><code>91d1ebf</code></a>
Sync latest Python releases (<a
href="https://redirect.github.com/astral-sh/uv/issues/21415">#21415</a>)</li>
<li><a
href="https://github.com/astral-sh/uv/commit/6a61ddb2c094ab44e55a2cc5bb4a88b7c08d733a"><code>6a61ddb</code></a>
Revert &quot;Limit CI planner checkout depth&quot; (<a
href="https://redirect.github.com/astral-sh/uv/issues/21414">#21414</a>)</li>
<li><a
href="https://github.com/astral-sh/uv/commit/e9d1816b538468e7deb8f267f28775007706b866"><code>e9d1816</code></a>
Add CPython 3.15.0rc2 (<a
href="https://redirect.github.com/astral-sh/uv/issues/21413">#21413</a>)</li>
<li><a
href="https://github.com/astral-sh/uv/commit/e888329afc22fa3e34f93d5ae5e23fbc87328952"><code>e888329</code></a>
Remove unreachable lock configuration sources (<a
href="https://redirect.github.com/astral-sh/uv/issues/21409">#21409</a>)</li>
<li><a
href="https://github.com/astral-sh/uv/commit/bf041f9245952ba7c9e3c29aebe0a8acc2bc983d"><code>bf041f9</code></a>
Allow negating locked and frozen modes (<a
href="https://redirect.github.com/astral-sh/uv/issues/21408">#21408</a>)</li>
<li><a
href="https://github.com/astral-sh/uv/commit/feffc0b5bbf8aeca1dff4a6f042a58e44a5daf09"><code>feffc0b</code></a>
Bump async_http_range_reader to 0.11.1 (<a
href="https://redirect.github.com/astral-sh/uv/issues/21401">#21401</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/astral-sh/uv/compare/0.12.5...0.12.9">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-04 11:03:26 -04:00
dependabot[bot] b317dd1110 build(deps): bump python from cae66f2 to cad9a2c (#1307)
> [!WARNING]
> Cooldown could not be applied because no publication date was
available from the registry.
>

Bumps python from `cae66f2` to `cad9a2c`.


[![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-09-04 11:03:15 -04:00
dependabot[bot] 223d8a2256 build(deps): bump the npm-deps group in /src/frontend with 4 updates (#1308)
Bumps the npm-deps group in /src/frontend with 4 updates:
[react-router-dom](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router-dom),
[@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node),
[@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react)
and [knip](https://github.com/webpro-nl/knip/tree/HEAD/packages/knip).

Updates `react-router-dom` from 7.18.2 to 7.18.3
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/remix-run/react-router/blob/react-router-dom@7.18.3/packages/react-router-dom/CHANGELOG.md">react-router-dom's
changelog</a>.</em></p>
<blockquote>
<h2>v7.18.3</h2>
<h3>Patch Changes</h3>
<ul>
<li>Updated dependencies:
<ul>
<li><a
href="https://github.com/remix-run/react-router/releases/tag/react-router@7.18.3"><code>react-router@7.18.3</code></a></li>
</ul>
</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/remix-run/react-router/commit/23166dfe7f61323f0d2775af67d2691f9ed0843d"><code>23166df</code></a>
Release v7.18.3 (<a
href="https://github.com/remix-run/react-router/tree/HEAD/packages/react-router-dom/issues/15424">#15424</a>)</li>
<li>See full diff in <a
href="https://github.com/remix-run/react-router/commits/react-router-dom@7.18.3/packages/react-router-dom">compare
view</a></li>
</ul>
</details>
<br />

Updates `@types/node` from 26.3.0 to 26.4.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 `@vitejs/plugin-react` from 6.1.0 to 6.1.1
<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.1</h2>
<h3>Add <code>compiler.logDiagnostics</code> option</h3>
<p>Recoverable React Compiler diagnostics are no longer logged by
default. Set <code>compiler.logDiagnostics</code> to <code>true</code>
to log them through Vite. Fatal diagnostics are always logged and fail
the transform.</p>
<h3>Respect environment sourcemap option for React Compiler transform
when <code>builder.sharedPlugins</code> is enabled (<a
href="https://redirect.github.com/vitejs/vite-plugin-react/pull/1439">#1439</a>)</h3>
<p>The React Compiler transform was using the top-level sourcemap option
instead of the environment sourcemap option. This caused a problem when
the experimental <code>builder.sharedPlugins</code> was enabled.</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.1 (2026-08-28)</h2>
<h3>Add <code>compiler.logDiagnostics</code> option</h3>
<p>Recoverable React Compiler diagnostics are no longer logged by
default. Set <code>compiler.logDiagnostics</code> to <code>true</code>
to log them through Vite. Fatal diagnostics are always logged and fail
the transform.</p>
<h3>Respect environment sourcemap option for React Compiler transform
when <code>builder.sharedPlugins</code> is enabled (<a
href="https://redirect.github.com/vitejs/vite-plugin-react/pull/1439">#1439</a>)</h3>
<p>The React Compiler transform was using the top-level sourcemap option
instead of the environment sourcemap option. This caused a problem when
the experimental <code>builder.sharedPlugins</code> was enabled.</p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/vitejs/vite-plugin-react/commit/04cac5020e349f452d76c5a4f6d788ad4b38930a"><code>04cac50</code></a>
release: plugin-react@6.1.1 (<a
href="https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react/issues/1440">#1440</a>)</li>
<li><a
href="https://github.com/vitejs/vite-plugin-react/commit/82d35abe4946eddd4e6456802bf2b53444e264f2"><code>82d35ab</code></a>
fix(react): respect environment sourcemap option when
<code>builder.sharedPlugins</code>...</li>
<li><a
href="https://github.com/vitejs/vite-plugin-react/commit/397e8471a559f18a16dd21bd797ac01a369dabdc"><code>397e847</code></a>
fix(react): make logging diagnostics an opt-in for React Compiler (<a
href="https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react/issues/1431">#1431</a>)</li>
<li><a
href="https://github.com/vitejs/vite-plugin-react/commit/61006e6f52124821c24121a78712f7162ae36f5b"><code>61006e6</code></a>
fix(deps): update all non-major dependencies (<a
href="https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react/issues/1433">#1433</a>)</li>
<li><a
href="https://github.com/vitejs/vite-plugin-react/commit/e2a649cbaa7334d6991f843563683975667e1be1"><code>e2a649c</code></a>
chore: use <code>deps.neverBundle</code> instead of
<code>external</code> in tsdown config (<a
href="https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react/issues/1430">#1430</a>)</li>
<li><a
href="https://github.com/vitejs/vite-plugin-react/commit/fb2d6f3635acbb0f3acbd0e9a914f6c620460957"><code>fb2d6f3</code></a>
fix(deps): update all non-major dependencies (<a
href="https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react/issues/1427">#1427</a>)</li>
<li>See full diff in <a
href="https://github.com/vitejs/vite-plugin-react/commits/plugin-react@6.1.1/packages/plugin-react">compare
view</a></li>
</ul>
</details>
<br />

Updates `knip` from 6.32.2 to 6.34.0
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/webpro-nl/knip/releases">knip's
releases</a>.</em></p>
<blockquote>
<h2>Release 6.34.0</h2>
<ul>
<li>fix: report types used only in private parameters (resolve <a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1950">#1950</a>)
(3c785f55ce608d7921e37e41dd5fc61b623e5da2)</li>
<li>💡 Add support for <code>node:fs/promises</code> <code>glob</code>
(<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1977">#1977</a>)
(44faf08e7f56ce695d1541a6c337b76bea0328d1) - thanks <a
href="https://github.com/DreamLongYT"><code>@​DreamLongYT</code></a>!</li>
<li>Respect JSDoc tags on URL dependencies (resolve <a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1943">#1943</a>)
(f93ad0997e1b8fc644f6487df52803cba6f7a9ad)</li>
<li>Make prop access consistent while at it
(1c90b49b1b196795df6dcf9d7e85bc1b1399667a)</li>
<li>Set an explicit Bun test timeout
(964085dfab0165db2658e51cb787e2301886f9ce)</li>
<li>Add note re. one scoped PRs
(8f9490a581874e0fd7246e60223b20938bf4ebb1)</li>
<li>fix(cli): validate numeric options instead of passing NaN through
(<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1975">#1975</a>)
(ab90e2428a16ff50fe5323ce73172f6f1efb9730) - thanks <a
href="https://github.com/devYRPauli"><code>@​devYRPauli</code></a>!</li>
<li>fix: compile files with workspace-scoped compilers (<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1997">#1997</a>)
(462ae45df33a5ee2e2185f3b2db756b13dfbadd3) - thanks <a
href="https://github.com/gioboa"><code>@​gioboa</code></a>!</li>
<li>Resolve Nuxt <code>#shared</code> imports from the shared directory
(<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1991">#1991</a>)
(cc470611042b01761491c21f6987549db4c11df8) - thanks <a
href="https://github.com/DreamLongYT"><code>@​DreamLongYT</code></a>!</li>
<li>Update dependencies (c4e0f082fbd7fb508a411c7dd0a782ea4556527c)</li>
<li>Declare webpack directly instead of through
<code>@types/webpack</code>
(96ff2df58dd1d229d077212e1cd80f10889650ca)</li>
<li>Assert counters in targeted regression tests
(f0cf11dfcfbdeac053bba0dd12212356a67f6464)</li>
</ul>
<h2>Release 6.33.0</h2>
<ul>
<li>Support package manifest resolution under Yarn PnP (<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1946">#1946</a>)
(a82efb62b290e004bdabee40bcb337baf3701a1b) - thanks <a
href="https://github.com/WooWan"><code>@​WooWan</code></a>!</li>
<li>Resolve oxlint jsPlugins from .oxlintrc.jsonc (<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1973">#1973</a>)
(d055023182789555a574eaefde80dac997190437) - thanks <a
href="https://github.com/DreierF"><code>@​DreierF</code></a>!</li>
<li>Update dependency bun to v1.4.0 (<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1964">#1964</a>)
(937ab38ad514a00edafecff4ca04603b015bf921) - thanks <a
href="https://github.com/renovate"><code>@​renovate</code></a>[bot]!</li>
<li>Expose <code>defineConfig</code> on a <code>./config</code>
entrypoint (<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1960">#1960</a>)
(17f7a6051d9781860f248cc2a745dbe837713cd9) - thanks <a
href="https://github.com/theRizwan"><code>@​theRizwan</code></a>!</li>
<li>Preserve all environment variables in exec.ts (<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1979">#1979</a>)
(c0a9ecf8d1c0cdd7789c1e1bea893706c060239d) - thanks <a
href="https://github.com/DreamLongYT"><code>@​DreamLongYT</code></a>!</li>
<li>Add support for TypeScript content mappers (<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1978">#1978</a>)
(db67f9bd8cebf0ea3c298690d462c154827aa462) - thanks <a
href="https://github.com/remcohaszing"><code>@​remcohaszing</code></a>!</li>
<li>fix(vitest): resolve array entries and root-relative setup
dependencies (<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1982">#1982</a>)
(0ecf48538c339437e6f9dfe9a6013d307783cbd3) - thanks <a
href="https://github.com/DreamLongYT"><code>@​DreamLongYT</code></a>!</li>
<li>Fix type annotation in dynamic configuration function example
(ada88c7f01563b73643af34a147a71fd9ab9d2dc)</li>
<li>Format docs with remark
(7764c2a4ebecdbf226c19494d255d9cb14550822)</li>
<li>Throw on configuration that is not an object
(d7ac0cf1de8f79be6ec53412cac9f7edf8f35ba7)</li>
<li>Clarify dependency inputs, isTypeOnly and toConfig in plugin docs
(2bd367961554ad1addb520bdfad5e31125885969)</li>
<li>Resolve content mapper process from
package.json#typescript.contentMapper.exec
(74640c03e450624409692f4cc17699775108fd7e)</li>
<li>Encode argv arrays before parsing them as shell commands
(b79234c15153fbf592e82c94e4e4e9f41b4479e0)</li>
<li>Update dependencies (28510146c2b97d88f7188f1239b9307aac7dbf0c)</li>
<li>Replace non-breaking space in module graph comment
(4e22bfcd09deccc92ed158f5554fe3e03f3a2c51)</li>
<li>Update fix format test for oxfmt package.json key sorting
(65011bf6054d03fb5580f3381867b07d1b8a9a0c)</li>
<li>fix(webpack): resolve entry descriptors using import field (<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1981">#1981</a>)
(cad8910e7fb89c2510fc9f5885289335dd4e60da) - thanks <a
href="https://github.com/DreamLongYT"><code>@​DreamLongYT</code></a>!</li>
<li>fix: count enum element access as a whole-object read (<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1958">#1958</a>)
(5a47d603aa23b388ae7ce4fe301656f1abc43b14) - thanks <a
href="https://github.com/addielaruee"><code>@​addielaruee</code></a>!</li>
<li>Resolve oxlint/vite-plus import resolvers from <code>settings</code>
(<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1983">#1983</a>)
(c1165b70efe27398b86fa50bcea7d85c253d3ee7) - thanks <a
href="https://github.com/DreierF"><code>@​DreierF</code></a>!</li>
<li>fix(nuxt): resolve string extends configs (<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1984">#1984</a>)
(4a5d7068d6264ea45c78c832abe7ba052763517e) - thanks <a
href="https://github.com/DreamLongYT"><code>@​DreamLongYT</code></a>!</li>
<li>Wrap up webpack entry resolution
(505decdb821f9b76d194742cee7e98c75ef7d8e9)</li>
<li>Keep element access precise for number-like enum member names
(bcbac3a8b8283da3b32256584b3cc6569ae5c02f)</li>
<li>Resolve <code>Object.*</code> enum enumeration through import
aliases (347967daaed817ca20e1ad6148388a66ec8b421b)</li>
<li>Resolve named string-key access on namespace-qualified enums
(2ac1fbc1e92f3dae662ca4d4701cf010346be49a)</li>
<li>Normalize line endings in nuxt-config-string fixture
(9fe2b2b2db8690685ec7c6cf8b3d39b9ed7b3fdc)</li>
<li>Resolve nuxt layer sources from all <code>extends</code> entry
shapes (db136151d9f98801837b33646ef7b02ae7b5a86d)</li>
<li>Report unused exports behind Vitest module promise mocks (resolve <a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1966">#1966</a>)
(74954c219367ca1577184cca08c30dbb3fb6222e)</li>
<li>Check line endings against <code>.gitattributes</code> in <code>pnpm
run ci</code> (ddc9fd0cc3328260665203c8573702bb6c9d0dd6)</li>
</ul>
<h2>Release 6.32.3</h2>
<ul>
<li>Update sentry production snapshot
(9f18cba93c91de8554636b13692d6e7a937b7697)</li>
<li>Pin TypeScript ecosystem test
(de5162952d223050f3135afb02d4e8de6d46f381)</li>
<li>Update query snapshot
(f6964dd577d8f7967f9be6778f5c3953632e3c96)</li>
<li>fix(tsup): match config files with .cts and .mts extensions (<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1938">#1938</a>)
(68d6f52f7913cfa1e36618e6b03f90734da34149) - thanks <a
href="https://github.com/codeAnqiang-ma"><code>@​codeAnqiang-ma</code></a>!</li>
<li>fix(lefthook): match all config file names and extensions (<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1940">#1940</a>)
(4015ae0330c78e97360a2c8f697d17f82c5c24ae) - thanks <a
href="https://github.com/codeAnqiang-ma"><code>@​codeAnqiang-ma</code></a>!</li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/webpro-nl/knip/commit/4ffebb746c630147c5d94c14da0bafb60af3c027"><code>4ffebb7</code></a>
Release knip@6.34.0</li>
<li><a
href="https://github.com/webpro-nl/knip/commit/f0cf11dfcfbdeac053bba0dd12212356a67f6464"><code>f0cf11d</code></a>
Assert counters in targeted regression tests</li>
<li><a
href="https://github.com/webpro-nl/knip/commit/96ff2df58dd1d229d077212e1cd80f10889650ca"><code>96ff2df</code></a>
Declare webpack directly instead of through
<code>@types/webpack</code></li>
<li><a
href="https://github.com/webpro-nl/knip/commit/c4e0f082fbd7fb508a411c7dd0a782ea4556527c"><code>c4e0f08</code></a>
Update dependencies</li>
<li><a
href="https://github.com/webpro-nl/knip/commit/cc470611042b01761491c21f6987549db4c11df8"><code>cc47061</code></a>
Resolve Nuxt <code>#shared</code> imports from the shared directory (<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1991">#1991</a>)</li>
<li><a
href="https://github.com/webpro-nl/knip/commit/462ae45df33a5ee2e2185f3b2db756b13dfbadd3"><code>462ae45</code></a>
fix: compile files with workspace-scoped compilers (<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1997">#1997</a>)</li>
<li><a
href="https://github.com/webpro-nl/knip/commit/ab90e2428a16ff50fe5323ce73172f6f1efb9730"><code>ab90e24</code></a>
fix(cli): validate numeric options instead of passing NaN through (<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1975">#1975</a>)</li>
<li><a
href="https://github.com/webpro-nl/knip/commit/964085dfab0165db2658e51cb787e2301886f9ce"><code>964085d</code></a>
Set an explicit Bun test timeout</li>
<li><a
href="https://github.com/webpro-nl/knip/commit/1c90b49b1b196795df6dcf9d7e85bc1b1399667a"><code>1c90b49</code></a>
Make prop access consistent while at it</li>
<li><a
href="https://github.com/webpro-nl/knip/commit/f93ad0997e1b8fc644f6487df52803cba6f7a9ad"><code>f93ad09</code></a>
Respect JSDoc tags on URL dependencies (resolve <a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1943">#1943</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/webpro-nl/knip/commits/knip@6.34.0/packages/knip">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-04 11:02:22 -04:00
dependabot[bot] 7b5853f35d build(deps): bump the gh-actions group with 3 updates (#1310)
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.8 to 4.37.9
<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.9</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>
</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>
<ul>
<li>The CodeQL Action now supports CodeQL releases that are compatible
with Linux Arm64 and download the native <code>linux-arm64</code> CodeQL
bundle when available. <a
href="https://redirect.github.com/github/codeql-action/pull/4072">#4072</a></li>
</ul>
<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/cdf488f595d80d6e07e03d4674febd5ab45fa938"><code>cdf488f</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4107">#4107</a>
from github/update-v4.37.9-920ba7cd1</li>
<li><a
href="https://github.com/github/codeql-action/commit/7243f38558d187dde99730d224bb47aa26a95306"><code>7243f38</code></a>
Update changelog for v4.37.9</li>
<li><a
href="https://github.com/github/codeql-action/commit/920ba7cd1596037e042122c00381eb16b397d68e"><code>920ba7c</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4106">#4106</a>
from github/update-bundle/codeql-bundle-v2.26.4</li>
<li><a
href="https://github.com/github/codeql-action/commit/ecfa6e16817b8f490bc9a59baa391baf4fa3e3c2"><code>ecfa6e1</code></a>
Add changelog note</li>
<li><a
href="https://github.com/github/codeql-action/commit/adcdf4a70d247343cf9c29e0f7a6658b51c3a2b1"><code>adcdf4a</code></a>
Update default bundle to codeql-bundle-v2.26.4</li>
<li><a
href="https://github.com/github/codeql-action/commit/486fec2a3ea2626afcd8c7e9208b4f515078dd7e"><code>486fec2</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4099">#4099</a>
from github/update-supported-enterprise-server-versions</li>
<li><a
href="https://github.com/github/codeql-action/commit/134624c67b20869c2aaa36dafa726375b78a5d76"><code>134624c</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4101">#4101</a>
from github/dependabot/npm_and_yarn/npm-minor-457d82...</li>
<li><a
href="https://github.com/github/codeql-action/commit/ff43db8f982a368288f117354fb8d046e937124c"><code>ff43db8</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4103">#4103</a>
from github/mergeback/v4.37.8-to-main-db488dde</li>
<li><a
href="https://github.com/github/codeql-action/commit/4605e03a74cf891614c4d76f82384a16c1c11816"><code>4605e03</code></a>
Rebuild</li>
<li><a
href="https://github.com/github/codeql-action/commit/099c869cad6bf3b88657154d4ae47ffed27e632d"><code>099c869</code></a>
Update changelog and version after v4.37.8</li>
<li>Additional commits viewable in <a
href="https://github.com/github/codeql-action/compare/db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28...cdf488f595d80d6e07e03d4674febd5ab45fa938">compare
view</a></li>
</ul>
</details>
<br />

Updates `github/codeql-action/autobuild` from 4.37.8 to 4.37.9
<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.9</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>
</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>
<ul>
<li>The CodeQL Action now supports CodeQL releases that are compatible
with Linux Arm64 and download the native <code>linux-arm64</code> CodeQL
bundle when available. <a
href="https://redirect.github.com/github/codeql-action/pull/4072">#4072</a></li>
</ul>
<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/cdf488f595d80d6e07e03d4674febd5ab45fa938"><code>cdf488f</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4107">#4107</a>
from github/update-v4.37.9-920ba7cd1</li>
<li><a
href="https://github.com/github/codeql-action/commit/7243f38558d187dde99730d224bb47aa26a95306"><code>7243f38</code></a>
Update changelog for v4.37.9</li>
<li><a
href="https://github.com/github/codeql-action/commit/920ba7cd1596037e042122c00381eb16b397d68e"><code>920ba7c</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4106">#4106</a>
from github/update-bundle/codeql-bundle-v2.26.4</li>
<li><a
href="https://github.com/github/codeql-action/commit/ecfa6e16817b8f490bc9a59baa391baf4fa3e3c2"><code>ecfa6e1</code></a>
Add changelog note</li>
<li><a
href="https://github.com/github/codeql-action/commit/adcdf4a70d247343cf9c29e0f7a6658b51c3a2b1"><code>adcdf4a</code></a>
Update default bundle to codeql-bundle-v2.26.4</li>
<li><a
href="https://github.com/github/codeql-action/commit/486fec2a3ea2626afcd8c7e9208b4f515078dd7e"><code>486fec2</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4099">#4099</a>
from github/update-supported-enterprise-server-versions</li>
<li><a
href="https://github.com/github/codeql-action/commit/134624c67b20869c2aaa36dafa726375b78a5d76"><code>134624c</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4101">#4101</a>
from github/dependabot/npm_and_yarn/npm-minor-457d82...</li>
<li><a
href="https://github.com/github/codeql-action/commit/ff43db8f982a368288f117354fb8d046e937124c"><code>ff43db8</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4103">#4103</a>
from github/mergeback/v4.37.8-to-main-db488dde</li>
<li><a
href="https://github.com/github/codeql-action/commit/4605e03a74cf891614c4d76f82384a16c1c11816"><code>4605e03</code></a>
Rebuild</li>
<li><a
href="https://github.com/github/codeql-action/commit/099c869cad6bf3b88657154d4ae47ffed27e632d"><code>099c869</code></a>
Update changelog and version after v4.37.8</li>
<li>Additional commits viewable in <a
href="https://github.com/github/codeql-action/compare/db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28...cdf488f595d80d6e07e03d4674febd5ab45fa938">compare
view</a></li>
</ul>
</details>
<br />

Updates `github/codeql-action/analyze` from 4.37.8 to 4.37.9
<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.9</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>
</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>
<ul>
<li>The CodeQL Action now supports CodeQL releases that are compatible
with Linux Arm64 and download the native <code>linux-arm64</code> CodeQL
bundle when available. <a
href="https://redirect.github.com/github/codeql-action/pull/4072">#4072</a></li>
</ul>
<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/cdf488f595d80d6e07e03d4674febd5ab45fa938"><code>cdf488f</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4107">#4107</a>
from github/update-v4.37.9-920ba7cd1</li>
<li><a
href="https://github.com/github/codeql-action/commit/7243f38558d187dde99730d224bb47aa26a95306"><code>7243f38</code></a>
Update changelog for v4.37.9</li>
<li><a
href="https://github.com/github/codeql-action/commit/920ba7cd1596037e042122c00381eb16b397d68e"><code>920ba7c</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4106">#4106</a>
from github/update-bundle/codeql-bundle-v2.26.4</li>
<li><a
href="https://github.com/github/codeql-action/commit/ecfa6e16817b8f490bc9a59baa391baf4fa3e3c2"><code>ecfa6e1</code></a>
Add changelog note</li>
<li><a
href="https://github.com/github/codeql-action/commit/adcdf4a70d247343cf9c29e0f7a6658b51c3a2b1"><code>adcdf4a</code></a>
Update default bundle to codeql-bundle-v2.26.4</li>
<li><a
href="https://github.com/github/codeql-action/commit/486fec2a3ea2626afcd8c7e9208b4f515078dd7e"><code>486fec2</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4099">#4099</a>
from github/update-supported-enterprise-server-versions</li>
<li><a
href="https://github.com/github/codeql-action/commit/134624c67b20869c2aaa36dafa726375b78a5d76"><code>134624c</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4101">#4101</a>
from github/dependabot/npm_and_yarn/npm-minor-457d82...</li>
<li><a
href="https://github.com/github/codeql-action/commit/ff43db8f982a368288f117354fb8d046e937124c"><code>ff43db8</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4103">#4103</a>
from github/mergeback/v4.37.8-to-main-db488dde</li>
<li><a
href="https://github.com/github/codeql-action/commit/4605e03a74cf891614c4d76f82384a16c1c11816"><code>4605e03</code></a>
Rebuild</li>
<li><a
href="https://github.com/github/codeql-action/commit/099c869cad6bf3b88657154d4ae47ffed27e632d"><code>099c869</code></a>
Update changelog and version after v4.37.8</li>
<li>Additional commits viewable in <a
href="https://github.com/github/codeql-action/compare/db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28...cdf488f595d80d6e07e03d4674febd5ab45fa938">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-04 11:02:15 -04:00
Ama Senevirathneandamasen02 c18da92569 fix(postprocess): attach unmatched chaptered audio files to existing book group (#1176) (#1309)
### Summary
Fixes #1176

When downloading an audiobook with many chaptered tracks (e.g. 250+
`.flac` or `.mp3` files), indexer XML or release metadata often caps the
file list at ~100-110 entries. When the release extracts on disk,
`match_plan_to_files()` matched those first ~110 files to the planned
book group, while the remaining 140+ files fell into `unmatched` and
triggered fallback heuristic grouping.

Because heuristic grouping parsed the folder name (`Westwell - Hot &
Cold (2023)`) and stripped the series/author prefix, it generated a
second book titled `Hot & Cold` containing the remaining tracks,
resulting in two split book folders.

### Changes
- In `match_plan_to_files()`
(`shelfmark/download/postprocess/packs.py`), check `unmatched` files
before falling back to heuristic multi-book splitting.
- If an unmatched file is chaptered audio (`.flac`, `.mp3`, `.aac`,
etc.) and shares the directory with an existing book group, or if the
plan was a single-book plan, append it to that group instead of creating
a secondary book.
- Non-chaptered standalone books (e.g. `.m4b`, `.epub`) or files in
separate subfolders continue to fall back to heuristic grouping as
before.
- Added unit tests in `tests/download/test_packs.py` verifying:
1. Truncated track list in single folder properly appends remaining
chaptered tracks without splitting.
2. Single-book plan with multi-disc audio files (`CD1`/`CD2`) groups
together cleanly.
3. Multi-book packs with unmatched chaptered tracks route each track to
its respective book folder.

### Testing
Ran `uv run pytest tests/download/test_packs.py` and `uv run pytest
tests/core/test_processing_packs.py` (all passed cleanly). Checked type
annotations with `basedpyright` (0 errors) and formatting with `ruff`.

Co-authored-by: amasen02 <amasen02@users.noreply.github.com>
2026-09-04 11:01:55 -04:00
Jorge Lima 97d1bb0df4 fix(bypass): keep Anna's Archive's aa_ddg_check so clearance replays (#1305)
## What

Add `aa_ddg_check` to the cookie-store allowlist. One name, one test.

## Why

Every replay of stored clearance ends in the `?check=1` redirect loop,
so each search pays a fresh browser solve. On this instance (v1.3.15,
WireGuard egress, 0 VPN restarts across the traces) not one replay was
accepted in three days of DEBUG logs.

The `__ddg*` cookies are stored and replayed correctly. Anna's Archive
also sets a cookie of its own, `aa_ddg_check`, and its `?check=1` hop
only answers with the page when that cookie is present too. The
allowlist keeps `cf_*` and `__ddg*` names, so this one was never stored.

## Measured, same egress IP, cookies taken from one solve

| replayed | plain `requests` | `curl_cffi`, Chrome TLS fingerprint |
|---|---|---|
| filtered `__ddg*` only (current behaviour) | 302 → 302 → 302 … loop |
302 → 302 → 302 … loop |
| filtered + `__ddg8_/9_/10_` | loop | loop |
| filtered + `aa_ddg_check` | **302 → 200, real search page** | 302 →
200 |
| `aa_ddg_check` alone | 302 → 403 | — |

So the TLS fingerprint is not the problem, the per-check trio is not the
answer, and the cookie needs the `__ddg*` clearance next to it. Cookie
attributes as issued: domain `.annas-archive.gl`, path `/`, expiry 90
days. It is not bound to the query, and it is accepted with a stock
Python User-Agent.

## Through the real fetch path

Same process, `html_get_page`, the name allowlisted, three different
queries:

```
1st: solve expected         25.8s  bypass_calls=1  title='frankenstein shelley - search - an'  md5=True
2nd: other query             9.5s  bypass_calls=0  title='pride and prejudice austen - searc'  md5=True
3rd: third query             4.8s  bypass_calls=0  title='dracula stoker - search - anna's a'  md5=True
```

## Notes

- `tests/bypass/test_ddg_cookie_reuse.py` gains
`test_aa_check_cookie_is_stored`; its docstring table gains the row. The
bypass tests need seleniumbase to import and do not run on my macOS
host, so this leans on CI. `ruff check` and `ruff format --check` pass.
The logic was checked directly against `cookie_store` with the settings
registry stubbed.
- `__ddgmark_` carries a 24 h expiry, so the store's clearance is good
for about a day before the next solve, which is what a browser would see
too.
- Follow-up to #1286. Same instance, same method: DEBUG trace, then a
probe script inside the container.
2026-09-03 23:20:48 -04:00
Matt Van HornandMatt Van Horn 9f11e83e1f fix: keep polling queued Real-Debrid torrents (#1303)
Add `queued` to the existing set of non-terminal Real-Debrid torrent
states so `_handle_torrent_info` returns an in-progress `DownloadStatus`
and leaves the mutable download state eligible for subsequent polling.
Keep the change within the existing status-classification path rather
than introducing a new helper or changing the broader handling of
unknown statuses. The native Real-Debrid client currently treats the
documented `queued` torrent status as a terminal error because it is
absent from `_STATUS_DOWNLOADING`. This occurs after a torrent has been
added and its files selected, particularly for uncached torrents that
wait before downloading.

A torrent-info payload with `status: queued`, zero progress, and a
filename returns a non-complete `DownloadState.DOWNLOADING` result
rather than `DownloadState.ERROR`; After handling `queued`, the internal
download state remains non-terminal so a later status poll can be
processed instead of returning a cached error.

Fixes #1268

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
2026-09-03 23:19:50 -04:00
dependabot[bot] b98c2cb83e build(deps): bump the python-deps group with 2 updates (#1302)
Bumps the python-deps group with 2 updates:
[authlib](https://github.com/authlib/authlib) and
[apprise](https://github.com/caronc/apprise).

Updates `authlib` from 1.7.2 to 1.8.0
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/authlib/authlib/releases">authlib's
releases</a>.</em></p>
<blockquote>
<h2>v1.8.0</h2>
<h2>What's Changed</h2>
<ul>
<li>Prefer <code>id_token_signed_response_alg</code> client metadata to
guess algs by <a
href="https://github.com/azmeuk"><code>@​azmeuk</code></a> in <a
href="https://redirect.github.com/authlib/authlib/pull/888">authlib/authlib#888</a></li>
<li>fix: Catch InvalidKeyIdError in RFC 9068 JWTBearerTokenValidator by
<a
href="https://github.com/liudonggalaxy"><code>@​liudonggalaxy</code></a>
in <a
href="https://redirect.github.com/authlib/authlib/pull/891">authlib/authlib#891</a></li>
<li>fix: make leeway configurable in JWTBearerTokenValidator by <a
href="https://github.com/mondi04"><code>@​mondi04</code></a> in <a
href="https://redirect.github.com/authlib/authlib/pull/903">authlib/authlib#903</a></li>
<li>feat: add default jti claim to sign_jwt_bearer_assertion by <a
href="https://github.com/liudonggalaxy"><code>@​liudonggalaxy</code></a>
in <a
href="https://redirect.github.com/authlib/authlib/pull/897">authlib/authlib#897</a></li>
<li>fix(oauth): cast sub claim to string in JWTBearerTokenGenerator by
<a href="https://github.com/levinKaus"><code>@​levinKaus</code></a> in
<a
href="https://redirect.github.com/authlib/authlib/pull/911">authlib/authlib#911</a></li>
<li>Declare lower bounds for dependencies by <a
href="https://github.com/azmeuk"><code>@​azmeuk</code></a> in <a
href="https://redirect.github.com/authlib/authlib/pull/912">authlib/authlib#912</a></li>
<li>feat(client): use httpx2 instead of httpx by <a
href="https://github.com/levinKaus"><code>@​levinKaus</code></a> in <a
href="https://redirect.github.com/authlib/authlib/pull/909">authlib/authlib#909</a></li>
<li>Fix RFC7523 malformed claims handling by <a
href="https://github.com/azmeuk"><code>@​azmeuk</code></a> in <a
href="https://redirect.github.com/authlib/authlib/pull/916">authlib/authlib#916</a></li>
<li>Fix httpx oauth1 binary form data by <a
href="https://github.com/shc261392"><code>@​shc261392</code></a> in <a
href="https://redirect.github.com/authlib/authlib/pull/779">authlib/authlib#779</a></li>
<li>fix(starlette_client): remove default= keyword from config.get calls
by <a
href="https://github.com/aliaksei-protchanka"><code>@​aliaksei-protchanka</code></a>
in <a
href="https://redirect.github.com/authlib/authlib/pull/770">authlib/authlib#770</a></li>
<li>Added client_id parameter to AssertionClient by <a
href="https://github.com/vilmar-hillow"><code>@​vilmar-hillow</code></a>
in <a
href="https://redirect.github.com/authlib/authlib/pull/476">authlib/authlib#476</a></li>
<li>fix(oauth): save device credential with authenticated client id by
<a
href="https://github.com/arpitjain099"><code>@​arpitjain099</code></a>
in <a
href="https://redirect.github.com/authlib/authlib/pull/908">authlib/authlib#908</a></li>
<li>fix(oauth1): correct protocol name in InsecureTransportError
description by <a
href="https://github.com/RavSinghChandan"><code>@​RavSinghChandan</code></a>
in <a
href="https://redirect.github.com/authlib/authlib/pull/919">authlib/authlib#919</a></li>
<li>fix(client): client can be flexible with jwt's header by <a
href="https://github.com/lepture"><code>@​lepture</code></a> in <a
href="https://redirect.github.com/authlib/authlib/pull/922">authlib/authlib#922</a></li>
<li>fix(oidc): omit claims when the value is None by <a
href="https://github.com/lepture"><code>@​lepture</code></a> in <a
href="https://redirect.github.com/authlib/authlib/pull/923">authlib/authlib#923</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a href="https://github.com/mondi04"><code>@​mondi04</code></a> made
their first contribution in <a
href="https://redirect.github.com/authlib/authlib/pull/903">authlib/authlib#903</a></li>
<li><a href="https://github.com/levinKaus"><code>@​levinKaus</code></a>
made their first contribution in <a
href="https://redirect.github.com/authlib/authlib/pull/911">authlib/authlib#911</a></li>
<li><a
href="https://github.com/aliaksei-protchanka"><code>@​aliaksei-protchanka</code></a>
made their first contribution in <a
href="https://redirect.github.com/authlib/authlib/pull/770">authlib/authlib#770</a></li>
<li><a
href="https://github.com/vilmar-hillow"><code>@​vilmar-hillow</code></a>
made their first contribution in <a
href="https://redirect.github.com/authlib/authlib/pull/476">authlib/authlib#476</a></li>
<li><a
href="https://github.com/arpitjain099"><code>@​arpitjain099</code></a>
made their first contribution in <a
href="https://redirect.github.com/authlib/authlib/pull/908">authlib/authlib#908</a></li>
<li><a
href="https://github.com/RavSinghChandan"><code>@​RavSinghChandan</code></a>
made their first contribution in <a
href="https://redirect.github.com/authlib/authlib/pull/919">authlib/authlib#919</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/authlib/authlib/compare/v1.7.2...v1.8.0">https://github.com/authlib/authlib/compare/v1.7.2...v1.8.0</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/authlib/authlib/commit/1a86748b31a2b1940b09cf627d1b70e03d85c077"><code>1a86748</code></a>
chore: release 1.8.0</li>
<li><a
href="https://github.com/authlib/authlib/commit/e4fb941d4959a11e875b34880c41f46ba7cd8c67"><code>e4fb941</code></a>
Merge pull request <a
href="https://redirect.github.com/authlib/authlib/issues/923">#923</a>
from authlib/fix-921</li>
<li><a
href="https://github.com/authlib/authlib/commit/9f2cd2e4c8c2d88cd8c7831d2c612ad38a4b0958"><code>9f2cd2e</code></a>
Merge pull request <a
href="https://redirect.github.com/authlib/authlib/issues/922">#922</a>
from authlib/fix-902</li>
<li><a
href="https://github.com/authlib/authlib/commit/5d53239e8a0ca7ea1c241e84bfd5fd453203a94a"><code>5d53239</code></a>
fix(oidc): omit claims when the value is None</li>
<li><a
href="https://github.com/authlib/authlib/commit/772bc72fe8440e6c5dd0a6615fb42d7466f85546"><code>772bc72</code></a>
Merge pull request <a
href="https://redirect.github.com/authlib/authlib/issues/919">#919</a>
from RavSinghChandan/fix-oauth1-insecure-transport-de...</li>
<li><a
href="https://github.com/authlib/authlib/commit/11794b00375127bbc4397e7ebd46e047c3935b64"><code>11794b0</code></a>
Merge pull request <a
href="https://redirect.github.com/authlib/authlib/issues/908">#908</a>
from arpitjain099/chore/device-cred-authenticated-cli...</li>
<li><a
href="https://github.com/authlib/authlib/commit/13f2eb0589cec6df53c4e1146b11dc0cdd3012c7"><code>13f2eb0</code></a>
fix(client): Added client_id parameter to AssertionClient (<a
href="https://redirect.github.com/authlib/authlib/issues/476">#476</a>)</li>
<li><a
href="https://github.com/authlib/authlib/commit/dde5b7a8015904aba63dad76d7286aa431249783"><code>dde5b7a</code></a>
fix(client): rollback</li>
<li><a
href="https://github.com/authlib/authlib/commit/e4aec8c5c1e333951f49ba6aa3fe7487c6fce670"><code>e4aec8c</code></a>
fix(client): remove default= keyword from starlette client config.get
calls (...</li>
<li><a
href="https://github.com/authlib/authlib/commit/62f41cc2f55c15e658f7a0d2bdcb23feeeb93410"><code>62f41cc</code></a>
fix(client): httpx oauth1 binary form data (<a
href="https://redirect.github.com/authlib/authlib/issues/779">#779</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/authlib/authlib/compare/v1.7.2...v1.8.0">compare
view</a></li>
</ul>
</details>
<br />

Updates `apprise` from 1.13.0 to 1.13.1
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/caronc/apprise/releases">apprise's
releases</a>.</em></p>
<blockquote>
<h2>Release v1.13.1</h2>
<h2>What's Changed</h2>
<p>Just a small update to fix a small stability issue with Attachments
under some circumstances and the few PR's made by others in one more
small bundle.</p>
<p><strong>Note:</strong> ⚠️ <strong>This will be the last version of
Apprise v1.x</strong> (this time i mean it 😉 ) . Future releases
will be under Apprise v2.x. Again, I strongly want to urge Developers:
<em>please</em> make sure you pin your <code>projects.toml</code> or
<code>requirements.txt</code> to <code>apprise &gt;=1.0.0,
&lt;2.0.0</code>. v2.x will be awesome, but it WILL introduce breaking
changes your software may need to adapt to. I will still continue
supporting the v1.x branch should security issues arise for at year or
two, but eventually v2.x would be the way to go.</p>
<h3>📣 New Notification Services:</h3>
<ul>
<li>Add Signalgrid notification service plugin by <a
href="https://github.com/signalgridco"><code>@​signalgridco</code></a>
in <a
href="https://redirect.github.com/caronc/apprise/pull/1710">caronc/apprise#1710</a></li>
<li>Add Lauther notification support by <a
href="https://github.com/csanadymiklos"><code>@​csanadymiklos</code></a>
in <a
href="https://redirect.github.com/caronc/apprise/pull/1715">caronc/apprise#1715</a></li>
</ul>
<h3>🐞  Bugfixes</h3>
<ul>
<li>Fixes Gento Linux issue with memory based attachment handling in <a
href="https://redirect.github.com/caronc/apprise/pull/1709">caronc/apprise#1709</a>
<ul>
<li>☝️ This is the core reason for this minor update</li>
</ul>
</li>
<li>Allow periods in Pingram API keys (JWT format) by <a
href="https://github.com/lipusal"><code>@​lipusal</code></a> in <a
href="https://redirect.github.com/caronc/apprise/pull/1714">caronc/apprise#1714</a></li>
<li>fixes intermittent test failures for matrix in <a
href="https://redirect.github.com/caronc/apprise/pull/1717">caronc/apprise#1717</a></li>
</ul>
<h3>💡 Features</h3>
<p>n/a</p>
<h3>❤️ Life-Cycle Support</h3>
<ul>
<li>Allow Py 3.13+, please by <a
href="https://github.com/milos-korenciak"><code>@​milos-korenciak</code></a>
in <a
href="https://redirect.github.com/caronc/apprise/pull/1711">caronc/apprise#1711</a></li>
<li>fix(matrix): typofix in label by <a
href="https://github.com/arthurlutz"><code>@​arthurlutz</code></a> in <a
href="https://redirect.github.com/caronc/apprise/pull/1707">caronc/apprise#1707</a></li>
<li>docs: fix typo recomended -&gt; recommended by <a
href="https://github.com/vaibhav8a"><code>@​vaibhav8a</code></a> in <a
href="https://redirect.github.com/caronc/apprise/pull/1716">caronc/apprise#1716</a></li>
</ul>
<h2>Installation</h2>
<p>Apprise is available <a href="https://pypi.org/project/apprise/">on
PyPI</a> through <em>pip</em>:</p>
<pre lang="bash"><code># Install Apprise v1.13.1 from PyPI
pip install apprise==1.13.1
</code></pre>
<h2>New Contributors</h2>
<ul>
<li><a
href="https://github.com/arthurlutz"><code>@​arthurlutz</code></a> made
their first contribution in <a
href="https://redirect.github.com/caronc/apprise/pull/1707">caronc/apprise#1707</a></li>
<li><a
href="https://github.com/milos-korenciak"><code>@​milos-korenciak</code></a>
made their first contribution in <a
href="https://redirect.github.com/caronc/apprise/pull/1711">caronc/apprise#1711</a></li>
<li><a href="https://github.com/vaibhav8a"><code>@​vaibhav8a</code></a>
made their first contribution in <a
href="https://redirect.github.com/caronc/apprise/pull/1716">caronc/apprise#1716</a></li>
<li><a
href="https://github.com/signalgridco"><code>@​signalgridco</code></a>
made their first contribution in <a
href="https://redirect.github.com/caronc/apprise/pull/1710">caronc/apprise#1710</a></li>
<li><a
href="https://github.com/csanadymiklos"><code>@​csanadymiklos</code></a>
made their first contribution in <a
href="https://redirect.github.com/caronc/apprise/pull/1715">caronc/apprise#1715</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/caronc/apprise/compare/v1.13.0...v1.13.1">https://github.com/caronc/apprise/compare/v1.13.0...v1.13.1</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/caronc/apprise/commit/cf4da87eab713370a62d7a4f9ca64dcd3bffdf61"><code>cf4da87</code></a>
bumped version to v1.13.1</li>
<li><a
href="https://github.com/caronc/apprise/commit/0386b1cc093b3c55cfcebf4bdce17fc4309e0bd7"><code>0386b1c</code></a>
Add Lauther notification support (<a
href="https://redirect.github.com/caronc/apprise/issues/1715">#1715</a>)</li>
<li><a
href="https://github.com/caronc/apprise/commit/3299eb37aa60ed729f935f039b79a9f9786e7424"><code>3299eb3</code></a>
Add Signalgrid notification support (<a
href="https://redirect.github.com/caronc/apprise/issues/1710">#1710</a>)</li>
<li><a
href="https://github.com/caronc/apprise/commit/e05331ba9b5d2ace834b23e49728065ffebcb377"><code>e05331b</code></a>
relaxed some noisy logging</li>
<li><a
href="https://github.com/caronc/apprise/commit/8f73f6a0cdb83e6187624715a12e8f36dd8a1bbc"><code>8f73f6a</code></a>
Fixes intermittent test failures for matrix on py39 (<a
href="https://redirect.github.com/caronc/apprise/issues/1717">#1717</a>)</li>
<li><a
href="https://github.com/caronc/apprise/commit/0af1bb2097dad2557f55a523d09269341fa3fdfb"><code>0af1bb2</code></a>
docs: fix typo recomended -&gt; recommended (<a
href="https://redirect.github.com/caronc/apprise/issues/1716">#1716</a>)</li>
<li><a
href="https://github.com/caronc/apprise/commit/8df288a1824711bc6b1bdb59c1b5b356e4da3fd5"><code>8df288a</code></a>
Allow periods in Pingram API keys (JWT format) (<a
href="https://redirect.github.com/caronc/apprise/issues/1714">#1714</a>)</li>
<li><a
href="https://github.com/caronc/apprise/commit/aa7221b7cabe757e000a1e6e019fc8f91ec517a0"><code>aa7221b</code></a>
Allow Py 3.13+ (<a
href="https://redirect.github.com/caronc/apprise/issues/1711">#1711</a>)</li>
<li><a
href="https://github.com/caronc/apprise/commit/e93c9360c5ae2ed729a282bc454f6ceeb4abd9e1"><code>e93c936</code></a>
Fixes Gento Linux issue with memory based attachment handling (<a
href="https://redirect.github.com/caronc/apprise/issues/1709">#1709</a>)</li>
<li><a
href="https://github.com/caronc/apprise/commit/4e6f4b117fa356f7ba0a076080c4927fcf78bc47"><code>4e6f4b1</code></a>
typofix in matrix:// label (<a
href="https://redirect.github.com/caronc/apprise/issues/1707">#1707</a>)</li>
<li>See full diff in <a
href="https://github.com/caronc/apprise/compare/v1.13.0...v1.13.1">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-03 01:14:41 -04:00
163 changed files with 10396 additions and 1853 deletions
@@ -3,10 +3,8 @@ on:
push: push:
tags: tags:
- 'v*' - 'v*'
schedule: # Also dispatched on main by dev-image-debounce.yml, once main has been
# Nightly at 03:17 UTC — only builds if there are new commits on main # quiet for an hour, to publish the dev image.
# since the last successful run (see check-changes job).
- cron: '17 3 * * *'
workflow_dispatch: workflow_dispatch:
permissions: read-all permissions: read-all
@@ -14,41 +12,7 @@ env:
REGISTRY: ghcr.io REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository_owner }}/shelfmark IMAGE_NAME: ${{ github.repository_owner }}/shelfmark
jobs: jobs:
check-changes:
runs-on: ubuntu-latest
outputs:
should_build: ${{ steps.check.outputs.should_build }}
steps:
- name: Check for new commits since last successful build
id: check
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
EVENT_NAME: ${{ github.event_name }}
CURRENT_SHA: ${{ github.sha }}
REPO: ${{ github.repository }}
run: |
# Always build on tag pushes and manual dispatch.
if [[ "$EVENT_NAME" != "schedule" ]]; then
echo "Event is $EVENT_NAME — building unconditionally."
echo "should_build=true" >> "$GITHUB_OUTPUT"
exit 0
fi
# Scheduled run: only build if HEAD differs from the last successful build on main.
LAST_SHA=$(gh api "/repos/${REPO}/actions/workflows/build-and-publish-docker-image.yml/runs?branch=main&status=success&per_page=1" --jq '.workflow_runs[0].head_sha' 2>/dev/null || true)
echo "Last successful build SHA: ${LAST_SHA:-<none>}"
echo "Current HEAD SHA: ${CURRENT_SHA}"
if [[ -z "$LAST_SHA" || "$LAST_SHA" != "$CURRENT_SHA" ]]; then
echo "New commits detected — building."
echo "should_build=true" >> "$GITHUB_OUTPUT"
else
echo "No new commits since last successful build — skipping."
echo "should_build=false" >> "$GITHUB_OUTPUT"
fi
build-and-push-images: build-and-push-images:
needs: check-changes
if: needs.check-changes.outputs.should_build == 'true'
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
contents: read contents: read
@@ -96,11 +60,11 @@ jobs:
type=ref,event=tag type=ref,event=tag
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0
- name: Build and push ${{ matrix.target }} Docker image - name: Build and push ${{ matrix.target }} Docker image
id: push id: push
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
with: with:
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64
context: . context: .
@@ -141,7 +105,7 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0
- name: Create legacy aliases - name: Create legacy aliases
run: | run: |
+5 -5
View File
@@ -16,7 +16,7 @@ jobs:
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Install uv and Python - name: Install uv and Python
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
with: with:
version: "0.11.3" version: "0.11.3"
python-version: "3.14" python-version: "3.14"
@@ -42,7 +42,7 @@ jobs:
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Install uv and Python - name: Install uv and Python
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
with: with:
version: "0.11.3" version: "0.11.3"
python-version: "3.14" python-version: "3.14"
@@ -62,7 +62,7 @@ jobs:
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Install uv and Python - name: Install uv and Python
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
with: with:
version: "0.11.3" version: "0.11.3"
python-version: "3.14" python-version: "3.14"
@@ -81,10 +81,10 @@ jobs:
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0
- name: Build shelfmark-lite image - name: Build shelfmark-lite image
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
with: with:
context: . context: .
target: shelfmark-lite target: shelfmark-lite
+3 -3
View File
@@ -25,14 +25,14 @@ jobs:
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v3 uses: github/codeql-action/init@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v3
with: with:
languages: ${{ matrix.language }} languages: ${{ matrix.language }}
- name: Autobuild - name: Autobuild
uses: github/codeql-action/autobuild@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v3 uses: github/codeql-action/autobuild@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v3
- name: Perform CodeQL Analysis - name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v3 uses: github/codeql-action/analyze@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v3
with: with:
category: "/language:${{ matrix.language }}" category: "/language:${{ matrix.language }}"
+51
View File
@@ -0,0 +1,51 @@
name: Debounce dev image
# A burst of merges to main should publish one dev image, not one per commit.
# Each push to main waits out the 60-minute wait timer on the
# dev-image-debounce environment (Settings → Environments; waiting holds no
# runner), then dispatches the Docker workflow only if main still points at
# its commit. So only the last push of a burst builds, and the Docker
# workflow's history holds real builds only.
on:
push:
branches:
- main
permissions: {}
jobs:
debounce:
runs-on: ubuntu-latest
environment:
name: dev-image-debounce
deployment: false
permissions:
actions: write # dispatch the build, delete finished debounce runs
contents: read
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
steps:
- name: Dispatch the dev build if main is still at this commit
env:
CURRENT_SHA: ${{ github.sha }}
run: |
HEAD_SHA=$(gh api "repos/${GH_REPO}/git/ref/heads/main" --jq '.object.sha')
echo "This run's commit: ${CURRENT_SHA}"
echo "main HEAD now: ${HEAD_SHA}"
if [[ "$HEAD_SHA" == "$CURRENT_SHA" ]]; then
echo "No newer commits on main — dispatching the dev build."
gh workflow run build-and-publish-docker-image.yml --ref main
else
echo "main has moved on — the newer push's run will build it."
fi
# A finished debounce run is noise: it either did nothing or its build
# run is the record. Best effort, since runs from the same burst race to
# delete the same runs.
- name: Delete finished debounce runs
run: |
# On an HTTP error gh prints the error body to stdout, so bail out
# rather than loop over it.
ids=$(gh api "repos/${GH_REPO}/actions/workflows/dev-image-debounce.yml/runs?status=success&per_page=100" --jq '.workflow_runs[].id') || exit 0
for id in $ids; do
gh api -X DELETE "repos/${GH_REPO}/actions/runs/${id}" || true
done
+2 -2
View File
@@ -72,7 +72,7 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@v7 uses: actions/checkout@v7
- name: Install uv and Python - name: Install uv and Python
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
with: with:
python-version: "3.14" python-version: "3.14"
enable-cache: true enable-cache: true
@@ -97,7 +97,7 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@v7 uses: actions/checkout@v7
- name: Install uv and Python - name: Install uv and Python
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
with: with:
python-version: "3.14" python-version: "3.14"
enable-cache: true enable-cache: true
+3 -3
View File
@@ -4,7 +4,7 @@ ARG BUILDPLATFORM
ARG BUILDARCH ARG BUILDARCH
# Frontend build stage. # Frontend build stage.
FROM --platform=$BUILDPLATFORM node:24-alpine@sha256:d32cdf619f63fe0471182d08996dd516c6275bb5fd31ae06e55a570bd9e1ad43 AS frontend-builder FROM --platform=$BUILDPLATFORM node:24-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401c70e0e762c5b53302f905ec1c1 AS frontend-builder
# Helpful debug output to see what platforms BuildKit thinks it's using # Helpful debug output to see what platforms BuildKit thinks it's using
RUN echo "BUILDPLATFORM=$BUILDPLATFORM BUILDARCH=$BUILDARCH TARGETPLATFORM=$TARGETPLATFORM TARGETARCH=$TARGETARCH" RUN echo "BUILDPLATFORM=$BUILDPLATFORM BUILDARCH=$BUILDARCH TARGETPLATFORM=$TARGETPLATFORM TARGETARCH=$TARGETARCH"
@@ -28,10 +28,10 @@ RUN npm run build
# than copied into the image. A COPY here would land ~24 MB in a `base` layer that # than copied into the image. A COPY here would land ~24 MB in a `base` layer that
# every published image inherits, and a later `rm` cannot take it back out again -- # every published image inherits, and a later `rm` cannot take it back out again --
# a RUN adds a layer, it does not rewrite the one underneath. # a RUN adds a layer, it does not rewrite the one underneath.
FROM ghcr.io/astral-sh/uv:0.12.5@sha256:e85be844203885286c60ffad8a858d48afb6c5a5c237ca0e67f12e74b8f174b1 AS uv FROM ghcr.io/astral-sh/uv:0.12.16@sha256:adc68cd785ca65ea25c0611043b0a00b4ea3a22e1b54102fc084406d888082ee AS uv
# Use python-slim as the base image # Use python-slim as the base image
FROM python:3.14.7-slim@sha256:cae66f2ef0ec51a9891263eeee7f987dacf0a9879e8aa9353d5606e0530619a5 AS base FROM python:3.14.7-slim@sha256:cad9a2c871761c413caa6fdd6441c783451e740a48aaeba60ae62a8b53525ef6 AS base
# Add build argument for version # Add build argument for version
ARG BUILD_VERSION ARG BUILD_VERSION
+72
View File
@@ -0,0 +1,72 @@
# API access with an API key
Shelfmark's web interface is driven entirely by a JSON API under `/api/`. Set
the `SHELFMARK_API_KEY` environment variable and scripts, dashboards and assistants can
call the same API without a browser session. Browser logins keep working
exactly as before: it is cookie **or** key.
## Set the key
```yaml
environment:
SHELFMARK_API_KEY: "a-long-random-secret"
```
Generate something long and random (for example `openssl rand -base64 32`).
A request carrying the key acts as an **admin**: the first admin user in
Shelfmark's user database. Create an admin before relying on the key in any
install that has none yet (for example an OIDC-only install). Without an
admin user, the key still authenticates as an admin identity with no user
row, and routes that need one (requests, activity) answer 403. To rotate,
change the variable and restart. Unset it and the feature is off. When the
instance runs with no authentication configured (`AUTH_METHOD=none`), the
key is simply unnecessary.
## Send the key
Either header works, and both are checked, so the key can be sent in
`X-Api-Key` behind a reverse proxy that sets its own `Authorization` header.
```bash
curl -s -H "Authorization: Bearer $SHELFMARK_API_KEY" https://shelfmark.example.com/api/downloads/active
curl -s -H "X-Api-Key: $SHELFMARK_API_KEY" https://shelfmark.example.com/api/downloads/active
```
A request that carries the key is authenticated by the key alone. Session
cookies are ignored and none are set. A bearer value that is not the configured
key is ignored and the request continues with normal session authentication,
so reverse proxies that forward their own tokens are unaffected; without a valid
session such a request gets the usual `401 {"error": "Unauthorized"}`. A
database error while resolving the admin returns
`500 {"error": "Authentication error"}` — never anonymous access.
`/api/auth/check` reflects the browser session only and ignores the key, so
use `/api/status` to verify a key.
## Examples
Search, then look up releases, then queue one (the same calls the web UI makes):
```bash
curl -s -H "Authorization: Bearer $SHELFMARK_API_KEY" \
"https://shelfmark.example.com/api/metadata/search?query=dune%20frank%20herbert"
# -> {"books":[{"provider":"hardcover","provider_id":"427363", ...}]}
curl -s -H "Authorization: Bearer $SHELFMARK_API_KEY" \
"https://shelfmark.example.com/api/releases?provider=hardcover&book_id=427363&content_type=ebook"
# -> {"releases":[{"source":"direct_download","source_id":"...", ...}], ...}
curl -s -X POST -H "Authorization: Bearer $SHELFMARK_API_KEY" -H "Content-Type: application/json" \
-d @release.json https://shelfmark.example.com/api/releases/download
# release.json = one object from "releases" (source and source_id are required)
curl -s -H "Authorization: Bearer $SHELFMARK_API_KEY" https://shelfmark.example.com/api/status
```
## Security notes
- The key is compared in constant time and is never logged.
- Keyed requests never set cookies and ignore any cookie sent with them.
- WebSocket (live activity) connections do not accept the key; poll `/api/status` instead.
- The key is a root-equivalent credential: an admin can configure a custom
post-download script that the server executes, so treat it like a root
password and send it only over HTTPS.
+48 -9
View File
@@ -47,6 +47,7 @@ These environment variables are used at startup before the settings system loads
| `CWA_DB_PATH` | Path to the Calibre-Web database for authentication integration. | string (path) | `/auth/app.db` | | `CWA_DB_PATH` | Path to the Calibre-Web database for authentication integration. | string (path) | `/auth/app.db` |
| `HIDE_LOCAL_AUTH` | Hide the username/password login form when OIDC is active. | boolean | `false` | | `HIDE_LOCAL_AUTH` | Hide the username/password login form when OIDC is active. | boolean | `false` |
| `DISABLE_LOCAL_AUTH` | Disable username/password login and remove the local-admin prerequisite for OIDC. Implies HIDE_LOCAL_AUTH; with AUTH_METHOD=builtin, everyone is locked out until auth env vars are changed. | boolean | `false` | | `DISABLE_LOCAL_AUTH` | Disable username/password login and remove the local-admin prerequisite for OIDC. Implies HIDE_LOCAL_AUTH; with AUTH_METHOD=builtin, everyone is locked out until auth env vars are changed. | boolean | `false` |
| `SHELFMARK_API_KEY` | Optional static API key. When set, requests carrying it as 'Authorization: Bearer <key>' (or X-Api-Key) are authenticated as an admin; browser sessions keep working. Unset = off. | string | `unset` |
| `OIDC_AUTO_REDIRECT` | Automatically redirect to the OIDC provider instead of showing the login page. | boolean | `false` | | `OIDC_AUTO_REDIRECT` | Automatically redirect to the OIDC provider instead of showing the login page. | boolean | `false` |
| `DOCKERMODE` | Indicates the application is running inside a Docker container. | boolean | `false` | | `DOCKERMODE` | Indicates the application is running inside a Docker container. | boolean | `false` |
| `ONBOARDING` | Show the onboarding wizard on first run. Set to false to skip (useful for ephemeral storage). | boolean | `true` | | `ONBOARDING` | Show the onboarding wizard on first run. Set to false to skip (useful for ephemeral storage). | boolean | `true` |
@@ -124,6 +125,13 @@ Disable username/password login and remove the local-admin prerequisite for OIDC
- **Type:** boolean - **Type:** boolean
- **Default:** `false` - **Default:** `false`
#### `SHELFMARK_API_KEY`
Optional static API key. When set, requests carrying it as 'Authorization: Bearer <key>' (or X-Api-Key) are authenticated as an admin; browser sessions keep working. Unset = off.
- **Type:** string
- **Default:** `unset`
#### `OIDC_AUTO_REDIRECT` #### `OIDC_AUTO_REDIRECT`
Automatically redirect to the OIDC provider instead of showing the login page. Automatically redirect to the OIDC provider instead of showing the login page.
@@ -434,8 +442,9 @@ The release source tab to open by default in the release modal for audiobooks. U
| `BOOKS_OUTPUT_MODE` | Choose where completed book files are sent. | string (choice) | `folder` | | `BOOKS_OUTPUT_MODE` | Choose where completed book files are sent. | string (choice) | `folder` |
| `INGEST_DIR` | Directory where downloaded files are saved. Use {User} for per-user folders (e.g. /books/{User}). | string | `/books` | | `INGEST_DIR` | Directory where downloaded files are saved. Use {User} for per-user folders (e.g. /books/{User}). | string | `/books` |
| `FILE_ORGANIZATION` | Choose how downloaded book files are named and organized. | string (choice) | `rename` | | `FILE_ORGANIZATION` | Choose how downloaded book files are named and organized. | string (choice) | `rename` |
| `TEMPLATE_RENAME` | Variables: {Author}, {Title}, {Year}, {Language}, {User}, {OriginalName} (source filename without extension). Universal adds: {Series}, {SeriesPosition}, {Subtitle}, {PrimaryTitle}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. Rename templates are filename-only (no '/' or '\'); use Organize for folders. Applies to single-file downloads. | string | `{Author} - {Title} ({Year})` | | `NAMING_WORD_SEPARATOR` | Replaces spaces inside naming template values (e.g. 'Conan Doyle' -> 'Conan.Doyle' with '.'). Applies to books and audiobooks, rename and organize templates alike. Literal characters typed into a template (like the '-' in '{Author} - {Title}') are left as-is. Leave empty to keep spaces as-is. | string | _empty string_ |
| `TEMPLATE_ORGANIZE` | Use / to create folders. Variables: {Author}, {Title}, {Year}, {Language}, {User}, {OriginalName} (source filename without extension). Universal adds: {Series}, {SeriesPosition}, {Subtitle}, {PrimaryTitle}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. | string | `{Author}/{Title} ({Year})` | | `TEMPLATE_RENAME` | Variables: {Author}, {FirstAuthor} (first of several authors), {Title}, {Year}, {Language}, {User}, {OriginalName} (source filename without extension). Universal adds: {Series}, {SeriesPosition}, {Subtitle}, {PrimaryTitle}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. Rename templates are filename-only (no '/' or '\'); use Organize for folders. Applies to single-file downloads. | string | `{Author} - {Title} ({Year})` |
| `TEMPLATE_ORGANIZE` | Use / to create folders. Variables: {Author}, {FirstAuthor} (first of several authors), {Title}, {Year}, {Language}, {User}, {OriginalName} (source filename without extension). Universal adds: {Series}, {SeriesPosition}, {Subtitle}, {PrimaryTitle}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. | string | `{Author}/{Title} ({Year})` |
| `HARDLINK_TORRENTS` | Create hardlinks instead of copying. Preserves seeding but archives won't be extracted. Don't use if destination is a library ingest folder. | boolean | `false` | | `HARDLINK_TORRENTS` | Create hardlinks instead of copying. Preserves seeding but archives won't be extracted. Don't use if destination is a library ingest folder. | boolean | `false` |
| `BOOKLORE_HOST` | Base URL of your Grimmory instance | string | _none_ | | `BOOKLORE_HOST` | Base URL of your Grimmory instance | string | _none_ |
| `BOOKLORE_USERNAME` | Grimmory account username | string | _none_ | | `BOOKLORE_USERNAME` | Grimmory account username | string | _none_ |
@@ -456,8 +465,8 @@ The release source tab to open by default in the release modal for audiobooks. U
| `EMAIL_ALLOW_UNVERIFIED_TLS` | Disable TLS certificate verification (not recommended). | boolean | `false` | | `EMAIL_ALLOW_UNVERIFIED_TLS` | Disable TLS certificate verification (not recommended). | boolean | `false` |
| `DESTINATION_AUDIOBOOK` | Directory where downloaded audiobook files are saved. Leave empty to use the Books destination. | string | _none_ | | `DESTINATION_AUDIOBOOK` | Directory where downloaded audiobook files are saved. Leave empty to use the Books destination. | string | _none_ |
| `FILE_ORGANIZATION_AUDIOBOOK` | Choose how downloaded audiobook files are named and organized. | string (choice) | `rename` | | `FILE_ORGANIZATION_AUDIOBOOK` | Choose how downloaded audiobook files are named and organized. | string (choice) | `rename` |
| `TEMPLATE_AUDIOBOOK_RENAME` | Variables: {Author}, {Title}, {Year}, {Language}, {User}, {OriginalName} (source filename without extension), {Series}, {SeriesPosition}, {Subtitle}, {PrimaryTitle}, {PartNumber}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. Rename templates are filename-only (no '/' or '\'); use Organize for folders. Applies to single-file downloads. | string | `{Author} - {Title}` | | `TEMPLATE_AUDIOBOOK_RENAME` | Variables: {Author}, {FirstAuthor} (first of several authors), {Title}, {Year}, {Language}, {User}, {OriginalName} (source filename without extension), {Series}, {SeriesPosition}, {Subtitle}, {PrimaryTitle}, {PartNumber}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. Rename templates are filename-only (no '/' or '\'); use Organize for folders. Applies to single-file downloads. | string | `{Author} - {Title}` |
| `TEMPLATE_AUDIOBOOK_ORGANIZE` | Use / to create folders. Variables: {Author}, {Title}, {Year}, {Language}, {User}, {OriginalName} (source filename without extension), {Series}, {SeriesPosition}, {Subtitle}, {PrimaryTitle}, {PartNumber}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. | string | `{Author}/{Title}/{Title}` | | `TEMPLATE_AUDIOBOOK_ORGANIZE` | Use / to create folders. Variables: {Author}, {FirstAuthor} (first of several authors), {Title}, {Year}, {Language}, {User}, {OriginalName} (source filename without extension), {Series}, {SeriesPosition}, {Subtitle}, {PrimaryTitle}, {PartNumber}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. | string | `{Author}/{Title}/{Title}` |
| `HARDLINK_TORRENTS_AUDIOBOOK` | Create hardlinks instead of copying. Preserves seeding but archives won't be extracted. Don't use if destination is a library ingest folder. | boolean | `true` | | `HARDLINK_TORRENTS_AUDIOBOOK` | Create hardlinks instead of copying. Preserves seeding but archives won't be extracted. Don't use if destination is a library ingest folder. | boolean | `true` |
| `AUTO_OPEN_DOWNLOADS_SIDEBAR` | Automatically open the downloads sidebar when a new download is queued. | boolean | `false` | | `AUTO_OPEN_DOWNLOADS_SIDEBAR` | Automatically open the downloads sidebar when a new download is queued. | boolean | `false` |
| `DOWNLOAD_TO_BROWSER_CONTENT_TYPES` | Automatically download completed files to your browser for the selected content types. | string (comma-separated) | _empty list_ | | `DOWNLOAD_TO_BROWSER_CONTENT_TYPES` | Automatically download completed files to your browser for the selected content types. | string (comma-separated) | _empty list_ |
@@ -497,11 +506,20 @@ Choose how downloaded book files are named and organized.
- **Default:** `rename` - **Default:** `rename`
- **Options:** `none` (None), `rename` (Rename Only), `organize` (Rename and Organize) - **Options:** `none` (None), `rename` (Rename Only), `organize` (Rename and Organize)
#### `NAMING_WORD_SEPARATOR`
**Word Separator**
Replaces spaces inside naming template values (e.g. 'Conan Doyle' -> 'Conan.Doyle' with '.'). Applies to books and audiobooks, rename and organize templates alike. Literal characters typed into a template (like the '-' in '{Author} - {Title}') are left as-is. Leave empty to keep spaces as-is.
- **Type:** string
- **Default:** _empty string_
#### `TEMPLATE_RENAME` #### `TEMPLATE_RENAME`
**Naming Template** **Naming Template**
Variables: {Author}, {Title}, {Year}, {Language}, {User}, {OriginalName} (source filename without extension). Universal adds: {Series}, {SeriesPosition}, {Subtitle}, {PrimaryTitle}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. Rename templates are filename-only (no '/' or '\'); use Organize for folders. Applies to single-file downloads. Variables: {Author}, {FirstAuthor} (first of several authors), {Title}, {Year}, {Language}, {User}, {OriginalName} (source filename without extension). Universal adds: {Series}, {SeriesPosition}, {Subtitle}, {PrimaryTitle}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. Rename templates are filename-only (no '/' or '\'); use Organize for folders. Applies to single-file downloads.
- **Type:** string - **Type:** string
- **Default:** `{Author} - {Title} ({Year})` - **Default:** `{Author} - {Title} ({Year})`
@@ -510,7 +528,7 @@ Variables: {Author}, {Title}, {Year}, {Language}, {User}, {OriginalName} (source
**Path Template** **Path Template**
Use / to create folders. Variables: {Author}, {Title}, {Year}, {Language}, {User}, {OriginalName} (source filename without extension). Universal adds: {Series}, {SeriesPosition}, {Subtitle}, {PrimaryTitle}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. Use / to create folders. Variables: {Author}, {FirstAuthor} (first of several authors), {Title}, {Year}, {Language}, {User}, {OriginalName} (source filename without extension). Universal adds: {Series}, {SeriesPosition}, {Subtitle}, {PrimaryTitle}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty.
- **Type:** string - **Type:** string
- **Default:** `{Author}/{Title} ({Year})` - **Default:** `{Author}/{Title} ({Year})`
@@ -711,7 +729,7 @@ Choose how downloaded audiobook files are named and organized.
**Naming Template** **Naming Template**
Variables: {Author}, {Title}, {Year}, {Language}, {User}, {OriginalName} (source filename without extension), {Series}, {SeriesPosition}, {Subtitle}, {PrimaryTitle}, {PartNumber}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. Rename templates are filename-only (no '/' or '\'); use Organize for folders. Applies to single-file downloads. Variables: {Author}, {FirstAuthor} (first of several authors), {Title}, {Year}, {Language}, {User}, {OriginalName} (source filename without extension), {Series}, {SeriesPosition}, {Subtitle}, {PrimaryTitle}, {PartNumber}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. Rename templates are filename-only (no '/' or '\'); use Organize for folders. Applies to single-file downloads.
- **Type:** string - **Type:** string
- **Default:** `{Author} - {Title}` - **Default:** `{Author} - {Title}`
@@ -720,7 +738,7 @@ Variables: {Author}, {Title}, {Year}, {Language}, {User}, {OriginalName} (source
**Path Template** **Path Template**
Use / to create folders. Variables: {Author}, {Title}, {Year}, {Language}, {User}, {OriginalName} (source filename without extension), {Series}, {SeriesPosition}, {Subtitle}, {PrimaryTitle}, {PartNumber}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. Use / to create folders. Variables: {Author}, {FirstAuthor} (first of several authors), {Title}, {Year}, {Language}, {User}, {OriginalName} (source filename without extension), {Series}, {SeriesPosition}, {Subtitle}, {PrimaryTitle}, {PartNumber}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty.
- **Type:** string - **Type:** string
- **Default:** `{Author}/{Title}/{Title}` - **Default:** `{Author}/{Title}/{Title}`
@@ -784,6 +802,7 @@ How long to keep completed/failed downloads in the queue display.
| `PROXY_AUTH_USER_HEADER` | The HTTP header your proxy uses to pass the authenticated username. | string | `X-Auth-User` | | `PROXY_AUTH_USER_HEADER` | The HTTP header your proxy uses to pass the authenticated username. | string | `X-Auth-User` |
| `PROXY_AUTH_LOGOUT_URL` | The URL to redirect users to for logging out. Leave empty to disable logout functionality. | string | _empty string_ | | `PROXY_AUTH_LOGOUT_URL` | The URL to redirect users to for logging out. Leave empty to disable logout functionality. | string | _empty string_ |
| `PROXY_AUTH_ADMIN_GROUP_HEADER` | Optional: header your proxy uses to pass user groups/roles. | string | `X-Auth-Groups` | | `PROXY_AUTH_ADMIN_GROUP_HEADER` | Optional: header your proxy uses to pass user groups/roles. | string | `X-Auth-Groups` |
| `PROXY_AUTH_DEFAULT_ROLE` | Role for users the proxy authenticates for the first time when no admin group is configured. The first account is always an admin so the instance is never left without one. | string (choice) | `user` |
| `PROXY_AUTH_ADMIN_GROUP_NAME` | Optional: users in this group are treated as admins. Leave blank to skip group-based admin detection. | string | _empty string_ | | `PROXY_AUTH_ADMIN_GROUP_NAME` | Optional: users in this group are treated as admins. Leave blank to skip group-based admin detection. | string | _empty string_ |
| `OIDC_DISCOVERY_URL` | OpenID Connect discovery endpoint URL. Usually ends with /.well-known/openid-configuration. | string | _none_ | | `OIDC_DISCOVERY_URL` | OpenID Connect discovery endpoint URL. Usually ends with /.well-known/openid-configuration. | string | _none_ |
| `OIDC_CLIENT_ID` | OAuth2 client ID from your identity provider. | string | _none_ | | `OIDC_CLIENT_ID` | OAuth2 client ID from your identity provider. | string | _none_ |
@@ -843,6 +862,16 @@ Optional: users in this group are treated as admins. Leave blank to skip group-b
- **Type:** string - **Type:** string
- **Default:** _empty string_ - **Default:** _empty string_
#### `PROXY_AUTH_DEFAULT_ROLE`
**Proxy Auth Default Role**
Role for users the proxy authenticates for the first time when no admin group is configured. The first account is always an admin so the instance is never left without one.
- **Type:** string (choice)
- **Default:** `user`
- **Options:** `user` (User), `admin` (Admin)
#### `OIDC_DISCOVERY_URL` #### `OIDC_DISCOVERY_URL`
@@ -1572,6 +1601,7 @@ How long to keep cached search results before they expire.
| `PROWLARR_TORRENT_CLIENT` | Choose which torrent client to use | string (choice) | _empty string_ | | `PROWLARR_TORRENT_CLIENT` | Choose which torrent client to use | string (choice) | _empty string_ |
| `ALLDEBRID_API_KEY` | AllDebrid API Key (apiv4) from your AllDebrid account settings | string (secret) | _none_ | | `ALLDEBRID_API_KEY` | AllDebrid API Key (apiv4) from your AllDebrid account settings | string (secret) | _none_ |
| `REALDEBRID_API_KEY` | Real-Debrid API Key (Secret Token) from your Real-Debrid account settings | string (secret) | _none_ | | `REALDEBRID_API_KEY` | Real-Debrid API Key (Secret Token) from your Real-Debrid account settings | string (secret) | _none_ |
| `TORBOX_API_KEY` | TorBox API Key from your TorBox account settings | string (secret) | _none_ |
| `QBITTORRENT_URL` | Web UI URL of your qBittorrent instance | string | _none_ | | `QBITTORRENT_URL` | Web UI URL of your qBittorrent instance | string | _none_ |
| `QBITTORRENT_USERNAME` | qBittorrent Web UI username | string | _none_ | | `QBITTORRENT_USERNAME` | qBittorrent Web UI username | string | _none_ |
| `QBITTORRENT_PASSWORD` | qBittorrent Web UI password | string (secret) | _none_ | | `QBITTORRENT_PASSWORD` | qBittorrent Web UI password | string (secret) | _none_ |
@@ -1623,7 +1653,7 @@ Choose which torrent client to use
- **Type:** string (choice) - **Type:** string (choice)
- **Default:** _empty string_ - **Default:** _empty string_
- **Options:** `""` (None), `alldebrid` (AllDebrid), `qbittorrent` (qBittorrent), `realdebrid` (Real-Debrid), `transmission` (Transmission), `deluge` (Deluge), `rtorrent` (rTorrent) - **Options:** `""` (None), `alldebrid` (AllDebrid), `qbittorrent` (qBittorrent), `realdebrid` (Real-Debrid), `torbox` (TorBox), `transmission` (Transmission), `deluge` (Deluge), `rtorrent` (rTorrent)
#### `ALLDEBRID_API_KEY` #### `ALLDEBRID_API_KEY`
@@ -1643,6 +1673,15 @@ Real-Debrid API Key (Secret Token) from your Real-Debrid account settings
- **Type:** string (secret) - **Type:** string (secret)
- **Default:** _none_ - **Default:** _none_
#### `TORBOX_API_KEY`
**API Key**
TorBox API Key from your TorBox account settings
- **Type:** string (secret)
- **Default:** _none_
#### `QBITTORRENT_URL` #### `QBITTORRENT_URL`
**qBittorrent URL** **qBittorrent URL**
+1
View File
@@ -15,6 +15,7 @@ Use the guides below to set up the app, connect your library tools, and understa
- [Users & Requests](users-and-requests.md) - [Users & Requests](users-and-requests.md)
- [Reverse Proxy](reverse-proxy.md) - [Reverse Proxy](reverse-proxy.md)
- [OIDC](oidc.md) - [OIDC](oidc.md)
- [API Access](api-access.md)
- [URL Search Parameters](url-search-parameters.md) - [URL Search Parameters](url-search-parameters.md)
- [Custom Scripts](custom-scripts.md) - [Custom Scripts](custom-scripts.md)
+1
View File
@@ -87,6 +87,7 @@ Configure in Settings → Security:
| Proxy Auth Logout URL | `https://auth.example.com/logout` | | Proxy Auth Logout URL | `https://auth.example.com/logout` |
| Proxy Auth Admin Group Header | `Remote-Groups` | | Proxy Auth Admin Group Header | `Remote-Groups` |
| Proxy Auth Admin Group Name | `admins` (or your admin group) | | Proxy Auth Admin Group Name | `admins` (or your admin group) |
| Proxy Auth Default Role | `User` — first-time users are regular users; the very first account is still made admin. Only consulted when no admin group is set |
#### Nginx Configuration with Authelia #### Nginx Configuration with Authelia
+46 -23
View File
@@ -1,71 +1,79 @@
# URL Search Parameters # URL Search Parameters
You can trigger searches directly via URL by adding query parameters. This enables bookmarking searches and sharing links. You can trigger searches directly via URL. This enables bookmarking searches and sharing links.
Parameters live in the URL **hash** (`#…`), so they stay in the browser and are never sent to
the server. Shelfmark also keeps the hash in sync as you search, so the address bar always
holds a shareable link to what you're looking at.
## Basic Usage ## Basic Usage
``` ```
http://your-server:8084/?q=harry+potter http://your-server:8084/#q=harry+potter
``` ```
Older query-string links (`/?q=harry+potter`) still work: they're read once on load and
rewritten to the hash form.
## Supported Parameters ## Supported Parameters
| Parameter | Description | Example | | Parameter | Description | Example |
|-----------|-------------|---------| |-----------|-------------|---------|
| `q` or `query` | Main search query | `/?q=dune` | | `q` or `query` | Main search query | `/#q=dune` |
| `author` | Filter by author name | `/?author=frank+herbert` | | `author` | Filter by author name | `/#author=frank+herbert` |
| `title` | Filter by book title | `/?title=foundation` | | `title` | Filter by book title | `/#title=foundation` |
| `isbn` | Filter by ISBN | `/?isbn=978-0747532699` | | `isbn` | Filter by ISBN | `/#isbn=978-0747532699` |
| `lang` | Filter by language (ISO 639-1 code) | `/?lang=en` | | `lang` | Filter by language (ISO 639-1 code) | `/#lang=en` |
| `format` | Filter by file format | `/?format=epub` | | `format` | Filter by file format | `/#format=epub` |
| `content` | Filter by content type | `/?content=fiction` | | `content` | Filter by content type | `/#content=fiction` |
| `content_type` | Select media type (`ebook`, `audiobook`, or `combined`) in Universal mode only | `/?q=dune&content_type=audiobook` | | `content_type` | Select media type (`ebook`, `audiobook`, or `combined`) in Universal mode only | `/#q=dune&content_type=audiobook` |
| `sort` | Sort order for results | `/?sort=newest` | | `sort` | Sort order for results | `/#sort=newest` |
| `search_by` | "Search By" target the query applies to (`general`, `author`, `title`, `isbn`, a metadata provider field like `series`, or `manual`) | `/#search_by=author&q=frank+herbert` |
## Multiple Values ## Multiple Values
Some parameters support multiple values by repeating the parameter: Some parameters support multiple values by repeating the parameter:
``` ```
/?lang=en&lang=de&lang=fr /#lang=en&lang=de&lang=fr
/?format=epub&format=mobi&format=azw3 /#format=epub&format=mobi&format=azw3
``` ```
## Examples ## Examples
**Simple search:** **Simple search:**
``` ```
/?q=lord+of+the+rings /#q=lord+of+the+rings
``` ```
**Search with author filter:** **Search with author filter:**
``` ```
/?q=dune&author=frank+herbert /#q=dune&author=frank+herbert
``` ```
**Search with format and language:** **Search with format and language:**
``` ```
/?q=harry+potter&format=epub&lang=en /#q=harry+potter&format=epub&lang=en
``` ```
**Author search with multiple formats:** **Author search with multiple formats:**
``` ```
/?author=stephen+king&format=epub&format=mobi /#author=stephen+king&format=epub&format=mobi
``` ```
**Search with sort order:** **Search with sort order:**
``` ```
/?q=science+fiction&sort=newest /#q=science+fiction&sort=newest
``` ```
**Universal search as audiobook:** **Universal search as audiobook:**
``` ```
/?q=dune&content_type=audiobook /#q=dune&content_type=audiobook
``` ```
**Universal search forcing combined (ebook + audiobook):** **Universal search forcing combined (ebook + audiobook):**
``` ```
/?q=dune&content_type=combined /#q=dune&content_type=combined
``` ```
## Search Mode Behavior ## Search Mode Behavior
@@ -77,13 +85,28 @@ When Search Mode is set to Direct, all parameters are used to filter results fro
### Universal Mode ### Universal Mode
`q`, `sort`, and `content_type` are used. Other parameters (author, title, format, etc.) are silently ignored since metadata providers have their own search capabilities. `q`, `search_by`, `sort`, and `content_type` are used. Other parameters (author, title, format, etc.) are silently ignored since metadata providers have their own search capabilities — except when `search_by` names one of the provider's own search fields, in which case `q` is sent as that field's value.
`content_type=combined` forces combined mode (search ebook and audiobook providers together), overriding the last-used preference. It is silently ignored if combined mode is unavailable (e.g. the combined selector is disabled in settings, or either content type is blocked by request policy). `content_type=combined` forces combined mode (search ebook and audiobook providers together), overriding the last-used preference. It is silently ignored if combined mode is unavailable (e.g. the combined selector is disabled in settings, or either content type is blocked by request policy).
## Search By
`search_by` picks which target the `q` value is applied to, matching the selector next to the
search box. It can be deep-linked on its own (`/#search_by=manual`) to open the app in that
mode with an empty query.
`search_by=manual` fills the search box but does not auto-run: manual search opens the release
browser from an explicit submit.
A `search_by` naming a target that isn't available (wrong search mode, or a metadata provider
that doesn't offer that field) is ignored, and the query falls back to a general search.
## Notes ## Notes
- URL parameters are read once on page load - URL parameters are read once on page load, and again if the hash is replaced in an open tab
- The URL is not updated when you perform searches manually (e.g. pasting a shared link into the address bar)
- The hash is kept in sync with the search box, Search By target and filters as you search
- Spaces should be encoded as `+` or `%20` - Spaces should be encoded as `+` or `%20`
- Invalid or unknown parameters are silently ignored - Invalid or unknown parameters are silently ignored
- Your last-used Search By target is remembered in browser storage and used when a link
doesn't specify one
+5 -5
View File
@@ -21,8 +21,8 @@ dependencies = [
"rarfile", "rarfile",
"qbittorrent-api>=2026.8.1", "qbittorrent-api>=2026.8.1",
"transmission-rpc", "transmission-rpc",
"authlib>=1.7.2,<1.8", "authlib>=1.8.0,<1.9",
"apprise>=1.13.0", "apprise>=1.13.1",
# HTTP/2 client for RFC 8484 DoH: quad9 rejects HTTP/1.1 outright (505), which # HTTP/2 client for RFC 8484 DoH: quad9 rejects HTTP/1.1 outright (505), which
# requests cannot speak. See shelfmark/download/doh_wireformat.py. # requests cannot speak. See shelfmark/download/doh_wireformat.py.
"httpx[http2]>=0.28.1", "httpx[http2]>=0.28.1",
@@ -32,18 +32,18 @@ dependencies = [
browser = [ browser = [
"pyvirtualdisplay", "pyvirtualdisplay",
"pyautogui", "pyautogui",
"seleniumbase==4.53.5", "seleniumbase==4.54.9",
"python-xlib", "python-xlib",
] ]
[dependency-groups] [dependency-groups]
dev = [ dev = [
"basedpyright>=1.39.10", "basedpyright>=1.40.1",
"prek", "prek",
"pytest", "pytest",
"pytest-cov", "pytest-cov",
"pytest-xdist>=3.8.0", "pytest-xdist>=3.8.0",
"ruff==0.16.5", "ruff==0.16.8",
"vulture>=2.14", "vulture>=2.14",
] ]
+2
View File
@@ -248,6 +248,8 @@ volumes:
With any authentication method enabled, Shelfmark supports multi-user management with admin/user roles. Users can have per-user settings for download destinations, email recipients, and notification preferences. Non-admin users only see their own downloads and can submit book requests for admin review. Admins can configure request policies per source to control whether users can download directly, must submit a request, or are blocked entirely. With any authentication method enabled, Shelfmark supports multi-user management with admin/user roles. Users can have per-user settings for download destinations, email recipients, and notification preferences. Non-admin users only see their own downloads and can submit book requests for admin review. Admins can configure request policies per source to control whether users can download directly, must submit a request, or are blocked entirely.
See [API Access](docs/api-access.md) to call the API with a static key from scripts and integrations.
## Project Scope ## Project Scope
Shelfmark is a manual search and download tool, the entry point to your book library, not a library manager. It finds books, downloads them, and sends them to a configured destination. That's the full scope. Shelfmark is a manual search and download tool, the entry point to your book library, not a library manager. It finds books, downloads them, and sends them to a configured destination. That's the full scope.
+6
View File
@@ -184,6 +184,12 @@ def _generate_bootstrap_env_docs() -> list[str]:
"type": "boolean", "type": "boolean",
"default": "false", "default": "false",
}, },
{
"name": "SHELFMARK_API_KEY",
"description": "Optional static API key. When set, requests carrying it as 'Authorization: Bearer <key>' (or X-Api-Key) are authenticated as an admin; browser sessions keep working. Unset = off.",
"type": "string",
"default": "unset",
},
{ {
"name": "OIDC_AUTO_REDIRECT", "name": "OIDC_AUTO_REDIRECT",
"description": "Automatically redirect to the OIDC provider instead of showing the login page.", "description": "Automatically redirect to the OIDC provider instead of showing the login page.",
+6 -1
View File
@@ -38,6 +38,10 @@ DDG_COOKIE_NAMES = {
"ddg_last_challenge", "ddg_last_challenge",
} }
# Anna's Archive's own pass for its ?check=1 hop. Without it the hop 302s back
# forever, however good the __ddg* clearance is.
AA_COOKIE_NAMES = {"aa_ddg_check"}
# DDoS-Guard cookies that describe *one* check rather than granting clearance, and so # DDoS-Guard cookies that describe *one* check rather than granting clearance, and so
# must never be replayed on a later request. Observed live on Anna's Archive: # must never be replayed on a later request. Observed live on Anna's Archive:
# #
@@ -88,7 +92,8 @@ def _should_extract_cookie(name: str, *, extract_all: bool) -> bool:
return True return True
is_cf = name in CF_COOKIE_NAMES or name.startswith("cf_") is_cf = name in CF_COOKIE_NAMES or name.startswith("cf_")
is_ddg = name in DDG_COOKIE_NAMES or name.startswith("__ddg") is_ddg = name in DDG_COOKIE_NAMES or name.startswith("__ddg")
return is_cf or is_ddg is_aa = name in AA_COOKIE_NAMES
return is_cf or is_ddg or is_aa
def _cookie_field(cookie: Any, name: str) -> Any: def _cookie_field(cookie: Any, name: str) -> Any:
+106 -9
View File
@@ -37,6 +37,7 @@ from shelfmark.bypass.cookie_store import (
store_extracted_cookies, store_extracted_cookies,
) )
from shelfmark.bypass.fingerprint import get_screen_size from shelfmark.bypass.fingerprint import get_screen_size
from shelfmark.bypass.waiting_room import WaitingRoomTimeoutError, is_aa_waiting_room
from shelfmark.config import env from shelfmark.config import env
from shelfmark.config.env import LOG_DIR from shelfmark.config.env import LOG_DIR
from shelfmark.config.settings import RECORDING_DIR from shelfmark.config.settings import RECORDING_DIR
@@ -85,11 +86,22 @@ _HELPER_IDLE_TIMEOUT_DEFAULT = 180.0
# How long to wait for a solved page to produce its document before the attempt is # 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. # abandoned. SeleniumBase's own get_page_source() allows one second; see _read_page_source.
_PAGE_SOURCE_TIMEOUT_DEFAULT = 20.0 _PAGE_SOURCE_TIMEOUT_DEFAULT = 20.0
# Leave time inside the existing browser watchdog for challenge solving and cleanup.
_AA_WAITING_ROOM_TIMEOUT_SECONDS = 300.0
_AA_WAITING_ROOM_POLL_SECONDS = 1.0
_PARENT_WATCHDOG_INTERVAL_SECONDS = 5.0 _PARENT_WATCHDOG_INTERVAL_SECONDS = 5.0
# How much of ffmpeg's stderr to quote when reporting that it died. # How much of ffmpeg's stderr to quote when reporting that it died.
_FFMPEG_ERROR_TAIL_CHARS = 500 _FFMPEG_ERROR_TAIL_CHARS = 500
class _WaitingRoomSnapshot(TypedDict):
html: str
title: str
body: str
url: str
waiting: bool
class _DisplayState(TypedDict): class _DisplayState(TypedDict):
ffmpeg: subprocess.Popen[bytes] | None ffmpeg: subprocess.Popen[bytes] | None
ffmpeg_output: Path | None ffmpeg_output: Path | None
@@ -454,6 +466,14 @@ async def _detect_challenge_type(page: Any) -> str:
async def _is_bypassed(page: Any, *, escape_emojis: bool = True) -> bool: async def _is_bypassed(page: Any, *, escape_emojis: bool = True) -> bool:
"""Check if the protection has been bypassed.""" """Check if the protection has been bypassed."""
title, body, current_url = await _get_page_info(page) title, body, current_url = await _get_page_info(page)
return _is_bypassed_content(title, body, current_url, escape_emojis=escape_emojis)
def _is_bypassed_content(
title: str, body: str, current_url: str, *, escape_emojis: bool = True
) -> bool:
"""Apply the same protection checks to one consistent page snapshot."""
title, body = title.lower(), body.lower()
body_len = len(body.strip()) body_len = len(body.strip())
# Long page content = probably bypassed # Long page content = probably bypassed
@@ -831,6 +851,75 @@ async def _read_page_source(page: Any) -> str:
return await element.get_html_async() return await element.get_html_async()
async def _read_waiting_room_snapshot(
page: Any, cancel_flag: Event | None
) -> _WaitingRoomSnapshot | None:
"""Read one DOM snapshot while still checking cancellation during a stalled read."""
task = asyncio.create_task(
page.evaluate("""({
html: document.documentElement?.outerHTML || '',
title: document.title,
body: document.body?.innerText || '',
url: location.href,
waiting: !!document.querySelector('.js-partner-countdown')
})""")
)
try:
while not task.done():
_check_cancellation(cancel_flag, "Bypass cancelled in Anna's waiting room")
# Keep a slow CDP request alive. Cancelling and reissuing it on every
# poll can break the listener when a late response targets a cancelled
# SeleniumBase transaction. Cancel only when this browser is unwinding.
await asyncio.wait({task}, timeout=_AA_WAITING_ROOM_POLL_SECONDS)
_check_cancellation(cancel_flag, "Bypass cancelled in Anna's waiting room")
return task.result()
finally:
task.cancel()
await asyncio.gather(task, return_exceptions=True)
async def _wait_for_aa_download_page(
page: Any, url: str, html: str, cancel_flag: Event | None = None
) -> str:
"""Let the waiting room's own JavaScript countdown and navigation finish.
Returning the timer HTML closes this incognito browser. Sleeping in the HTTP
downloader and fetching again then starts a different session, losing queue state.
Keep the original tab alive, including through zero and automatic page reloads.
"""
if not is_aa_waiting_room(url, html):
return html
logger.info("Waiting for Anna's Archive queue in the same browser session...")
started = time.monotonic()
try:
async with asyncio.timeout(_AA_WAITING_ROOM_TIMEOUT_SECONDS):
while True:
try:
# Read readiness and HTML atomically: navigation between separate
# CDP reads could validate a new page but return an old interstitial.
snapshot = await _read_waiting_room_snapshot(page, cancel_flag)
except _CDP_OPERATION_ERRORS:
# A frame/context can disappear during automatic navigation.
snapshot = None
if (
snapshot
and not snapshot["waiting"]
and _is_bypassed_content(snapshot["title"], snapshot["body"], snapshot["url"])
):
logger.info(
"Anna's Archive waiting room finished after %.0fs",
time.monotonic() - started,
)
return snapshot["html"]
# A zero timer, empty document, or protection page is not completion.
await asyncio.sleep(_AA_WAITING_ROOM_POLL_SECONDS)
except TimeoutError as exc:
raise WaitingRoomTimeoutError(
f"Anna's Archive waiting room did not finish within {_AA_WAITING_ROOM_TIMEOUT_SECONDS:g}s"
) from exc
async def _get(url: str, driver: Any, cancel_flag: Event | None = None) -> str: async def _get(url: str, driver: Any, cancel_flag: Event | None = None) -> str:
"""Fetch URL with Cloudflare bypass using a CDP browser.""" """Fetch URL with Cloudflare bypass using a CDP browser."""
_check_cancellation(cancel_flag, "Bypass cancelled before starting") _check_cancellation(cancel_flag, "Bypass cancelled before starting")
@@ -853,8 +942,10 @@ async def _get(url: str, driver: Any, cancel_flag: Event | None = None) -> str:
logger.debug("Starting bypass process...") logger.debug("Starting bypass process...")
if await _bypass(page, cancel_flag=cancel_flag): if await _bypass(page, cancel_flag=cancel_flag):
html = await _read_page_source(page)
html = await _wait_for_aa_download_page(page, url, html, cancel_flag)
await _extract_cookies_from_cdp(driver, page, url) await _extract_cookies_from_cdp(driver, page, url)
return await _read_page_source(page) return html
logger.warning("Bypass completed but page still shows protection") logger.warning("Bypass completed but page still shows protection")
try: try:
@@ -904,7 +995,8 @@ def _run_bypass_in_current_process(url: str, retry: int, cancel_flag: Event | No
result = await _get(url, driver, cancel_flag) result = await _get(url, driver, cancel_flag)
if result: if result:
return result return result
except BypassCancelledError: except BypassCancelledError, WaitingRoomTimeoutError:
# Retrying would restart the same queue in another browser.
raise raise
except _CDP_OPERATION_ERRORS as e: except _CDP_OPERATION_ERRORS as e:
error_details = f"{type(e).__name__}: {e}" error_details = f"{type(e).__name__}: {e}"
@@ -1215,6 +1307,8 @@ def _get_via_subprocess(url: str, retry: int, cancel_flag: Event | None = None)
trace = result.get("traceback") trace = result.get("traceback")
if trace: if trace:
logger.debug("Internal bypasser helper traceback: %s", trace) logger.debug("Internal bypasser helper traceback: %s", trace)
if error_type == WaitingRoomTimeoutError.__name__:
raise WaitingRoomTimeoutError(error)
msg = f"{error_type}: {error}" msg = f"{error_type}: {error}"
raise RuntimeError(msg) raise RuntimeError(msg)
@@ -1397,17 +1491,16 @@ def _start_ffmpeg_recording(display: str) -> None:
timestamp = datetime.now(UTC).strftime("%y%m%d-%H%M%S") timestamp = datetime.now(UTC).strftime("%y%m%d-%H%M%S")
output_file = RECORDING_DIR / f"screen_recording_{timestamp}.mp4" output_file = RECORDING_DIR / f"screen_recording_{timestamp}.mp4"
screen_width, screen_height = get_screen_size() # No -video_size: x11grab then captures the whole screen, whatever size it is. The
display_width = screen_width + 100 # size we ask SeleniumBase for (xvfb_metrics) is not the size we get. It builds that
display_height = screen_height + 150 # display with use_xauth=True, the image ships no xauth binary, so it falls back to a
# fixed 1440x1880 Xvfb. Asking ffmpeg for the fingerprint size plus margin then asked
# for an area larger than the screen, and every recording died at startup (#1364).
ffmpeg_cmd = [ ffmpeg_cmd = [
"ffmpeg", "ffmpeg",
"-y", "-y",
"-f", "-f",
"x11grab", "x11grab",
"-video_size",
f"{display_width}x{display_height}",
"-i", "-i",
display, display,
"-c:v", "-c:v",
@@ -1534,6 +1627,10 @@ def _try_with_cached_cookies(url: str, hostname: str) -> str | None:
verify=get_ssl_verify(url), verify=get_ssl_verify(url),
) )
if response.status_code == HTTPStatus.OK: if response.status_code == HTTPStatus.OK:
if is_aa_waiting_room(url, response.text):
# Clearance is valid, but HTTP cannot run the queue's JavaScript.
# Enforce this here for both cache checks, including the locked one.
return None
logger.debug("Cached cookies worked, skipped Chrome bypass") logger.debug("Cached cookies worked, skipped Chrome bypass")
return response.text return response.text
if response.status_code == HTTPStatus.TOO_MANY_REQUESTS: if response.status_code == HTTPStatus.TOO_MANY_REQUESTS:
@@ -1606,7 +1703,7 @@ def get_bypassed_page(
try: try:
response_html = get(attempt_url, cancel_flag=cancel_flag) response_html = get(attempt_url, cancel_flag=cancel_flag)
except BypassCancelledError: except BypassCancelledError, WaitingRoomTimeoutError:
raise raise
except _CDP_OPERATION_ERRORS + _REQUEST_OPERATION_ERRORS: except _CDP_OPERATION_ERRORS + _REQUEST_OPERATION_ERRORS:
_check_cancellation(cancel_flag, "Bypass cancelled") _check_cancellation(cancel_flag, "Bypass cancelled")
+16
View File
@@ -0,0 +1,16 @@
"""Recognize Anna's Archive pages that require a live JavaScript timer."""
from urllib.parse import urlparse
from bs4 import BeautifulSoup
class WaitingRoomTimeoutError(TimeoutError):
"""The source waiting room did not finish within the browser session budget."""
def is_aa_waiting_room(url: str, html: str) -> bool:
"""Match the download route and actual timer element, not a script reference."""
return urlparse(url).path.startswith("/slow_download/") and bool(
BeautifulSoup(html, "html.parser").select_one(".js-partner-countdown")
)
+3
View File
@@ -166,6 +166,9 @@ SESSION_COOKIE_NAME = "shelfmark_session"
CWA_DB_PATH = _resolve_cwa_db_path() CWA_DB_PATH = _resolve_cwa_db_path()
HIDE_LOCAL_AUTH = string_to_bool(os.getenv("HIDE_LOCAL_AUTH", "false")) HIDE_LOCAL_AUTH = string_to_bool(os.getenv("HIDE_LOCAL_AUTH", "false"))
DISABLE_LOCAL_AUTH = string_to_bool(os.getenv("DISABLE_LOCAL_AUTH", "false")) DISABLE_LOCAL_AUTH = string_to_bool(os.getenv("DISABLE_LOCAL_AUTH", "false"))
# Optional static API key. When set, requests carrying it as a Bearer token
# (or X-Api-Key) are authenticated as an admin for that request only.
SHELFMARK_API_KEY = os.getenv("SHELFMARK_API_KEY", "").strip()
OIDC_AUTO_REDIRECT = string_to_bool(os.getenv("OIDC_AUTO_REDIRECT", "false")) OIDC_AUTO_REDIRECT = string_to_bool(os.getenv("OIDC_AUTO_REDIRECT", "false"))
+12
View File
@@ -179,6 +179,18 @@ def security_settings() -> list[SettingsField]:
placeholder="e.g. admins", placeholder="e.g. admins",
default="", default="",
), ),
_auth_field(
SelectField,
"proxy",
key="PROXY_AUTH_DEFAULT_ROLE",
label="Proxy Auth Default Role",
description="Role for users the proxy authenticates for the first time when no admin group is configured. The first account is always an admin so the instance is never left without one.",
options=[
{"value": "user", "label": "User"},
{"value": "admin", "label": "Admin"},
],
default="user",
),
] ]
fields.append( fields.append(
+24 -5
View File
@@ -204,6 +204,7 @@ register_group(
# Direct mode sort options # Direct mode sort options
_AA_SORT_OPTIONS = [ _AA_SORT_OPTIONS = [
{"value": "", "label": "Most downloads"},
{"value": "relevance", "label": "Most relevant"}, {"value": "relevance", "label": "Most relevant"},
{"value": "newest", "label": "Newest (publication year)"}, {"value": "newest", "label": "Newest (publication year)"},
{"value": "oldest", "label": "Oldest (publication year)"}, {"value": "oldest", "label": "Oldest (publication year)"},
@@ -483,7 +484,7 @@ def search_mode_settings() -> list[SettingsField]:
label="Default Sort Order", label="Default Sort Order",
description="Default sort order for search results.", description="Default sort order for search results.",
options=_AA_SORT_OPTIONS, options=_AA_SORT_OPTIONS,
default="relevance", default="",
show_when={"field": "SEARCH_MODE", "value": "direct"}, show_when={"field": "SEARCH_MODE", "value": "direct"},
), ),
CheckboxField( CheckboxField(
@@ -1023,12 +1024,30 @@ def download_settings() -> list[SettingsField]:
"value": "folder", "value": "folder",
}, },
), ),
TextField(
key="NAMING_WORD_SEPARATOR",
label="Word Separator",
description=(
"Replaces spaces inside naming template values (e.g. 'Conan Doyle' -> "
"'Conan.Doyle' with '.'). Applies to books and audiobooks, rename and "
"organize templates alike. Literal characters typed into a template "
"(like the '-' in '{Author} - {Title}') are left as-is. Leave empty to "
"keep spaces as-is."
),
default="",
placeholder=".",
max_length=5,
show_when={
"field": "BOOKS_OUTPUT_MODE",
"value": "folder",
},
),
# Rename mode template - filename only # Rename mode template - filename only
_naming_template_field( _naming_template_field(
key="TEMPLATE_RENAME", key="TEMPLATE_RENAME",
label="Naming Template", label="Naming Template",
description=( description=(
"Variables: {Author}, {Title}, {Year}, {Language}, {User}, {OriginalName} " "Variables: {Author}, {FirstAuthor} (first of several authors), {Title}, {Year}, {Language}, {User}, {OriginalName} "
"(source filename without extension). Universal adds: {Series}, " "(source filename without extension). Universal adds: {Series}, "
"{SeriesPosition}, {Subtitle}, {PrimaryTitle}. Use arbitrary prefix/suffix: " "{SeriesPosition}, {Subtitle}, {PrimaryTitle}. Use arbitrary prefix/suffix: "
"{Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. " "{Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. "
@@ -1047,7 +1066,7 @@ def download_settings() -> list[SettingsField]:
key="TEMPLATE_ORGANIZE", key="TEMPLATE_ORGANIZE",
label="Path Template", label="Path Template",
description=( description=(
"Use / to create folders. Variables: {Author}, {Title}, {Year}, {Language}, {User}, " "Use / to create folders. Variables: {Author}, {FirstAuthor} (first of several authors), {Title}, {Year}, {Language}, {User}, "
"{OriginalName} (source filename without extension). Universal adds: {Series}, " "{OriginalName} (source filename without extension). Universal adds: {Series}, "
"{SeriesPosition}, {Subtitle}, {PrimaryTitle}. Use arbitrary prefix/suffix: " "{SeriesPosition}, {Subtitle}, {PrimaryTitle}. Use arbitrary prefix/suffix: "
"{Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty." "{Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty."
@@ -1315,7 +1334,7 @@ def download_settings() -> list[SettingsField]:
key="TEMPLATE_AUDIOBOOK_RENAME", key="TEMPLATE_AUDIOBOOK_RENAME",
label="Naming Template", label="Naming Template",
description=( description=(
"Variables: {Author}, {Title}, {Year}, {Language}, {User}, {OriginalName} " "Variables: {Author}, {FirstAuthor} (first of several authors), {Title}, {Year}, {Language}, {User}, {OriginalName} "
"(source filename without extension), {Series}, {SeriesPosition}, {Subtitle}, " "(source filename without extension), {Series}, {SeriesPosition}, {Subtitle}, "
"{PrimaryTitle}, {PartNumber}. Use arbitrary prefix/suffix: " "{PrimaryTitle}, {PartNumber}. Use arbitrary prefix/suffix: "
"{Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. " "{Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. "
@@ -1335,7 +1354,7 @@ def download_settings() -> list[SettingsField]:
key="TEMPLATE_AUDIOBOOK_ORGANIZE", key="TEMPLATE_AUDIOBOOK_ORGANIZE",
label="Path Template", label="Path Template",
description=( description=(
"Use / to create folders. Variables: {Author}, {Title}, {Year}, {Language}, {User}, " "Use / to create folders. Variables: {Author}, {FirstAuthor} (first of several authors), {Title}, {Year}, {Language}, {User}, "
"{OriginalName} (source filename without extension), {Series}, {SeriesPosition}, " "{OriginalName} (source filename without extension), {Series}, {SeriesPosition}, "
"{Subtitle}, {PrimaryTitle}, {PartNumber}. Use arbitrary prefix/suffix: " "{Subtitle}, {PrimaryTitle}, {PartNumber}. Use arbitrary prefix/suffix: "
"{Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty." "{Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty."
+12 -5
View File
@@ -280,6 +280,7 @@ def register_admin_routes(app: Flask, user_db: UserDB) -> None:
# Handle optional password update # Handle optional password update
password = data.get("password", "") password = data.get("password", "")
password_hash: str | None = None
if password: if password:
if not capabilities["canSetPassword"]: if not capabilities["canSetPassword"]:
return jsonify( return jsonify(
@@ -292,7 +293,7 @@ def register_admin_routes(app: Flask, user_db: UserDB) -> None:
return jsonify( return jsonify(
{"error": f"Password must be at least {MIN_PASSWORD_LENGTH} characters"} {"error": f"Password must be at least {MIN_PASSWORD_LENGTH} characters"}
), 400 ), 400
user_db.update_user(user_id, password_hash=generate_password_hash(password)) password_hash = generate_password_hash(password)
# Update user fields # Update user fields
user_fields = {} user_fields = {}
@@ -358,10 +359,8 @@ def register_admin_routes(app: Flask, user_db: UserDB) -> None:
if field in user_fields and user_fields[field] == user.get(field): if field in user_fields and user_fields[field] == user.get(field):
user_fields.pop(field) user_fields.pop(field)
if user_fields: # Validate per-user settings
user_db.update_user(user_id, **user_fields) validated_settings: dict[str, Any] | None = None
# Update per-user settings
if "settings" in data: if "settings" in data:
if not isinstance(data["settings"], dict): if not isinstance(data["settings"], dict):
return jsonify({"error": "Settings must be an object"}), 400 return jsonify({"error": "Settings must be an object"}), 400
@@ -375,6 +374,14 @@ def register_admin_routes(app: Flask, user_db: UserDB) -> None:
} }
), 400 ), 400
# Apply the writes only once the whole payload has been accepted.
if password_hash is not None:
user_fields["password_hash"] = password_hash
if user_fields:
user_db.update_user(user_id, **user_fields)
if validated_settings is not None:
user_db.set_user_settings(user_id, validated_settings) user_db.set_user_settings(user_id, validated_settings)
# Ensure runtime reads see updated per-user overrides immediately. # Ensure runtime reads see updated per-user overrides immediately.
try: try:
+39
View File
@@ -0,0 +1,39 @@
"""Static API-key authentication backed by the SHELFMARK_API_KEY environment variable.
When ``SHELFMARK_API_KEY`` is set, a request carrying that value as a Bearer token or in
``X-Api-Key`` is treated as an admin for that request only. Both headers are
checked, since a reverse proxy in front of Shelfmark may set its own
``Authorization`` header, which would otherwise shadow an operator-supplied
``X-Api-Key``. A candidate that matches neither is ignored so that bearer
tokens forwarded by reverse proxies keep working. The key is never logged.
"""
from __future__ import annotations
import hmac
from shelfmark.config.env import SHELFMARK_API_KEY
def extract_api_key_candidates(
authorization_header: str | None, api_key_header: str | None
) -> list[str]:
"""Return the non-empty credentials a client presented, Bearer token first."""
candidates: list[str] = []
if authorization_header:
scheme, _, token = authorization_header.strip().partition(" ")
token = token.strip()
if scheme.lower() == "bearer" and token:
candidates.append(token)
if api_key_header:
token = api_key_header.strip()
if token:
candidates.append(token)
return candidates
def matches_api_key(candidate: str) -> bool:
"""Constant-time comparison against the configured key. False when unset."""
if not SHELFMARK_API_KEY or not candidate:
return False
return hmac.compare_digest(candidate.encode("utf-8"), SHELFMARK_API_KEY.encode("utf-8"))
+112
View File
@@ -0,0 +1,112 @@
"""Comparing and trimming author names for release search.
Lives in core because more than one release source needs it: Prowlarr ranks
results on author agreement (#1293), and IRC both trims the name it searches
for and ranks what comes back.
"""
import re
_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_PARTIAL = 1
AUTHOR_UNKNOWN = 2
AUTHOR_MISMATCH = 3
# 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 _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.
Graded, not binary, because the ways of falling short are not equally bad.
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. And
a name that merely says *less* than the one asked for is not evidence of a
different person: "Petrie" contradicts nothing about "David Petrie", while
"Gordon Petrie" does. That gap matters most where a source is searched by
surname alone (#1331) - the filenames such a search is meant to reach are
exactly the ones filed under a bare surname, and ranking them as wrong put
them below every result that named someone else entirely.
"""
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))
if matched >= required:
return AUTHOR_MATCH
# Too little agreement to call it the same person, so the question is whether
# what was offered *disagrees*. A name every token of which fits the wanted
# name is an abbreviation of it; one carrying a token that fits nothing is a
# different name that happens to share a surname.
if all(
any(
_author_tokens_compatible(wanted_token, offered_token) for wanted_token in wanted_tokens
)
for offered_token in offered_tokens
):
return AUTHOR_PARTIAL
return AUTHOR_MISMATCH
def search_surname(author: object) -> str:
"""The one name token worth sending to a source that matches conjunctively.
Given names are where catalogues disagree - "David Petrie" is filed as
"D. Petrie", "Timothy" as "Tim" - so a query carrying one matches nothing on
a source that requires every term to appear. The surname is the token both
spellings share.
Keeps the author's own capitalisation, because the result is posted to a
public channel, and returns "" when no usable token is left so the caller
searches by title alone rather than by noise.
"""
if not isinstance(author, str):
return ""
tokens = [
token
for token in _AUTHOR_TOKEN_PATTERN.findall(author)
if token.lower() not in _AUTHOR_NOISE_TOKENS
]
if not tokens:
return ""
return tokens[-1]
+7 -3
View File
@@ -207,6 +207,7 @@ class DownloadHistoryService:
"content_type": row.get("content_type"), "content_type": row.get("content_type"),
"source": row.get("source"), "source": row.get("source"),
"source_display_name": row.get("source_display_name"), "source_display_name": row.get("source_display_name"),
"downloads": row.get("downloads"),
"status_message": row.get("status_message"), "status_message": row.get("status_message"),
"download_path": DownloadHistoryService._resolve_existing_download_path( "download_path": DownloadHistoryService._resolve_existing_download_path(
row.get("download_path") row.get("download_path")
@@ -272,6 +273,7 @@ class DownloadHistoryService:
size: str | None, size: str | None,
preview: str | None, preview: str | None,
content_type: str | None, content_type: str | None,
downloads: int | None,
origin: str, origin: str,
retry_payload: dict[str, Any] | None = None, retry_payload: dict[str, Any] | None = None,
) -> None: ) -> None:
@@ -307,15 +309,16 @@ class DownloadHistoryService:
title, author, format, size, preview, content_type, title, author, format, size, preview, content_type,
origin, final_status, origin, final_status,
status_message, download_path, retry_payload, status_message, download_path, retry_payload,
queued_at, terminal_at queued_at, terminal_at, downloads
) )
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', NULL, NULL, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', NULL, NULL, ?, ?, ?, ?)
ON CONFLICT(task_id) DO UPDATE SET ON CONFLICT(task_id) DO UPDATE SET
final_status = 'active', final_status = 'active',
status_message = NULL, status_message = NULL,
download_path = NULL, download_path = NULL,
retry_payload = excluded.retry_payload, retry_payload = excluded.retry_payload,
terminal_at = ? terminal_at = ?,
downloads = excluded.downloads
""", """,
( (
normalized_task_id, normalized_task_id,
@@ -334,6 +337,7 @@ class DownloadHistoryService:
normalized_retry_payload, normalized_retry_payload,
recorded_at, recorded_at,
recorded_at, recorded_at,
downloads,
recorded_at, recorded_at,
), ),
) )
+22 -10
View File
@@ -4,6 +4,7 @@ import logging
import sys import sys
from collections.abc import Mapping from collections.abc import Mapping
from logging.handlers import RotatingFileHandler from logging.handlers import RotatingFileHandler
from threading import Lock
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from shelfmark.config.env import ENABLE_LOGGING, LOG_FILE, LOG_LEVEL from shelfmark.config.env import ENABLE_LOGGING, LOG_FILE, LOG_LEVEL
@@ -12,6 +13,10 @@ if TYPE_CHECKING:
from pathlib import Path from pathlib import Path
_file_handlers: dict[Path, RotatingFileHandler] = {}
_file_handlers_lock = Lock()
class CustomLogger(logging.Logger): class CustomLogger(logging.Logger):
"""Custom logger class with additional error_trace method.""" """Custom logger class with additional error_trace method."""
@@ -122,6 +127,22 @@ def _normalize_log_extra(value: object) -> Mapping[str, object] | None:
return None return None
def _get_file_handler(log_file: Path, formatter: logging.Formatter) -> RotatingFileHandler:
"""Return the process-wide rotating handler for a log file."""
with _file_handlers_lock:
handler = _file_handlers.get(log_file)
if handler is None:
log_file.parent.mkdir(parents=True, exist_ok=True)
handler = RotatingFileHandler(
log_file,
maxBytes=10485760, # 10MB
backupCount=5,
)
handler.setFormatter(formatter)
_file_handlers[log_file] = handler
return handler
def setup_logger(name: str, log_file: Path = LOG_FILE) -> CustomLogger: def setup_logger(name: str, log_file: Path = LOG_FILE) -> CustomLogger:
"""Set up and configure a logger instance. """Set up and configure a logger instance.
@@ -163,16 +184,7 @@ def setup_logger(name: str, log_file: Path = LOG_FILE) -> CustomLogger:
# File handler if log file is specified # File handler if log file is specified
try: try:
if ENABLE_LOGGING: if ENABLE_LOGGING:
# Create log directory if it doesn't exist logger.addHandler(_get_file_handler(log_file, formatter))
log_dir = log_file.parent
log_dir.mkdir(parents=True, exist_ok=True)
file_handler = RotatingFileHandler(
log_file,
maxBytes=10485760, # 10MB
backupCount=5,
)
file_handler.setFormatter(formatter)
logger.addHandler(file_handler)
except (OSError, TypeError, ValueError) as e: except (OSError, TypeError, ValueError) as e:
logger.error_trace(f"Failed to create log file: {e}", exc_info=True) logger.error_trace(f"Failed to create log file: {e}", exc_info=True)
+1
View File
@@ -97,6 +97,7 @@ class DownloadTask:
year: str | None = None year: str | None = None
format: str | None = None format: str | None = None
size: str | None = None size: str | None = None
downloads: int | None = None # Download count from source
preview: str | None = None preview: str | None = None
content_type: str | None = None # "book (fiction)", "audiobook", "magazine", etc. content_type: str | None = None # "book (fiction)", "audiobook", "magazine", etc.
source_url: str | None = None # Original release URL used by source-specific handlers source_url: str | None = None # Original release URL used by source-specific handlers
+38 -3
View File
@@ -14,11 +14,12 @@ logger = setup_logger(__name__)
# Known variable tokens, sorted longest-first to avoid partial matches # Known variable tokens, sorted longest-first to avoid partial matches
# e.g., "SeriesPosition" must match before "Series" # e.g., "SeriesPosition" must match before "Series", "FirstAuthor" before "Author"
KNOWN_TOKENS = [ KNOWN_TOKENS = [
"seriesposition", "seriesposition",
"primarytitle", "primarytitle",
"originalname", "originalname",
"firstauthor",
"partnumber", "partnumber",
"language", "language",
"subtitle", "subtitle",
@@ -29,12 +30,22 @@ KNOWN_TOKENS = [
"user", "user",
] ]
# Authors reach naming already joined as "First Author, Second Author, ...".
# {FirstAuthor} keeps only the first entry. A single name written "Last, First"
# is split on the comma too and renders as "Last" -- the source metadata does
# not mark which form it is (see #930).
AUTHOR_LIST_SEPARATOR = re.compile(r"\s*[,;]\s*")
# Match any {...} block for template parsing # Match any {...} block for template parsing
BRACE_PATTERN = re.compile(r"\{([^}]+)\}") BRACE_PATTERN = re.compile(r"\{([^}]+)\}")
# Characters that are invalid in filenames on various filesystems # Characters that are invalid in filenames on various filesystems
INVALID_CHARS = re.compile(r'[\\/:*?"<>|]') INVALID_CHARS = re.compile(r'[\\/:*?"<>|]')
# Runs of whitespace inside a single placeholder's rendered value, e.g. "Conan Doyle"
# -- collapsed to the configured word separator (see `parse_naming_template`).
WHITESPACE_RUN = re.compile(r"\s+")
def _sanitize(name: str | None, max_length: int = 245) -> str: def _sanitize(name: str | None, max_length: int = 245) -> str:
"""Sanitize a string for filesystem use.""" """Sanitize a string for filesystem use."""
@@ -56,6 +67,14 @@ def sanitize_filename(name: str | None, max_length: int = 245) -> str:
sanitize_path_component = sanitize_filename sanitize_path_component = sanitize_filename
def first_author(value: object) -> str:
"""Return the first entry from an author string joined with ',' or ';'."""
text = " ".join(str(value or "").split())
if not text:
return ""
return AUTHOR_LIST_SEPARATOR.split(text, maxsplit=1)[0].strip()
def format_series_position(position: str | float | None) -> str: def format_series_position(position: str | float | None) -> str:
"""Format a series position for naming templates.""" """Format a series position for naming templates."""
if position is None: if position is None:
@@ -143,8 +162,16 @@ def parse_naming_template(
metadata: Mapping[str, str | int | float | None], metadata: Mapping[str, str | int | float | None],
*, *,
allow_path_separators: bool = True, allow_path_separators: bool = True,
word_separator: str = " ",
) -> str: ) -> str:
"""Render a naming template with Shelfmark metadata placeholders.""" """Render a naming template with Shelfmark metadata placeholders.
`word_separator` replaces whitespace *inside* each placeholder's rendered
value (e.g. "Conan Doyle" -> "Conan.Doyle" for a "." separator). It never
touches literal characters typed into the template itself, so a template
like "{Author}.-.{Title}" keeps its own dots regardless of this setting.
The default (" ") leaves values untouched, matching prior behavior.
"""
if not template: if not template:
return "" return ""
@@ -163,6 +190,8 @@ def parse_naming_template(
value = normalized.get(placeholder_name) value = normalized.get(placeholder_name)
if placeholder_name == "seriesposition": if placeholder_name == "seriesposition":
value = format_series_position(value) value = format_series_position(value)
elif placeholder_name == "firstauthor" and not value:
value = first_author(normalized.get("author"))
if value is None: if value is None:
return "" return ""
return str(value).strip() return str(value).strip()
@@ -178,6 +207,8 @@ def parse_naming_template(
if not value: if not value:
return "" return ""
if word_separator != " ":
value = WHITESPACE_RUN.sub(word_separator, value)
if not allow_path_separators: if not allow_path_separators:
value = value.replace("/", "_") value = value.replace("/", "_")
value = sanitize_filename(value) value = sanitize_filename(value)
@@ -243,9 +274,13 @@ def build_library_path(
template: str, template: str,
metadata: Mapping[str, str | int | float | None], metadata: Mapping[str, str | int | float | None],
extension: str | None = None, extension: str | None = None,
*,
word_separator: str = " ",
) -> Path: ) -> Path:
"""Build a final library path from a template and metadata.""" """Build a final library path from a template and metadata."""
relative = parse_naming_template(template, metadata, allow_path_separators=True) relative = parse_naming_template(
template, metadata, allow_path_separators=True, word_separator=word_separator
)
if not relative: if not relative:
# Fallback to title if template produces empty result # Fallback to title if template produces empty result
+1 -1
View File
@@ -109,7 +109,7 @@ def _normalize_return_to(raw_return_to: object) -> str | None:
return None return None
parsed = urlsplit(value) parsed = urlsplit(value)
if parsed.scheme or parsed.netloc: if parsed.scheme or parsed.netloc or "\\" in parsed.path:
return None return None
script_root = request.script_root.rstrip("/") script_root = request.script_root.rstrip("/")
+32 -20
View File
@@ -145,28 +145,36 @@ class BookQueue:
with self._lock: with self._lock:
self._queue_hook = hook self._queue_hook = hook
def update_status(self, book_id: str, status: QueueStatus) -> None: def _apply_status_locked(
"""Update status of a book in the queue.""" self, book_id: str, status: QueueStatus
) -> tuple[Callable[[str, QueueStatus, DownloadTask], None] | None, DownloadTask | None]:
"""Apply a status change; returns the terminal hook to run after releasing the lock."""
hook: Callable[[str, QueueStatus, DownloadTask], None] | None = None hook: Callable[[str, QueueStatus, DownloadTask], None] | None = None
hook_task: DownloadTask | None = None hook_task: DownloadTask | None = None
previous_status = self._status.get(book_id)
self._update_status(book_id, status)
if (
status in TERMINAL_QUEUE_STATUSES
and previous_status != status
and self._terminal_status_hook is not None
):
current_task = self._task_data.get(book_id)
if current_task is not None:
hook = self._terminal_status_hook
hook_task = current_task
# Clean up active download tracking when finished
if status in TERMINAL_QUEUE_STATUSES:
self._active_downloads.pop(book_id, None)
self._cancel_flags.pop(book_id, None)
return hook, hook_task
def update_status(self, book_id: str, status: QueueStatus) -> None:
"""Update status of a book in the queue."""
with self._lock: with self._lock:
previous_status = self._status.get(book_id) hook, hook_task = self._apply_status_locked(book_id, status)
self._update_status(book_id, status)
if (
status in TERMINAL_QUEUE_STATUSES
and previous_status != status
and self._terminal_status_hook is not None
):
current_task = self._task_data.get(book_id)
if current_task is not None:
hook = self._terminal_status_hook
hook_task = current_task
# Clean up active download tracking when finished
if status in TERMINAL_QUEUE_STATUSES:
self._active_downloads.pop(book_id, None)
self._cancel_flags.pop(book_id, None)
if hook is not None and hook_task is not None: if hook is not None and hook_task is not None:
hook(book_id, status, hook_task) hook(book_id, status, hook_task)
@@ -257,7 +265,11 @@ class BookQueue:
# Not in a cancellable state # Not in a cancellable state
return False return False
self.update_status(task_id, QueueStatus.CANCELLED) # Write under the same lock so a download that finishes first is not overwritten
hook, hook_task = self._apply_status_locked(task_id, QueueStatus.CANCELLED)
if hook is not None and hook_task is not None:
hook(task_id, QueueStatus.CANCELLED, hook_task)
return True return True
def set_priority(self, task_id: str, new_priority: int) -> bool: def set_priority(self, task_id: str, new_priority: int) -> bool:
+93
View File
@@ -0,0 +1,93 @@
"""Shared release-search helpers.
Extracted from the ``/api/releases`` route so the same per-source search logic can
be reused outside the HTTP route (for example by background automation) without
going through Flask. Behaviour for the HTTP route is preserved: the route delegates
its inner per-source search to :func:`search_source_releases`.
"""
from __future__ import annotations
import sqlite3
from typing import TYPE_CHECKING
from shelfmark.core.logger import setup_logger
from shelfmark.core.search_plan import build_release_search_plan
if TYPE_CHECKING:
from shelfmark.core.models import SearchFilters
from shelfmark.metadata_providers import BookMetadata
from shelfmark.release_sources import Release, ReleaseSource
logger = setup_logger(__name__)
# Mirror of main._OPERATIONAL_ERRORS so a misbehaving source can't crash a caller.
_OPERATIONAL_ERRORS = (OSError, RuntimeError, TypeError, ValueError, sqlite3.Error)
def search_source_releases(
source_name: str,
search_book: BookMetadata,
*,
languages: list[str] | None = None,
manual_query: str | None = None,
indexers: list[str] | None = None,
expand_search: bool = False,
content_type: str = "ebook",
source_filters: SearchFilters | None = None,
user_id: int | None = None,
) -> tuple[ReleaseSource | None, list[Release], str | None]:
"""Search a single release source, returning any error instead of raising.
Returns ``(source, releases, error_message)``. On failure ``source`` is ``None``
and ``error_message`` describes the problem. ``user_id`` lets the search plan
pick up that user's default languages when no explicit filter is given.
"""
from shelfmark.release_sources import SourceUnavailableError, get_source
try:
source = get_source(source_name)
plan = build_release_search_plan(
search_book,
languages=languages,
manual_query=manual_query,
indexers=indexers,
source_filters=source_filters,
user_id=user_id,
)
if plan.source_filters is not None:
planned_query = plan.manual_query or plan.primary_query
planned_query_type = "query"
elif plan.manual_query:
planned_query = plan.manual_query
planned_query_type = "manual"
elif not expand_search and plan.isbn_candidates:
planned_query = plan.isbn_candidates[0]
planned_query_type = "isbn"
else:
planned_query = plan.primary_query
planned_query_type = "title_author"
logger.debug(
"Searching %s: %s='%s' (title='%s', authors=%s, expand=%s, content_type=%s)",
source_name,
planned_query_type,
planned_query,
search_book.title,
search_book.authors,
expand_search,
content_type,
)
releases = source.search(
search_book, plan, expand_search=expand_search, content_type=content_type
)
except ValueError:
return None, [], f"Unknown source: {source_name}"
except (SourceUnavailableError, *_OPERATIONAL_ERRORS) as exc:
logger.warning("Release search failed for source %s: %s", source_name, exc)
return None, [], f"{source_name}: {exc!s}"
else:
return source, releases, None
+2
View File
@@ -715,6 +715,8 @@ def register_request_routes(
raw_requests = data.get("requests") raw_requests = data.get("requests")
if not isinstance(raw_requests, list) or len(raw_requests) == 0: if not isinstance(raw_requests, list) or len(raw_requests) == 0:
return jsonify({"error": "requests must contain at least one request"}), 400 return jsonify({"error": "requests must contain at least one request"}), 400
if not all(isinstance(raw_request, dict) for raw_request in raw_requests):
return jsonify({"error": "requests must contain objects"}), 400
try: try:
prepared_requests = [ prepared_requests = [
+11 -4
View File
@@ -302,6 +302,7 @@ def register_self_user_routes(app: Flask, user_db: UserDB) -> None:
auth_source = capabilities["authSource"] auth_source = capabilities["authSource"]
password = data.get("password", "") password = data.get("password", "")
password_hash: str | None = None
if password: if password:
if not capabilities["canSetPassword"]: if not capabilities["canSetPassword"]:
return jsonify( return jsonify(
@@ -314,7 +315,7 @@ def register_self_user_routes(app: Flask, user_db: UserDB) -> None:
return jsonify( return jsonify(
{"error": f"Password must be at least {MIN_PASSWORD_LENGTH} characters"} {"error": f"Password must be at least {MIN_PASSWORD_LENGTH} characters"}
), 400 ), 400
user_db.update_user(user_id, password_hash=generate_password_hash(password)) password_hash = generate_password_hash(password)
user_fields: dict[str, Any] = {} user_fields: dict[str, Any] = {}
if "email" in data: if "email" in data:
@@ -363,9 +364,7 @@ def register_self_user_routes(app: Flask, user_db: UserDB) -> None:
if field in user_fields and user_fields[field] == user.get(field): if field in user_fields and user_fields[field] == user.get(field):
user_fields.pop(field) user_fields.pop(field)
if user_fields: validated_settings: dict[str, Any] | None = None
user_db.update_user(user_id, **user_fields)
if "settings" in data: if "settings" in data:
settings_payload = data["settings"] settings_payload = data["settings"]
if not isinstance(settings_payload, dict): if not isinstance(settings_payload, dict):
@@ -397,6 +396,14 @@ def register_self_user_routes(app: Flask, user_db: UserDB) -> None:
} }
), 400 ), 400
# Apply the writes only once the whole payload has been accepted.
if password_hash is not None:
user_fields["password_hash"] = password_hash
if user_fields:
user_db.update_user(user_id, **user_fields)
if validated_settings is not None:
user_db.set_user_settings(user_id, validated_settings) user_db.set_user_settings(user_id, validated_settings)
try: try:
app_config.refresh(force=True) app_config.refresh(force=True)
+30 -1
View File
@@ -87,7 +87,8 @@ CREATE TABLE IF NOT EXISTS download_history (
download_path TEXT, download_path TEXT,
retry_payload TEXT, retry_payload TEXT,
queued_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, queued_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
terminal_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP terminal_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
downloads INTEGER
); );
CREATE INDEX IF NOT EXISTS idx_download_history_user_status CREATE INDEX IF NOT EXISTS idx_download_history_user_status
@@ -203,6 +204,7 @@ class UserDB:
self._migrate_request_delivery_columns(conn) self._migrate_request_delivery_columns(conn)
self._migrate_download_history_queued_at(conn) self._migrate_download_history_queued_at(conn)
self._migrate_download_history_retry_payload(conn) self._migrate_download_history_retry_payload(conn)
self._migrate_download_history_downloads(conn)
conn.commit() conn.commit()
# WAL mode must be changed outside an open transaction. # WAL mode must be changed outside an open transaction.
conn.execute("PRAGMA journal_mode=WAL") conn.execute("PRAGMA journal_mode=WAL")
@@ -270,6 +272,13 @@ class UserDB:
if "retry_payload" not in column_names: if "retry_payload" not in column_names:
conn.execute("ALTER TABLE download_history ADD COLUMN retry_payload TEXT") conn.execute("ALTER TABLE download_history ADD COLUMN retry_payload TEXT")
def _migrate_download_history_downloads(self, conn: sqlite3.Connection) -> None:
"""Ensure download_history.downloads exists for download count persistence."""
columns = conn.execute("PRAGMA table_info(download_history)").fetchall()
column_names = {str(col["name"]) for col in columns}
if "downloads" not in column_names:
conn.execute("ALTER TABLE download_history ADD COLUMN downloads INTEGER")
def create_user( def create_user(
self, self,
username: str, username: str,
@@ -424,6 +433,26 @@ class UserDB:
finally: finally:
conn.close() conn.close()
def get_first_admin(self) -> dict[str, Any] | None:
"""Return the lowest-id admin user, or None. Used as the identity for SHELFMARK_API_KEY requests."""
conn = self._connect()
try:
row = conn.execute(
"SELECT * FROM users WHERE role = 'admin' ORDER BY id LIMIT 1"
).fetchone()
return dict(row) if row else None
finally:
conn.close()
def has_admin(self) -> bool:
"""Return True when at least one admin user exists."""
conn = self._connect()
try:
row = conn.execute("SELECT 1 FROM users WHERE role = 'admin' LIMIT 1").fetchone()
return row is not None
finally:
conn.close()
def has_admin_with_password(self) -> bool: def has_admin_with_password(self) -> bool:
"""Return True when at least one admin user with a password hash exists.""" """Return True when at least one admin user with a password hash exists."""
conn = self._connect() conn = self._connect()
+13
View File
@@ -190,6 +190,8 @@ class DownloadClient(ABC):
# Class attributes that subclasses must define # Class attributes that subclasses must define
protocol: str protocol: str
name: str name: str
handoff_only = False
prefers_torrent_file = False
def _log_error(self, method: str, e: Exception, level: str = "error") -> str: def _log_error(self, method: str, e: Exception, level: str = "error") -> str:
"""Log a client error with consistent formatting. """Log a client error with consistent formatting.
@@ -375,12 +377,14 @@ _CLIENTS: dict[str, list[type[DownloadClient]]] = {}
ClientType = TypeVar("ClientType", bound=DownloadClient) ClientType = TypeVar("ClientType", bound=DownloadClient)
_BUILTIN_CLIENT_MODULES = ( _BUILTIN_CLIENT_MODULES = (
"shelfmark.download.clients.alldebrid", "shelfmark.download.clients.alldebrid",
"shelfmark.download.clients.blackhole",
"shelfmark.download.clients.deluge", "shelfmark.download.clients.deluge",
"shelfmark.download.clients.nzbget", "shelfmark.download.clients.nzbget",
"shelfmark.download.clients.qbittorrent", "shelfmark.download.clients.qbittorrent",
"shelfmark.download.clients.realdebrid", "shelfmark.download.clients.realdebrid",
"shelfmark.download.clients.rtorrent", "shelfmark.download.clients.rtorrent",
"shelfmark.download.clients.sabnzbd", "shelfmark.download.clients.sabnzbd",
"shelfmark.download.clients.torbox",
"shelfmark.download.clients.transmission", "shelfmark.download.clients.transmission",
) )
_builtin_client_state = {"loaded": False} _builtin_client_state = {"loaded": False}
@@ -467,6 +471,15 @@ def list_configured_clients() -> list[str]:
return result return result
def client_prefers_torrent_file(protocol: str) -> bool:
"""Whether the active client needs a fetched .torrent file instead of a magnet."""
_ensure_builtin_clients_registered()
return any(
client_cls.prefers_torrent_file and client_cls.is_configured()
for client_cls in _CLIENTS.get(protocol, [])
)
def get_all_clients() -> dict[str, list[type[DownloadClient]]]: def get_all_clients() -> dict[str, list[type[DownloadClient]]]:
"""Get all registered client classes. """Get all registered client classes.
+30 -3
View File
@@ -24,7 +24,7 @@ from shelfmark.download.clients import (
) )
from shelfmark.download.fs import run_blocking_io from shelfmark.download.fs import run_blocking_io
from shelfmark.download.permissions_debug import log_path_permission_context from shelfmark.download.permissions_debug import log_path_permission_context
from shelfmark.release_sources import DownloadHandler from shelfmark.release_sources import DownloadHandler, HandoffResult
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Callable from collections.abc import Callable
@@ -762,8 +762,8 @@ class ExternalClientHandler(DownloadHandler, ABC):
cancel_flag: Event, cancel_flag: Event,
progress_callback: Callable[[float], None], progress_callback: Callable[[float], None],
status_callback: Callable[[str, str | None], None], status_callback: Callable[[str, str | None], None],
) -> str | None: ) -> str | HandoffResult | None:
"""Execute download via configured torrent/usenet client. Returns file path or None.""" """Execute download via configured torrent/usenet client."""
try: try:
if cancel_flag.is_set(): if cancel_flag.is_set():
status_callback("cancelled", "Cancelled") status_callback("cancelled", "Cancelled")
@@ -871,6 +871,33 @@ class ExternalClientHandler(DownloadHandler, ABC):
logger.info( logger.info(
"Added to %s: %s for '%s'", client.name, download_id, request.release_name "Added to %s: %s for '%s'", client.name, download_id, request.release_name
) )
if getattr(client, "handoff_only", False) is True:
if cancel_flag.is_set():
# The file is published and unpublishing it would race a watcher
# that may already have consumed it, so the handoff stands even
# though the task is cancelled. Say so rather than leaving a bare
# "Cancelled" the user cannot act on.
logger.info(
"Cancelled after handoff to %s; leaving publication in place: %s",
client.name,
download_id,
)
status_callback(
"cancelled",
f"Cancelled, but the torrent was already handed off to {client.name}",
)
return None
# A watcher can consume the publication as soon as add_download returns,
# so the handoff completes here rather than in the poll loop. The
# orchestrator deliberately does not check that this path still exists:
# a consumed publication is indistinguishable from a bogus one, and
# treating it as an error is the failure this avoids (#1345).
progress_callback(100)
self._on_download_complete(task)
return HandoffResult(
path=download_id,
message=f"Torrent file saved to {download_id}",
)
# Poll for progress # Poll for progress
return self._poll_and_complete( return self._poll_and_complete(
+113
View File
@@ -0,0 +1,113 @@
"""Blackhole download client that saves torrent files for an external watcher."""
from __future__ import annotations
import os
import tempfile
from pathlib import Path
from shelfmark.core.config import config
from shelfmark.core.naming import sanitize_filename
from shelfmark.download.clients import (
DownloadClient,
DownloadState,
DownloadStatus,
register_client,
)
from shelfmark.download.clients._coercion import config_text
from shelfmark.download.clients.torrent_utils import extract_torrent_info
@register_client("torrent")
class BlackholeClient(DownloadClient):
"""Write fetched torrent files to a directory watched by another downloader."""
protocol = "torrent"
name = "blackhole"
handoff_only = True
prefers_torrent_file = True
def __init__(self) -> None:
directory = config_text(config.get("BLACKHOLE_DIRECTORY", ""))
if not directory:
msg = "BLACKHOLE_DIRECTORY is required"
raise ValueError(msg)
self._directory = Path(directory)
@staticmethod
def is_configured() -> bool:
return config_text(config.get("PROWLARR_TORRENT_CLIENT", "")) == "blackhole" and bool(
config_text(config.get("BLACKHOLE_DIRECTORY", ""))
)
def test_connection(self) -> tuple[bool, str]:
try:
self._directory.mkdir(parents=True, exist_ok=True)
except OSError as error:
return False, f"Could not create Blackhole directory: {error}"
return True, f"Blackhole directory is ready: {self._directory}"
def add_download(
self,
url: str,
name: str,
category: str | None = None,
expected_hash: str | None = None,
**kwargs: object,
) -> str:
torrent_info = extract_torrent_info(url, expected_hash=expected_hash)
if not torrent_info.torrent_data:
msg = "Blackhole requires a .torrent file; this release only provides a magnet link"
raise ValueError(msg)
self._directory.mkdir(parents=True, exist_ok=True)
filename = f"{sanitize_filename(name) or 'torrent'}.torrent"
destination = self._next_destination(filename)
file_descriptor, temporary_path = tempfile.mkstemp(
dir=self._directory,
prefix=".blackhole-",
suffix=".tmp",
)
try:
with os.fdopen(file_descriptor, "wb") as temporary_file:
temporary_file.write(torrent_info.torrent_data)
temporary_file.flush()
os.fsync(temporary_file.fileno())
Path(temporary_path).replace(destination)
except Exception:
Path(temporary_path).unlink(missing_ok=True)
raise
return str(destination)
def get_status(self, download_id: str) -> DownloadStatus:
file_path = Path(download_id)
if file_path.is_file():
return DownloadStatus(
progress=100,
state=DownloadState.COMPLETE,
message="Torrent file saved",
complete=True,
file_path=str(file_path),
)
return DownloadStatus.error("Blackhole torrent file was not created")
def remove(self, download_id: str, *, delete_files: bool = False) -> bool:
return False
def get_download_path(self, download_id: str) -> str | None:
return download_id if Path(download_id).is_file() else None
def _next_destination(self, filename: str) -> Path:
candidate = self._directory / filename
if not candidate.exists():
return candidate
stem = Path(filename).stem
suffix = Path(filename).suffix
index = 1
while True:
candidate = self._directory / f"{stem}_{index}{suffix}"
if not candidate.exists():
return candidate
index += 1
+6 -3
View File
@@ -292,11 +292,14 @@ class DelugeClient(DownloadClient):
# Per-torrent seeding limits from indexer # Per-torrent seeding limits from indexer
seeding_time_limit = coerce_optional_int(kwargs.get("seeding_time_limit")) seeding_time_limit = coerce_optional_int(kwargs.get("seeding_time_limit"))
if seeding_time_limit is not None: if seeding_time_limit is not None:
options["seed_time_limit"] = seeding_time_limit logger.debug(
"Deluge has no per-torrent seeding time limit, ignoring %s minutes",
seeding_time_limit,
)
ratio_limit = coerce_optional_float(kwargs.get("ratio_limit")) ratio_limit = coerce_optional_float(kwargs.get("ratio_limit"))
if ratio_limit is not None: if ratio_limit is not None:
options["stop_at_ratio"] = ratio_limit options["stop_ratio"] = ratio_limit
options["stop_at_ratio_enabled"] = True options["stop_at_ratio"] = True
if torrent_info.is_magnet: if torrent_info.is_magnet:
magnet_url = torrent_info.magnet_url or url magnet_url = torrent_info.magnet_url or url
+1
View File
@@ -50,6 +50,7 @@ _STATUS_DOWNLOADING = frozenset(
{ {
"magnet_conversion", "magnet_conversion",
"waiting_files_selection", "waiting_files_selection",
"queued",
"downloading", "downloading",
"compressing", "compressing",
"uploading", "uploading",
+41
View File
@@ -565,6 +565,23 @@ def _test_realdebrid_connection(current_values: dict[str, Any] | None = None) ->
return {"success": success, "message": message} return {"success": success, "message": message}
def _test_torbox_connection(current_values: dict[str, Any] | None = None) -> dict[str, Any]:
"""Test the TorBox API connection using current form values."""
from shelfmark.core.config import config
from shelfmark.download.clients.torbox import TorBoxClient
current_values = current_values or {}
api_key = _resolve_string_setting(current_values, config.get, "TORBOX_API_KEY")
if not api_key:
return {"success": False, "message": "TorBox API Key is required"}
client = TorBoxClient()
client._api_key = api_key
success, message = client.test_connection()
return {"success": success, "message": message}
# ==================== Download Clients Tab ==================== # ==================== Download Clients Tab ====================
@@ -590,14 +607,23 @@ def prowlarr_clients_settings() -> list[SettingsField]:
options=[ options=[
{"value": "", "label": "None"}, {"value": "", "label": "None"},
{"value": "alldebrid", "label": "AllDebrid"}, {"value": "alldebrid", "label": "AllDebrid"},
{"value": "blackhole", "label": "Blackhole"},
{"value": "qbittorrent", "label": "qBittorrent"}, {"value": "qbittorrent", "label": "qBittorrent"},
{"value": "realdebrid", "label": "Real-Debrid"}, {"value": "realdebrid", "label": "Real-Debrid"},
{"value": "torbox", "label": "TorBox"},
{"value": "transmission", "label": "Transmission"}, {"value": "transmission", "label": "Transmission"},
{"value": "deluge", "label": "Deluge"}, {"value": "deluge", "label": "Deluge"},
{"value": "rtorrent", "label": "rTorrent"}, {"value": "rtorrent", "label": "rTorrent"},
], ],
default="", default="",
), ),
TextField(
key="BLACKHOLE_DIRECTORY",
label="Blackhole Directory",
description="Directory where Shelfmark saves .torrent files for another downloader",
placeholder="/blackhole",
show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "blackhole"},
),
# --- AllDebrid Settings --- # --- AllDebrid Settings ---
PasswordField( PasswordField(
key="ALLDEBRID_API_KEY", key="ALLDEBRID_API_KEY",
@@ -628,6 +654,21 @@ def prowlarr_clients_settings() -> list[SettingsField]:
callback=_test_realdebrid_connection, callback=_test_realdebrid_connection,
show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "realdebrid"}, show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "realdebrid"},
), ),
# --- TorBox Settings ---
PasswordField(
key="TORBOX_API_KEY",
label="API Key",
description="TorBox API Key from your TorBox account settings",
show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "torbox"},
),
ActionButton(
key="test_torbox",
label="Test Connection",
description="Verify your TorBox configuration",
style="primary",
callback=_test_torbox_connection,
show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "torbox"},
),
# --- qBittorrent Settings --- # --- qBittorrent Settings ---
TextField( TextField(
key="QBITTORRENT_URL", key="QBITTORRENT_URL",
+618
View File
@@ -0,0 +1,618 @@
"""TorBox debrid service client for Shelfmark."""
from __future__ import annotations
import math
import shutil
import threading
from dataclasses import dataclass, field
from pathlib import Path, PurePosixPath, PureWindowsPath
from typing import Any, ClassVar, NoReturn
from urllib.parse import urlparse
import requests
from shelfmark.config.env import TMP_DIR
from shelfmark.core.config import config
from shelfmark.core.logger import setup_logger
from shelfmark.download.clients import (
DownloadClient,
DownloadState,
DownloadStatus,
register_client,
)
from shelfmark.download.clients._coercion import config_text
from shelfmark.download.clients.torrent_utils import (
DebridMagnet,
DebridUpload,
resolve_debrid_upload,
)
from shelfmark.download.http import download_url
from shelfmark.download.network import get_ssl_verify
logger = setup_logger(__name__)
_API_BASE = "https://api.torbox.app/v1/api"
_API_TIMEOUT = 30
_STATUS_TIMEOUT = 15
_WORKER_JOIN_TIMEOUT = 5.0
_BOOK_EXTENSIONS = (
".aac",
".azw",
".azw3",
".cbr",
".cbz",
".djvu",
".doc",
".docx",
".epub",
".fb2",
".flac",
".lit",
".m4a",
".m4b",
".mobi",
".mp3",
".mp4",
".ogg",
".opus",
".pdf",
".rtf",
".txt",
".wma",
)
_TERMINAL_STATES = frozenset({"error", "failed", "missingfiles", "dead"})
_PLAN_NAMES = {0: "Free", 1: "Essential", 2: "Pro", 3: "Standard"}
def _raise_runtime_error(message: str) -> NoReturn:
raise RuntimeError(message)
@dataclass
class _DownloadState:
"""Internal mutable state for an in-progress TorBox download."""
torrent_id: str
name: str
target_dir: Path
phase: str = "waiting_torbox"
error_message: str | None = None
progress: float = 0.0
download_thread: threading.Thread | None = None
cancel_event: threading.Event = field(default_factory=threading.Event)
lock: threading.Lock = field(default_factory=threading.Lock)
@register_client("torrent")
class TorBoxClient(DownloadClient):
"""Download torrent content through TorBox and its CDN."""
protocol = "torrent"
name = "torbox"
prefers_torrent_file = True
_downloads: ClassVar[dict[str, _DownloadState]] = {}
_downloads_lock = threading.Lock()
def __init__(self) -> None:
self._api_key = config_text(config.get("TORBOX_API_KEY", ""))
def _auth_headers(self) -> dict[str, str]:
"""Return the authorization headers used by TorBox API calls."""
return {"Authorization": f"Bearer {self._api_key}"}
@staticmethod
def is_configured() -> bool:
"""Return True when TorBox is selected and an API key exists."""
client = config_text(config.get("PROWLARR_TORRENT_CLIENT", ""))
api_key = config_text(config.get("TORBOX_API_KEY", ""))
return client == "torbox" and bool(api_key)
def test_connection(self) -> tuple[bool, str]:
"""Validate the API key and report the connected TorBox plan."""
if not self._api_key:
return False, "TorBox API Key is required"
try:
user = self._request_data(
"GET",
"/user/me",
operation="account lookup",
params={"settings": "false"},
timeout=_STATUS_TIMEOUT,
)
if not isinstance(user, dict):
_raise_runtime_error("TorBox account lookup returned invalid user data")
except (
OSError,
requests.exceptions.RequestException,
RuntimeError,
TypeError,
ValueError,
) as e:
return False, f"Connection failed: {e}"
plan_value = user.get("plan")
plan = _PLAN_NAMES.get(plan_value, "Unknown") if isinstance(plan_value, int) else "Unknown"
email = user.get("email")
account = f" as '{email}'" if isinstance(email, str) and email else ""
return True, f"Connected to TorBox{account} ({plan} plan)"
def add_download(
self,
url: str,
name: str,
category: str | None = None,
expected_hash: str | None = None,
**kwargs: Any,
) -> str:
"""Send a magnet or torrent file to TorBox and return its torrent ID."""
if not self._api_key:
_raise_runtime_error("TorBox API key is not configured")
try:
upload = resolve_debrid_upload(url, expected_hash=expected_hash)
data = self._send_torrent(upload, name)
torrent_id = self._normalize_torrent_id(data.get("torrent_id"))
target_dir = TMP_DIR / f"torbox_{torrent_id}"
target_dir.mkdir(parents=True, exist_ok=True)
state = _DownloadState(torrent_id=torrent_id, name=name, target_dir=target_dir)
with self._downloads_lock:
self._downloads[torrent_id] = state
logger.info(
"Added torrent to TorBox: ID %s", torrent_id, extra={"torrent_id": torrent_id}
)
except Exception:
logger.exception("Failed to add torrent to TorBox")
raise
else:
return torrent_id
def _send_torrent(self, upload: DebridUpload, name: str) -> dict[str, Any]:
"""Create a TorBox torrent from a magnet link or torrent file."""
endpoint = "/torrents/createtorrent"
data: dict[str, str] = {"name": name}
files: dict[str, tuple[str, bytes, str]] | None = None
if isinstance(upload, DebridMagnet):
data["magnet"] = upload.magnet_url
else:
files = {
"file": (
"release.torrent",
upload.torrent_data,
"application/x-bittorrent",
)
}
result = self._request_data(
"POST",
endpoint,
operation="torrent creation",
data=data,
files=files,
timeout=_API_TIMEOUT,
)
if not isinstance(result, dict):
_raise_runtime_error("TorBox torrent creation returned invalid data")
return result
def get_status(self, download_id: str) -> DownloadStatus:
"""Poll TorBox for torrent status and drive local file retrieval."""
download_id = self._normalize_torrent_id(download_id)
state = self._ensure_state(download_id)
with state.lock:
if state.phase == "error":
return DownloadStatus.error(state.error_message or "TorBox download failed")
if state.phase == "complete":
return DownloadStatus(
progress=100.0,
state=DownloadState.COMPLETE,
message="Complete",
complete=True,
file_path=str(state.target_dir),
)
if state.phase == "downloading_http":
return DownloadStatus(
progress=state.progress,
state=DownloadState.DOWNLOADING,
message="Downloading files via TorBox...",
complete=False,
file_path=None,
)
try:
data = self._request_data(
"GET",
"/torrents/mylist",
operation="torrent status lookup",
params={"id": download_id, "bypass_cache": "true"},
timeout=_STATUS_TIMEOUT,
)
torrent = self._extract_torrent(data, download_id)
return self._handle_torrent_status(torrent, state)
except Exception as e:
logger.exception(
"Failed to check TorBox torrent status",
extra={"torrent_id": download_id},
)
return DownloadStatus.error(f"TorBox status check failed: {e}")
def remove(self, download_id: str, *, delete_files: bool = False) -> bool:
"""Delete the remote torrent and clean up its local temporary directory."""
download_id = self._normalize_torrent_id(download_id)
remote_removed = True
try:
self._request_data(
"POST",
"/torrents/controltorrent",
operation="torrent deletion",
json={"torrent_id": int(download_id), "operation": "delete"},
timeout=_STATUS_TIMEOUT,
require_data=False,
)
except OSError, requests.exceptions.RequestException, RuntimeError, TypeError, ValueError:
remote_removed = False
logger.warning("Failed to delete TorBox torrent", extra={"torrent_id": download_id})
with self._downloads_lock:
state = self._downloads.get(download_id)
if state:
with state.lock:
state.cancel_event.set()
if state.download_thread and state.download_thread is not threading.current_thread():
state.download_thread.join(_WORKER_JOIN_TIMEOUT)
if state.download_thread.is_alive():
logger.warning(
"TorBox retrieval thread did not stop; deferring cleanup",
extra={"torrent_id": download_id},
)
return False
with self._downloads_lock:
state = self._downloads.pop(download_id, None)
target_dir = state.target_dir if state else TMP_DIR / f"torbox_{download_id}"
local_removed = True
if target_dir.exists():
try:
shutil.rmtree(target_dir)
except OSError:
local_removed = False
logger.warning(
"Failed to remove TorBox temporary files",
extra={"torrent_id": download_id},
)
return remote_removed and local_removed
def get_download_path(self, download_id: str) -> str | None:
"""Return the local directory once TorBox files have been retrieved."""
download_id = self._normalize_torrent_id(download_id)
with self._downloads_lock:
state = self._downloads.get(download_id)
if state and state.phase == "complete":
return str(state.target_dir)
return None
def _request_data(
self,
method: str,
endpoint: str,
*,
operation: str,
require_data: bool = True,
**kwargs: object,
) -> Any:
"""Send a TorBox request and validate its JSON response envelope."""
url = f"{_API_BASE}{endpoint}"
request_kwargs: Any = {
"headers": self._auth_headers(),
"verify": get_ssl_verify(url),
**kwargs,
}
request: Any = requests.get if method == "GET" else requests.post
try:
response = request(url, **request_kwargs)
except requests.exceptions.RequestException as e:
raise RuntimeError(f"TorBox {operation} failed: {type(e).__name__}") from None
try:
payload = response.json()
except (AttributeError, TypeError, ValueError) as e:
_raise_runtime_error(f"TorBox {operation} returned invalid JSON: {e}")
if not isinstance(payload, dict):
_raise_runtime_error(f"TorBox {operation} returned an invalid response")
error = payload.get("error")
detail = payload.get("detail")
status_code = getattr(response, "status_code", 200)
if not isinstance(status_code, int) or not 200 <= status_code < 300:
message = detail if isinstance(detail, str) and detail else f"HTTP {status_code}"
code = f" [{error}]" if isinstance(error, str) and error else ""
_raise_runtime_error(f"TorBox {operation} failed{code}: {message}")
if payload.get("success") is not True or error:
message = detail if isinstance(detail, str) and detail else "Unknown TorBox error"
code = f" [{error}]" if isinstance(error, str) and error else ""
_raise_runtime_error(f"TorBox {operation} failed{code}: {message}")
data = payload.get("data")
if require_data and data is None:
_raise_runtime_error(f"TorBox {operation} returned no data")
return data
def _ensure_state(self, download_id: str) -> _DownloadState:
"""Get or create download state for a TorBox torrent ID."""
download_id = self._normalize_torrent_id(download_id)
with self._downloads_lock:
state = self._downloads.get(download_id)
if state is None:
state = _DownloadState(
torrent_id=download_id,
name=f"Download {download_id}",
target_dir=TMP_DIR / f"torbox_{download_id}",
)
self._downloads[download_id] = state
return state
@staticmethod
def _normalize_torrent_id(value: object) -> str:
"""Return a canonical positive decimal TorBox torrent ID."""
torrent_id = str(value) if value is not None else ""
if not torrent_id.isascii() or not torrent_id.isdecimal():
_raise_runtime_error("TorBox returned an invalid torrent ID")
normalized = str(int(torrent_id))
if normalized == "0":
_raise_runtime_error("TorBox returned an invalid torrent ID")
return normalized
@staticmethod
def _extract_torrent(data: Any, download_id: str) -> dict[str, Any]:
"""Extract the requested torrent from TorBox's object or list response."""
if isinstance(data, dict):
return data
if isinstance(data, list):
for torrent in data:
if isinstance(torrent, dict) and str(torrent.get("id", "")) == download_id:
return torrent
_raise_runtime_error(f"TorBox torrent {download_id} was not found")
def _handle_torrent_status(
self,
torrent: dict[str, Any],
state: _DownloadState,
) -> DownloadStatus:
"""Map a TorBox torrent object into Shelfmark download status."""
remote_state = str(torrent.get("download_state", "unknown"))
normalized_state = remote_state.lower()
if normalized_state in _TERMINAL_STATES:
message = torrent.get("tracker_message") or f"TorBox status error: {remote_state}"
self._set_error(state, str(message))
return DownloadStatus.error(str(message))
finished = torrent.get("download_finished") is True
present = torrent.get("download_present") is True
if finished and not present:
message = "TorBox finished processing but the download is unavailable"
self._set_error(state, message)
return DownloadStatus.error(message)
if finished and present:
files = torrent.get("files")
if not isinstance(files, list):
message = "TorBox returned no file list for a completed torrent"
self._set_error(state, message)
return DownloadStatus.error(message)
self._maybe_start_download_thread(state, files)
return DownloadStatus(
progress=50.0,
state=DownloadState.DOWNLOADING,
message="TorBox ready, retrieving files...",
complete=False,
file_path=None,
)
progress = self._normalize_remote_progress(torrent.get("progress")) * 0.5
speed = self._integer_value(torrent.get("download_speed"))
eta = self._integer_value(torrent.get("eta"))
name = torrent.get("name") or state.name
return DownloadStatus(
progress=progress,
state=DownloadState.DOWNLOADING,
message=f"TorBox processing torrent ({name}: {remote_state})",
complete=False,
file_path=None,
download_speed=speed,
eta=eta,
)
@staticmethod
def _normalize_remote_progress(value: object) -> float:
"""Normalize fractional or percentage TorBox progress to 0 through 100."""
if not isinstance(value, int | float | str):
return 0.0
try:
progress = float(value)
except TypeError, ValueError:
return 0.0
if not math.isfinite(progress):
return 0.0
if 0.0 <= progress <= 1.0:
progress *= 100.0
return max(0.0, min(100.0, progress))
@staticmethod
def _integer_value(value: object) -> int | None:
"""Return an integer metric when TorBox provided a numeric value."""
if not isinstance(value, int | float | str):
return None
try:
return int(value)
except TypeError, ValueError:
return None
def _maybe_start_download_thread(
self,
state: _DownloadState,
files: list[dict[str, Any]],
) -> None:
"""Start exactly one background worker to retrieve TorBox files."""
with state.lock:
already_running = state.phase in {"downloading_http", "complete"}
thread_alive = state.download_thread is not None and state.download_thread.is_alive()
if already_running or thread_alive:
return
state.phase = "downloading_http"
state.progress = 50.0
state.download_thread = threading.Thread(
target=self._process_and_download,
args=(state, files),
daemon=True,
)
state.download_thread.start()
def _process_and_download(self, state: _DownloadState, files: list[dict[str, Any]]) -> None:
"""Request direct file links from TorBox and download supported content."""
try:
if state.cancel_event.is_set():
return
relevant = [
file_info
for file_info in files
if self._file_name(file_info).lower().endswith(_BOOK_EXTENSIONS)
]
if not relevant:
_raise_runtime_error("TorBox torrent contains no supported book or audiobook files")
with state.lock:
if state.cancel_event.is_set():
return
state.target_dir.mkdir(parents=True, exist_ok=True)
for index, file_info in enumerate(relevant, start=1):
if state.cancel_event.is_set():
return
file_id = self._file_id(file_info)
relative_path = self._safe_relative_path(file_info, state.target_dir)
direct_url = self._request_download_link(state.torrent_id, file_id)
buffer = download_url(
direct_url,
referer="https://torbox.app/",
cancel_flag=state.cancel_event,
)
if state.cancel_event.is_set():
return
if not buffer:
_raise_runtime_error(
f"TorBox file download failed for torrent {state.torrent_id}, file {file_id}"
)
destination = state.target_dir / relative_path
destination.parent.mkdir(parents=True, exist_ok=True)
with destination.open("wb") as output:
buffer.seek(0)
shutil.copyfileobj(buffer, output)
with state.lock:
state.progress = 50.0 + index / len(relevant) * 50.0
with state.lock:
if state.cancel_event.is_set():
return
state.phase = "complete"
state.progress = 100.0
logger.info(
"TorBox download complete: ID %s",
state.torrent_id,
extra={"torrent_id": state.torrent_id},
)
except Exception as e:
if state.cancel_event.is_set():
logger.info(
"TorBox file retrieval cancelled",
extra={"torrent_id": state.torrent_id},
)
return
logger.exception(
"TorBox file retrieval failed",
extra={"torrent_id": state.torrent_id},
)
self._set_error(state, str(e) or "TorBox file retrieval failed")
def _request_download_link(self, torrent_id: str, file_id: int) -> str:
"""Request a temporary direct link without exposing the token in messages."""
data = self._request_data(
"GET",
"/torrents/requestdl",
operation="file-link request",
params={
"token": self._api_key,
"torrent_id": torrent_id,
"file_id": file_id,
"redirect": "false",
"append_name": "true",
},
timeout=_API_TIMEOUT,
)
if not isinstance(data, str):
_raise_runtime_error(
f"TorBox returned an invalid download link for torrent {torrent_id}, file {file_id}"
)
parsed = urlparse(data)
if parsed.scheme != "https" or not parsed.hostname:
_raise_runtime_error(
f"TorBox returned an invalid download link for torrent {torrent_id}, file {file_id}"
)
return data
@staticmethod
def _file_name(file_info: dict[str, Any]) -> str:
"""Return the provider path, falling back to its shortened name."""
name = file_info.get("name")
if isinstance(name, str) and name.strip():
return name
short_name = file_info.get("short_name")
return short_name.strip() if isinstance(short_name, str) else ""
@staticmethod
def _file_id(file_info: dict[str, Any]) -> int:
"""Return a validated TorBox file ID."""
try:
return int(file_info["id"])
except (KeyError, TypeError, ValueError) as e:
_raise_runtime_error(f"TorBox returned an invalid file ID: {e}")
@classmethod
def _safe_relative_path(cls, file_info: dict[str, Any], target_dir: Path) -> Path:
"""Validate external file metadata before writing below ``target_dir``."""
name = cls._file_name(file_info)
if not name:
_raise_runtime_error("TorBox returned a file without a name")
normalized = name.replace("\\", "/")
relative_path = PurePosixPath(normalized)
windows_path = PureWindowsPath(name)
if (
relative_path.is_absolute()
or windows_path.is_absolute()
or windows_path.drive
or ".." in relative_path.parts
):
_raise_runtime_error(f"TorBox returned an unsafe file path: {name}")
if relative_path == PurePosixPath("."):
_raise_runtime_error("TorBox returned a file without a usable name")
destination = (target_dir / Path(*relative_path.parts)).resolve()
try:
destination.relative_to(target_dir.resolve())
except ValueError:
_raise_runtime_error(f"TorBox returned an unsafe file path: {name}")
return Path(*relative_path.parts)
@staticmethod
def _set_error(state: _DownloadState, message: str) -> None:
"""Record a terminal local error for later polling calls."""
with state.lock:
state.phase = "error"
state.error_message = message
+33 -8
View File
@@ -9,7 +9,7 @@ import time
from binascii import Error as BinasciiError from binascii import Error as BinasciiError
from dataclasses import dataclass from dataclasses import dataclass
from threading import Lock from threading import Lock
from urllib.parse import ParseResult, parse_qs, urljoin, urlparse from urllib.parse import ParseResult, parse_qs, urljoin, urlparse, urlunparse
import requests import requests
@@ -51,6 +51,28 @@ _torrent_fetch_cache: dict[str, tuple[float, TorrentInfo]] = {}
type BencodeValue = dict[str | bytes, BencodeValue] | list[BencodeValue] | int | bytes | str type BencodeValue = dict[str | bytes, BencodeValue] | list[BencodeValue] | int | bytes | str
def _safe_url(url: str, *, limit: int = 120) -> str:
"""Return a log-safe URL: scheme/host/path kept, query and fragment dropped.
Torrent download URLs commonly carry credentials in their query string
(Prowlarr's ``apikey=...`` proxy links among them), so raw URLs must never
reach logs or exception messages.
"""
parsed = urlparse(url)
if not parsed.scheme or not parsed.netloc:
return f"<unparsed url: {type(url).__name__}>"
safe = urlunparse(parsed._replace(query="", fragment=""))
return safe[:limit]
_URL_IN_TEXT_PATTERN = re.compile(r"https?://\S+")
def _redact_urls_in_text(text: str) -> str:
"""Scrub credential-bearing URLs out of free-form text such as exception messages."""
return _URL_IN_TEXT_PATTERN.sub(lambda match: _safe_url(match.group(0)), text)
@dataclass @dataclass
class TorrentInfo: class TorrentInfo:
"""Parsed information from a torrent URL.""" """Parsed information from a torrent URL."""
@@ -133,7 +155,7 @@ def resolve_debrid_upload(url: str, *, expected_hash: str | None = None) -> Debr
return DebridMagnet(magnet_url=f"magnet:?xt=urn:btih:{info.info_hash}") return DebridMagnet(magnet_url=f"magnet:?xt=urn:btih:{info.info_hash}")
reason = info.fetch_error or "no magnet link, info hash, or torrent file was available" reason = info.fetch_error or "no magnet link, info hash, or torrent file was available"
msg = f"Could not resolve a torrent to send from {url[:120]} ({reason})" msg = f"Could not resolve a torrent to send from {_safe_url(url)} ({reason})"
raise ValueError(msg) raise ValueError(msg)
@@ -185,7 +207,7 @@ def _get_cached_torrent_fetch(url: str) -> TorrentInfo | None:
if time.monotonic() - fetched_at > _TORRENT_FETCH_CACHE_TTL_SECONDS: if time.monotonic() - fetched_at > _TORRENT_FETCH_CACHE_TTL_SECONDS:
del _torrent_fetch_cache[url] del _torrent_fetch_cache[url]
return None return None
logger.debug("Reusing recently fetched torrent data for: %s...", url[:80]) logger.debug("Reusing recently fetched torrent data for: %s...", _safe_url(url))
return info return info
@@ -231,7 +253,7 @@ def _fetch_torrent_info(url: str) -> TorrentInfo:
return urljoin(current, location) return urljoin(current, location)
try: try:
logger.debug("Fetching torrent file from: %s...", url[:80]) logger.debug("Fetching torrent file from: %s...", _safe_url(url))
# Redirects are followed manually: some indexers redirect download URLs # Redirects are followed manually: some indexers redirect download URLs
# to magnet links, and each hop must decide anew whether it may see the # to magnet links, and each hop must decide anew whether it may see the
@@ -264,7 +286,7 @@ def _fetch_torrent_info(url: str) -> TorrentInfo:
magnet_url=redirect_url, magnet_url=redirect_url,
) )
if redirects_remaining <= 0: if redirects_remaining <= 0:
logger.warning("Too many redirects fetching torrent file: %s...", url[:80]) logger.warning("Too many redirects fetching torrent file: %s...", _safe_url(url))
return TorrentInfo( return TorrentInfo(
info_hash=None, info_hash=None,
torrent_data=None, torrent_data=None,
@@ -272,7 +294,7 @@ def _fetch_torrent_info(url: str) -> TorrentInfo:
fetch_error="too many redirects", fetch_error="too many redirects",
) )
redirects_remaining -= 1 redirects_remaining -= 1
logger.debug("Following redirect to: %s...", redirect_url[:80]) logger.debug("Following redirect to: %s...", _safe_url(redirect_url))
current_url = redirect_url current_url = redirect_url
resp.raise_for_status() resp.raise_for_status()
@@ -298,8 +320,11 @@ def _fetch_torrent_info(url: str) -> TorrentInfo:
logger.warning("Could not extract hash from torrent file") logger.warning("Could not extract hash from torrent file")
return TorrentInfo(info_hash=info_hash, torrent_data=torrent_data, is_magnet=False) return TorrentInfo(info_hash=info_hash, torrent_data=torrent_data, is_magnet=False)
except _TORRENT_FETCH_ERRORS as e: except _TORRENT_FETCH_ERRORS as e:
logger.warning("Could not fetch torrent file: %s", e) # Exception messages can repeat the source or redirect URL, including its
return TorrentInfo(info_hash=None, torrent_data=None, is_magnet=False, fetch_error=str(e)) # credentials; scrub them before logging or storing the reason.
message = _redact_urls_in_text(str(e))
logger.warning("Could not fetch torrent file: %s: %s", type(e).__name__, message)
return TorrentInfo(info_hash=None, torrent_data=None, is_magnet=False, fetch_error=message)
def _is_trusted_torrent_fetch_url(url: str) -> bool: def _is_trusted_torrent_fetch_url(url: str) -> bool:
+29 -17
View File
@@ -12,6 +12,7 @@ from tqdm import tqdm
from shelfmark.bypass import BypassCancelledError, ChallengeNotSolvedError, cookie_store from shelfmark.bypass import BypassCancelledError, ChallengeNotSolvedError, cookie_store
from shelfmark.bypass.challenge import challenge_marker from shelfmark.bypass.challenge import challenge_marker
from shelfmark.bypass.waiting_room import WaitingRoomTimeoutError, is_aa_waiting_room
from shelfmark.core import search_deadline from shelfmark.core import search_deadline
from shelfmark.core.config import config as app_config from shelfmark.core.config import config as app_config
from shelfmark.core.logger import setup_logger from shelfmark.core.logger import setup_logger
@@ -325,10 +326,10 @@ def _try_rotation(
) )
if action in ("mirror", "dns") and new_base: if action in ("mirror", "dns") and new_base:
new_url = selector.rewrite(original_url) new_url = selector.rewrite(original_url)
logger.info("[%s] switching to: %s", action, new_url) logger.info("[%s] switching mirror", action)
return new_url return new_url
elif network.should_rotate_dns_for_url(current_url) and network.rotate_dns_provider(): elif network.should_rotate_dns_for_url(current_url) and network.rotate_dns_provider():
logger.info("[dns-rotate] retrying: %s", original_url) logger.info("[dns-rotate] retrying download")
return original_url return original_url
return None return None
@@ -440,6 +441,9 @@ def html_get_page(
except _STATUS_CALLBACK_ERRORS: except _STATUS_CALLBACK_ERRORS:
logger.debug("Rate-limit status callback failed", exc_info=True) logger.debug("Rate-limit status callback failed", exc_info=True)
return _fail(str(e), bypass_url) return _fail(str(e), bypass_url)
except WaitingRoomTimeoutError as e:
logger.info("Waiting room timed out: %s", e)
return _fail(str(e), bypass_url)
except ChallengeNotSolvedError as e: except ChallengeNotSolvedError as e:
# Not a bypasser malfunction: it ran, and the host answered with something it # Not a bypasser malfunction: it ran, and the host answered with something it
# cannot clear - DDoS-Guard's manual CAPTCHA, typically. Must precede the # cannot clear - DDoS-Guard's manual CAPTCHA, typically. Must precede the
@@ -705,6 +709,14 @@ def html_get_page(
continue continue
response.raise_for_status() response.raise_for_status()
if (
_bypass_handoff_allowed()
and not _is_using_external_bypasser()
and is_aa_waiting_room(current_url, response.text)
):
# A successful HTTP response can still need a live browser: the
# queue's JavaScript must finish in the session that entered it.
return _run_bypasser(current_url)
if success_delay > 0: if success_delay > 0:
time.sleep(success_delay) time.sleep(success_delay)
return _result(response.text, response.url) return _result(response.text, response.url)
@@ -868,12 +880,7 @@ def download_url(
f"Connecting (Attempt {attempt + 1}/{MAX_DOWNLOAD_RETRIES})", f"Connecting (Attempt {attempt + 1}/{MAX_DOWNLOAD_RETRIES})",
) )
logger.info( logger.info("Downloading (attempt %s/%s)", attempt + 1, MAX_DOWNLOAD_RETRIES)
"Downloading: %s (attempt %s/%s)",
current_url,
attempt + 1,
MAX_DOWNLOAD_RETRIES,
)
# Try with CF cookies/UA if available # Try with CF cookies/UA if available
cookies = _apply_cf_bypass(current_url, headers) cookies = _apply_cf_bypass(current_url, headers)
response = requests.get( response = requests.get(
@@ -911,7 +918,7 @@ def download_url(
and bytes_downloaded < total_size * 0.9 and bytes_downloaded < total_size * 0.9
and response.headers.get("content-type", "").startswith("text/html") and response.headers.get("content-type", "").startswith("text/html")
): ):
logger.warning("Received HTML instead of file: %s", current_url) logger.warning("Received HTML instead of file")
return None return None
logger.debug("Download completed: %s bytes", bytes_downloaded) logger.debug("Download completed: %s bytes", bytes_downloaded)
@@ -929,18 +936,21 @@ def download_url(
parsed = urlparse(current_url) parsed = urlparse(current_url)
if _is_configured_zlib_host(parsed.hostname) and referer: if _is_configured_zlib_host(parsed.hostname) and referer:
zlib_cookie_refresh_attempted = True zlib_cookie_refresh_attempted = True
logger.info("Z-Library 403 - refreshing cookies via referer: %s", referer) logger.info("Z-Library 403 - refreshing cookies via referer")
try: try:
get_bypassed_page(referer, selector, cancel_flag) get_bypassed_page(referer, selector, cancel_flag)
time.sleep(0.5) time.sleep(0.5)
# Retry with fresh cookies (don't increment attempt) # Retry with fresh cookies (don't increment attempt)
continue continue
except _BYPASSER_ERRORS as cookie_err: except _BYPASSER_ERRORS as cookie_err:
logger.warning("Z-Library cookie refresh failed: %s", cookie_err) logger.warning(
"Z-Library cookie refresh failed: %s",
type(cookie_err).__name__,
)
# Non-retryable errors # Non-retryable errors
if status in _HTTP_STATUS_NON_RETRYABLE: if status in _HTTP_STATUS_NON_RETRYABLE:
logger.warning("Download failed (%s): %s", status, current_url) logger.warning("Download failed (%s)", status)
return None return None
# Rate limited - skip to next source immediately # Rate limited - skip to next source immediately
@@ -954,7 +964,7 @@ def download_url(
# Timeout - don't retry, server likely overloaded # Timeout - don't retry, server likely overloaded
if isinstance(e, requests.exceptions.Timeout): if isinstance(e, requests.exceptions.Timeout):
logger.warning("Timeout: %s - skipping to next source", current_url) logger.warning("Timeout - skipping to next source")
if status_callback: if status_callback:
status_callback("resolving", "Server timed out, trying next") status_callback("resolving", "Server timed out, trying next")
return None return None
@@ -981,14 +991,14 @@ def download_url(
attempt += 1 attempt += 1
continue continue
logger.warning("Download error: %s: %s", type(e).__name__, e) logger.warning("Download error: %s", type(e).__name__)
if attempt < MAX_DOWNLOAD_RETRIES - 1: if attempt < MAX_DOWNLOAD_RETRIES - 1:
time.sleep(_backoff_delay(attempt + 1)) time.sleep(_backoff_delay(attempt + 1))
attempt += 1 attempt += 1
else: else:
return buffer return buffer
logger.error("Download failed after %s attempts: %s", MAX_DOWNLOAD_RETRIES, link) logger.error("Download failed after %s attempts", MAX_DOWNLOAD_RETRIES)
return None return None
@@ -1078,7 +1088,7 @@ def _try_resume(
logger.info("Resume completed: %s bytes", start_byte) logger.info("Resume completed: %s bytes", start_byte)
except requests.exceptions.RequestException as e: except requests.exceptions.RequestException as e:
logger.debug("Resume attempt %s failed: %s", attempt + 1, e) logger.debug("Resume attempt %s failed: %s", attempt + 1, type(e).__name__)
else: else:
return buffer return buffer
@@ -1094,5 +1104,7 @@ def get_absolute_url(base_url: str, url: str) -> str:
parsed = urlparse(url) parsed = urlparse(url)
base = urlparse(base_url) base = urlparse(base_url)
if not parsed.netloc or not parsed.scheme: if not parsed.netloc or not parsed.scheme:
parsed = parsed._replace(netloc=base.netloc, scheme=base.scheme) parsed = parsed._replace(
netloc=parsed.netloc or base.netloc, scheme=parsed.scheme or base.scheme
)
return parsed.geturl() return parsed.geturl()
+19 -1
View File
@@ -29,6 +29,7 @@ from shelfmark.download.fs import run_blocking_io
from shelfmark.download.postprocess.pipeline import is_torrent_source, safe_cleanup_path from shelfmark.download.postprocess.pipeline import is_torrent_source, safe_cleanup_path
from shelfmark.download.postprocess.router import post_process_download from shelfmark.download.postprocess.router import post_process_download
from shelfmark.release_sources import ( from shelfmark.release_sources import (
HandoffResult,
get_handler, get_handler,
get_source, get_source,
get_source_display_name, get_source_display_name,
@@ -295,6 +296,7 @@ def queue_release(
year=year, year=year,
format=release_data.get("format"), format=release_data.get("format"),
size=release_data.get("size"), size=release_data.get("size"),
downloads=release_data.get("downloads") or extra.get("downloads"),
preview=preview, preview=preview,
content_type=content_type, content_type=content_type,
source_url=source_url, source_url=source_url,
@@ -318,7 +320,14 @@ def queue_release(
logger.info("Release already in queue: %s", task.title) logger.info("Release already in queue: %s", task.title)
return False, "Release is already in the download queue" return False, "Release is already in the download queue"
logger.info("Release queued with priority %s: %s", priority, task.title) logger.info(
"Release queued with priority %s: %s (downloads=%s, release_data.downloads=%s, extra=%s)",
priority,
task.title,
task.downloads,
release_data.get("downloads"),
extra,
)
# Broadcast status update via WebSocket # Broadcast status update via WebSocket
if ws_manager: if ws_manager:
@@ -520,6 +529,7 @@ def _restore_task_from_retry_payload(payload: object) -> DownloadTask | None:
year=normalize_optional_text(payload.get("year")), year=normalize_optional_text(payload.get("year")),
format=normalize_optional_text(payload.get("format")), format=normalize_optional_text(payload.get("format")),
size=normalize_optional_text(payload.get("size")), size=normalize_optional_text(payload.get("size")),
downloads=int(payload["downloads"]) if payload.get("downloads") is not None else None,
preview=normalize_optional_text(payload.get("preview")), preview=normalize_optional_text(payload.get("preview")),
content_type=normalize_optional_text(payload.get("content_type")), content_type=normalize_optional_text(payload.get("content_type")),
source_url=normalize_optional_text(payload.get("source_url")), source_url=normalize_optional_text(payload.get("source_url")),
@@ -616,6 +626,7 @@ def _task_to_dict(
"author": task.author, "author": task.author,
"format": task.format, "format": task.format,
"size": task.size, "size": task.size,
"downloads": task.downloads,
"preview": preview, "preview": preview,
"content_type": task.content_type, "content_type": task.content_type,
"source": task.source, "source": task.source,
@@ -750,6 +761,13 @@ def _download_task(task_id: str, cancel_flag: Event) -> str | None:
if not temp_path: if not temp_path:
return None return None
if isinstance(temp_path, HandoffResult):
handoff_path = Path(temp_path.path)
status_callback("complete", temp_path.message)
handler.post_process_cleanup(task, success=True)
_clear_task_error_state(task)
return str(handoff_path)
temp_file = Path(temp_path) temp_file = Path(temp_path)
if not run_blocking_io(temp_file.exists): if not run_blocking_io(temp_file.exists):
logger.error("Handler returned non-existent path: %s", temp_path) logger.error("Handler returned non-existent path: %s", temp_path)
+21 -2
View File
@@ -414,10 +414,29 @@ def match_plan_to_files(
) )
unmatched = [p for p in book_files if p not in claimed] unmatched = [p for p in book_files if p not in claimed]
if unmatched: still_unmatched: list[Path] = []
for p in unmatched:
p_ext = p.suffix.lower().lstrip(".")
is_chaptered = p_ext in _CHAPTERED_AUDIO_EXTENSIONS
matching_groups = [g for g in groups if any(f.parent == p.parent for f in g.files)]
target_group: BookGroup | None = None
if len(matching_groups) == 1 and is_chaptered:
target_group = matching_groups[0]
elif len(plan) == 1 and len(groups) == 1 and is_chaptered:
target_group = groups[0]
if target_group is not None:
target_group.files.append(p)
claimed.add(p)
else:
still_unmatched.append(p)
if still_unmatched:
groups.extend( groups.extend(
group_files_into_books( group_files_into_books(
unmatched, series_name=series_name, author_name=author_name, root=root still_unmatched, series_name=series_name, author_name=author_name, root=root
) )
) )
return groups return groups
+11
View File
@@ -55,6 +55,17 @@ def get_file_organization(*, is_audiobook: bool) -> str:
return mode if mode in ("none", "rename", "rename_and_group", "organize") else "rename" return mode if mode in ("none", "rename", "rename_and_group", "organize") else "rename"
def get_word_separator() -> str:
"""Get the configured word separator for naming template values.
Replaces whitespace inside each placeholder's rendered value (e.g. "Conan
Doyle" -> "Conan.Doyle"). The setting holds the separator character
directly (e.g. "." or "_"); empty means a plain space, which leaves
values unchanged.
"""
return _config_text(core_config.config.get("NAMING_WORD_SEPARATOR", "")) or " "
def get_template(*, is_audiobook: bool, organization_mode: str) -> str: def get_template(*, is_audiobook: bool, organization_mode: str) -> str:
"""Get the template for the content type and organization mode.""" """Get the template for the content type and organization mode."""
if is_audiobook: if is_audiobook:
+3 -1
View File
@@ -187,7 +187,9 @@ def scan_directory_tree(
file_path = Path(root) / filename file_path = Path(root) / filename
suffix = file_path.suffix.lower() suffix = file_path.suffix.lower()
if suffix in supported_exts: # zip/rar can also be enabled as supported formats. Keep archives
# out of book_files so they are extracted rather than imported whole.
if suffix in supported_exts and not is_archive(file_path):
book_files.append(file_path) book_files.append(file_path)
elif suffix in trackable_exts: elif suffix in trackable_exts:
rejected_files.append(file_path) rejected_files.append(file_path)
+25 -4
View File
@@ -25,7 +25,11 @@ from shelfmark.download.fs import (
atomic_move, atomic_move,
run_blocking_io, run_blocking_io,
) )
from shelfmark.download.postprocess.policy import get_file_organization, get_template from shelfmark.download.postprocess.policy import (
get_file_organization,
get_template,
get_word_separator,
)
from .packs import BookGroup, PackBook, group_files_into_books, match_plan_to_files from .packs import BookGroup, PackBook, group_files_into_books, match_plan_to_files
from .scan import collect_directory_files, scan_directory_tree from .scan import collect_directory_files, scan_directory_tree
@@ -198,6 +202,7 @@ def transfer_book_files(
is_audiobook = check_audiobook(task.content_type) is_audiobook = check_audiobook(task.content_type)
organization_mode = organization_mode or get_file_organization(is_audiobook=is_audiobook) organization_mode = organization_mode or get_file_organization(is_audiobook=is_audiobook)
word_separator = get_word_separator()
groups = resolve_book_groups(task, book_files, organization_mode=organization_mode) groups = resolve_book_groups(task, book_files, organization_mode=organization_mode)
if groups is not None: if groups is not None:
@@ -229,6 +234,7 @@ def transfer_book_files(
template, template,
file_metadata, file_metadata,
extension=ext or None, extension=ext or None,
word_separator=word_separator,
) )
run_blocking_io(dest_path.parent.mkdir, parents=True, exist_ok=True) run_blocking_io(dest_path.parent.mkdir, parents=True, exist_ok=True)
@@ -256,6 +262,7 @@ def transfer_book_files(
template, template,
file_metadata, file_metadata,
extension=ext or None, extension=ext or None,
word_separator=word_separator,
) )
run_blocking_io(dest_path.parent.mkdir, parents=True, exist_ok=True) run_blocking_io(dest_path.parent.mkdir, parents=True, exist_ok=True)
@@ -289,7 +296,9 @@ def transfer_book_files(
metadata = build_file_metadata(task, book_file) metadata = build_file_metadata(task, book_file)
extension = book_file.suffix.lstrip(".") or task.format or "" extension = book_file.suffix.lstrip(".") or task.format or ""
filename = parse_naming_template(template, metadata, allow_path_separators=False) filename = parse_naming_template(
template, metadata, allow_path_separators=False, word_separator=word_separator
)
filename = Path(filename).name if filename else "" filename = Path(filename).name if filename else ""
if filename and extension: if filename and extension:
filename = f"{sanitize_filename(filename)}.{extension}" filename = f"{sanitize_filename(filename)}.{extension}"
@@ -483,7 +492,12 @@ def transfer_file_to_library(
template_metadata = dict(metadata) template_metadata = dict(metadata)
template_metadata.setdefault("OriginalName", source_path.stem) template_metadata.setdefault("OriginalName", source_path.stem)
dest_path = run_blocking_io( dest_path = run_blocking_io(
build_library_path, library_base, template, template_metadata, extension build_library_path,
library_base,
template,
template_metadata,
extension,
word_separator=get_word_separator(),
) )
run_blocking_io(dest_path.parent.mkdir, parents=True, exist_ok=True) run_blocking_io(dest_path.parent.mkdir, parents=True, exist_ok=True)
@@ -538,12 +552,14 @@ def transfer_directory_to_library(
safe_cleanup_path(temp_file, task) safe_cleanup_path(temp_file, task)
return None return None
word_separator = get_word_separator()
base_library_path = run_blocking_io( base_library_path = run_blocking_io(
build_library_path, build_library_path,
library_base, library_base,
template, template,
metadata, metadata,
extension=None, extension=None,
word_separator=word_separator,
) )
run_blocking_io(base_library_path.parent.mkdir, parents=True, exist_ok=True) run_blocking_io(base_library_path.parent.mkdir, parents=True, exist_ok=True)
@@ -574,7 +590,12 @@ def transfer_directory_to_library(
ext = source_file.suffix.lstrip(".") ext = source_file.suffix.lstrip(".")
file_metadata = {**metadata, "PartNumber": part_number} file_metadata = {**metadata, "PartNumber": part_number}
file_path = run_blocking_io( file_path = run_blocking_io(
build_library_path, library_base, template, file_metadata, extension=ext build_library_path,
library_base,
template,
file_metadata,
extension=ext,
word_separator=word_separator,
) )
run_blocking_io(file_path.parent.mkdir, parents=True, exist_ok=True) run_blocking_io(file_path.parent.mkdir, parents=True, exist_ok=True)
+1 -1
View File
@@ -114,7 +114,7 @@ def run_warmup() -> bool:
from shelfmark.core.models import SearchFilters from shelfmark.core.models import SearchFilters
from shelfmark.release_sources.direct_download import search_books from shelfmark.release_sources.direct_download import search_books
results = search_books(query, SearchFilters()) results, _ = search_books(query, SearchFilters())
except Exception: except Exception:
# Broad by design: a warm-up must never take the app down, and the source # Broad by design: a warm-up must never take the app down, and the source
# raises everything from network errors to parse failures. # raises everything from network errors to parse failures.
+130 -57
View File
@@ -14,7 +14,7 @@ from importlib import import_module
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Any, Callable, NoReturn, cast from typing import TYPE_CHECKING, Any, Callable, NoReturn, cast
from flask import Flask, jsonify, request, send_file, send_from_directory, session from flask import Flask, g, jsonify, request, send_file, send_from_directory, session
from flask_cors import CORS from flask_cors import CORS
from flask_socketio import SocketIO, emit from flask_socketio import SocketIO, emit
from werkzeug.middleware.proxy_fix import ProxyFix from werkzeug.middleware.proxy_fix import ProxyFix
@@ -42,6 +42,7 @@ from shelfmark.config.settings import (
_SUPPORTED_BOOK_LANGUAGE, _SUPPORTED_BOOK_LANGUAGE,
migrate_audiobook_format_settings, migrate_audiobook_format_settings,
) )
from shelfmark.core import api_key as api_key_module # module access lets tests monkeypatch the key
from shelfmark.core import search_deadline from shelfmark.core import search_deadline
from shelfmark.core.activity_view_state_service import ActivityViewStateService from shelfmark.core.activity_view_state_service import ActivityViewStateService
from shelfmark.core.auth_modes import ( from shelfmark.core.auth_modes import (
@@ -398,7 +399,7 @@ def _resolve_policy_mode_for_current_user(*, source: Any, content_type: Any) ->
auth_mode = get_auth_mode() auth_mode = get_auth_mode()
if auth_mode == "none": if auth_mode == "none":
return None return None
if session.get("is_admin", True): if session.get("is_admin", False):
return None return None
if user_db is None: if user_db is None:
return None return None
@@ -550,7 +551,7 @@ if _is_debug_enabled():
r"/*": { r"/*": {
"origins": ["http://localhost:5173", "http://127.0.0.1:5173"], "origins": ["http://localhost:5173", "http://127.0.0.1:5173"],
"supports_credentials": True, "supports_credentials": True,
"allow_headers": ["Content-Type", "Authorization"], "allow_headers": ["Content-Type", "Authorization", "X-Api-Key"],
"methods": ["GET", "POST", "PUT", "DELETE", "OPTIONS"], "methods": ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
} }
}, },
@@ -658,6 +659,73 @@ logger.info(
logger.info("Session cookie name: %s", SESSION_COOKIE_NAME) logger.info("Session cookie name: %s", SESSION_COOKIE_NAME)
def _proxy_default_is_admin(db: UserDB) -> bool:
"""Role for a proxy user seen for the first time when no admin group is configured.
The first account ever provisioned is an admin so the instance is never left without
one; later accounts follow PROXY_AUTH_DEFAULT_ROLE (default: user).
"""
if not db.has_admin():
return True
role = str(app_config.get("PROXY_AUTH_DEFAULT_ROLE", "user") or "user").strip().lower()
return role == "admin"
_API_KEY_EXEMPT_PREFIXES = ("/api/auth/",)
_API_KEY_EXEMPT_PATHS = frozenset({"/api/health"})
@app.before_request
def api_key_auth_middleware() -> Response | tuple[Response, int] | None:
"""Authenticate requests that present the configured SHELFMARK_API_KEY.
Both Authorization: Bearer and X-Api-Key are checked, and either
matching authenticates the request as an admin for this request only:
any session cookie is ignored and none is written back. Checking both
means a reverse proxy's own Authorization header never shadows an
operator-supplied X-Api-Key. No matching candidate is ignored so bearer
tokens forwarded by reverse proxies keep working; the request then
continues on the normal session path.
"""
if not request.path.startswith("/api/"):
return None
if request.path in _API_KEY_EXEMPT_PATHS or request.path.startswith(_API_KEY_EXEMPT_PREFIXES):
return None
if not api_key_module.SHELFMARK_API_KEY:
return None
candidates = api_key_module.extract_api_key_candidates(
request.headers.get("Authorization"), request.headers.get("X-Api-Key")
)
if not candidates:
return None
if not any(api_key_module.matches_api_key(candidate) for candidate in candidates):
return None
if get_auth_mode() == "none":
return None
# Mark the request as keyed before the lookup so the after-request cookie
# reset also covers the error path below.
g.api_key_auth = True
try:
admin = user_db.get_first_admin() if user_db is not None else None
except _OPERATIONAL_ERRORS:
logger.exception("API key auth middleware error")
return jsonify({"error": "Authentication error"}), 500
session.clear()
session["user_id"] = admin["username"] if admin else "api"
session["is_admin"] = True
if admin:
session["db_user_id"] = admin["id"]
session.permanent = False
# Identity is per request; never persist it as a cookie.
session.modified = False
return None
@app.before_request @app.before_request
def proxy_auth_middleware() -> Response | tuple[Response, int] | None: def proxy_auth_middleware() -> Response | tuple[Response, int] | None:
"""Middleware to handle proxy authentication. """Middleware to handle proxy authentication.
@@ -671,6 +739,10 @@ def proxy_auth_middleware() -> Response | tuple[Response, int] | None:
if auth_mode != "proxy": if auth_mode != "proxy":
return None return None
# A request already authenticated by SHELFMARK_API_KEY needs no proxy headers.
if g.get("api_key_auth"):
return None
# Skip for public endpoints that don't need auth # Skip for public endpoints that don't need auth
if request.path == "/api/health": if request.path == "/api/health":
return None return None
@@ -710,8 +782,9 @@ def proxy_auth_middleware() -> Response | tuple[Response, int] | None:
# Resolve admin role for proxy sessions. # Resolve admin role for proxy sessions.
# If an admin group is configured, derive from groups header. # If an admin group is configured, derive from groups header.
# Otherwise preserve existing DB role for known users and default # Otherwise preserve the existing DB role for known users; a first-time user
# first-time users to admin (to avoid lockouts). # is an admin only while the instance has none (so nobody is locked out),
# after that PROXY_AUTH_DEFAULT_ROLE decides (default: user).
admin_group_header = ( admin_group_header = (
normalize_optional_text( normalize_optional_text(
app_config.get("PROXY_AUTH_ADMIN_GROUP_HEADER", "X-Auth-Groups") app_config.get("PROXY_AUTH_ADMIN_GROUP_HEADER", "X-Auth-Groups")
@@ -734,6 +807,8 @@ def proxy_auth_middleware() -> Response | tuple[Response, int] | None:
existing_db_user = user_db.get_user(username=username) existing_db_user = user_db.get_user(username=username)
if existing_db_user: if existing_db_user:
is_admin = existing_db_user.get("role") == "admin" is_admin = existing_db_user.get("role") == "admin"
else:
is_admin = _proxy_default_is_admin(user_db)
# Create or update session # Create or update session
previous_username = session.get("user_id") previous_username = session.get("user_id")
@@ -800,6 +875,17 @@ def set_security_headers(response: Response) -> Response:
return response return response
@app.after_request
def strip_cookie_for_api_key_requests(response: Response) -> Response:
"""Keyed requests never mint or refresh a session cookie, even if a handler dirties the session."""
if g.get("api_key_auth"):
# Setting `permanent` mutates the session dict (re-marking it
# modified), so it must be reset before `modified`, not after.
session.permanent = False
session.modified = False
return response
def login_required( def login_required(
f: Callable[..., Response | tuple[Response, int]], f: Callable[..., Response | tuple[Response, int]],
) -> Callable[..., Response | tuple[Response, int]]: ) -> Callable[..., Response | tuple[Response, int]]:
@@ -1073,6 +1159,13 @@ def api_download_release() -> Response | tuple[Response, int]:
release_payload = dict(data) release_payload = dict(data)
release_payload["content_type"] = resolved_content_type release_payload["content_type"] = resolved_content_type
logger.info(
"Download request received. keys=%s downloads=%s extra.downloads=%s",
list(data.keys()),
data.get("downloads"),
data.get("extra", {}).get("downloads") if isinstance(data.get("extra"), dict) else None,
)
priority = data.get("priority", 0) priority = data.get("priority", 0)
# Per-user download overrides # Per-user download overrides
db_user_id = session.get("db_user_id") db_user_id = session.get("db_user_id")
@@ -1190,8 +1283,8 @@ def api_config() -> Response | tuple[Response, int]:
"onboarding_complete": _get_onboarding_complete(), "onboarding_complete": _get_onboarding_complete(),
# Default sort orders # Default sort orders
"default_sort": app_config.get( "default_sort": app_config.get(
"AA_DEFAULT_SORT", "relevance" "AA_DEFAULT_SORT", ""
), # For direct mode (Anna's Archive) ), # For direct mode (Anna's Archive) — empty means use local downloads sort
"metadata_default_sort": get_provider_default_sort( "metadata_default_sort": get_provider_default_sort(
metadata_ui_provider metadata_ui_provider
), # For universal mode ), # For universal mode
@@ -1354,6 +1447,7 @@ def _record_download_queued(task_id: str, task: Any) -> None:
size=normalize_optional_text(getattr(task, "size", None)), size=normalize_optional_text(getattr(task, "size", None)),
preview=normalize_optional_text(getattr(task, "preview", None)), preview=normalize_optional_text(getattr(task, "preview", None)),
content_type=normalize_optional_text(getattr(task, "content_type", None)), content_type=normalize_optional_text(getattr(task, "content_type", None)),
downloads=getattr(task, "downloads", None),
origin=origin, origin=origin,
retry_payload=backend.serialize_task_for_retry(task), retry_payload=backend.serialize_task_for_retry(task),
) )
@@ -1626,6 +1720,17 @@ def api_local_download() -> Response | tuple[Response, int]:
# Book data not found or not available # Book data not found or not available
return jsonify({"error": "File not found"}), 404 return jsonify({"error": "File not found"}), 404
is_admin, db_user_id, can_access_status = _resolve_status_scope()
if not is_admin:
actor_username = session.get("user_id")
if not can_access_status or not _task_owned_by_actor(
book_info,
actor_user_id=db_user_id,
actor_username=actor_username if isinstance(actor_username, str) else None,
):
return jsonify({"error": "File not found"}), 404
file_name = book_info.get_filename() if book_info is not None else Path(book_id).name file_name = book_info.get_filename() if book_info is not None else Path(book_id).name
# Prepare the file for sending to the client # Prepare the file for sending to the client
data = io.BytesIO(file_data) data = io.BytesIO(file_data)
@@ -2821,7 +2926,7 @@ def api_releases() -> Response | tuple[Response, int]:
try: try:
from dataclasses import asdict from dataclasses import asdict
from shelfmark.core.search_plan import build_release_search_plan from shelfmark.core.release_search import search_source_releases
from shelfmark.metadata_providers import ( from shelfmark.metadata_providers import (
BookMetadata, BookMetadata,
get_provider, get_provider,
@@ -2840,54 +2945,17 @@ def api_releases() -> Response | tuple[Response, int]:
source_name: str, search_book: BookMetadata source_name: str, search_book: BookMetadata
) -> tuple[Any | None, list[Any], str | None]: ) -> tuple[Any | None, list[Any], str | None]:
"""Search one source and return any error message instead of raising.""" """Search one source and return any error message instead of raising."""
try: return search_source_releases(
source = get_source(source_name) source_name,
search_book,
plan = build_release_search_plan( languages=(browse_filters.lang if source_query_filters is not None else languages),
search_book, manual_query=(query_text if source_query_filters is not None else manual_query),
languages=browse_filters.lang indexers=indexers,
if source_query_filters is not None expand_search=expand_search,
else languages, content_type=content_type,
manual_query=query_text if source_query_filters is not None else manual_query, source_filters=source_query_filters,
indexers=indexers, user_id=db_user_id,
source_filters=source_query_filters, )
user_id=db_user_id,
)
if plan.source_filters is not None:
planned_query = plan.manual_query or plan.primary_query
planned_query_type = "query"
elif plan.manual_query:
planned_query = plan.manual_query
planned_query_type = "manual"
elif not expand_search and plan.isbn_candidates:
planned_query = plan.isbn_candidates[0]
planned_query_type = "isbn"
else:
planned_query = plan.primary_query
planned_query_type = "title_author"
logger.debug(
"Searching %s: %s='%s' (title='%s', authors=%s, expand=%s, content_type=%s)",
source_name,
planned_query_type,
planned_query,
search_book.title,
search_book.authors,
expand_search,
content_type,
)
releases = source.search(
search_book, plan, expand_search=expand_search, content_type=content_type
)
except ValueError:
return None, [], f"Unknown source: {source_name}"
except (SourceUnavailableError, *_OPERATIONAL_ERRORS) as e:
logger.warning("Release search failed for source %s: %s", source_name, e)
return None, [], f"{source_name}: {e!s}"
else:
return source, releases, None
provider = request.args.get("provider", "").strip() provider = request.args.get("provider", "").strip()
book_id = request.args.get("book_id", "").strip() book_id = request.args.get("book_id", "").strip()
@@ -3052,8 +3120,13 @@ def api_releases() -> Response | tuple[Response, int]:
search_info = {} search_info = {}
for source_name, source_instance in source_instances.items(): for source_name, source_instance in source_instances.items():
info: dict[str, str | int | None] = {}
if hasattr(source_instance, "last_search_type") and source_instance.last_search_type: if hasattr(source_instance, "last_search_type") and source_instance.last_search_type:
search_info[source_name] = {"search_type": source_instance.last_search_type} info["search_type"] = source_instance.last_search_type
if hasattr(source_instance, "total_results"):
info["total_results"] = source_instance.total_results
if info:
search_info[source_name] = info
response = { response = {
"releases": releases_data, "releases": releases_data,
+3 -2
View File
@@ -158,10 +158,11 @@ class GoogleBooksProvider(MetadataProvider):
query = "+".join(query_parts) query = "+".join(query_parts)
# Build request params # Build request params
page_size = min(options.limit, 40) # Google max is 40
params: dict[str, Any] = { params: dict[str, Any] = {
"q": query, "q": query,
"maxResults": min(options.limit, 40), # Google max is 40 "maxResults": page_size,
"startIndex": (options.page - 1) * options.limit, "startIndex": (options.page - 1) * page_size,
"printType": "books", # Exclude magazines "printType": "books", # Exclude magazines
} }
+13 -2
View File
@@ -46,6 +46,7 @@ class BrowseRecord:
content: str | None = None content: str | None = None
format: str | None = None format: str | None = None
size: str | None = None size: str | None = None
downloads: int | None = None
info: dict[str, list[str]] | None = None info: dict[str, list[str]] | None = None
description: str | None = None description: str | None = None
download_urls: list[str] = field(default_factory=list) download_urls: list[str] = field(default_factory=list)
@@ -368,12 +369,21 @@ class ReleaseSource(ABC):
return None return None
@dataclass(frozen=True)
class HandoffResult:
"""An external handoff that completed without a Shelfmark book payload."""
path: str
message: str
class DownloadHandler(ABC): class DownloadHandler(ABC):
"""Interface for executing downloads. """Interface for executing downloads.
A handler may either: A handler may either:
- download directly into ``TMP_DIR`` (managed by Shelfmark), or - download directly into ``TMP_DIR`` (managed by Shelfmark), or
- return a path owned by an external client (e.g. torrent/usenet). - return a path owned by an external client (e.g. torrent/usenet).
- finish an external handoff without producing a book payload.
The orchestrator is responsible for post-processing (archive extraction, output mode The orchestrator is responsible for post-processing (archive extraction, output mode
handling) and transferring files into their final destination. handling) and transferring files into their final destination.
@@ -386,8 +396,8 @@ class DownloadHandler(ABC):
cancel_flag: Event, cancel_flag: Event,
progress_callback: Callable[[float], None], progress_callback: Callable[[float], None],
status_callback: Callable[[str, str | None], None], status_callback: Callable[[str, str | None], None],
) -> str | None: ) -> str | HandoffResult | None:
"""Execute download and return a path to the downloaded payload.""" """Execute download and return a payload path or completed external handoff."""
def post_process_cleanup(self, task: DownloadTask, *, success: bool) -> None: def post_process_cleanup(self, task: DownloadTask, *, success: bool) -> None:
"""Run optional cleanup after orchestrator post-processing. """Run optional cleanup after orchestrator post-processing.
@@ -422,6 +432,7 @@ _BUILTIN_SOURCE_MODULES = (
"shelfmark.release_sources.audiobookbay", "shelfmark.release_sources.audiobookbay",
"shelfmark.release_sources.direct_download", "shelfmark.release_sources.direct_download",
"shelfmark.release_sources.irc", "shelfmark.release_sources.irc",
"shelfmark.release_sources.libgen",
"shelfmark.release_sources.newznab", "shelfmark.release_sources.newznab",
"shelfmark.release_sources.prowlarr", "shelfmark.release_sources.prowlarr",
) )
@@ -0,0 +1,20 @@
"""Direct Download release source and public entry points.
Importing the source and handler classes registers them with Shelfmark.
"""
from shelfmark.release_sources.direct_download.annas_archive import search_books
from shelfmark.release_sources.direct_download.common import DirectDownloadUnavailableError
from shelfmark.release_sources.direct_download.handler import DirectDownloadHandler
from shelfmark.release_sources.direct_download.source import DirectDownloadSource
__all__ = [
"DirectDownloadUnavailableError",
"DirectDownloadHandler",
"DirectDownloadSource",
"SearchUnavailableError",
"search_books",
]
# Compatibility alias for integrations that imported the old module-level name.
SearchUnavailableError = DirectDownloadUnavailableError
@@ -0,0 +1,272 @@
"""Shared contracts and result normalization for Direct Download websites."""
import hashlib
import re
import threading
from dataclasses import dataclass
from typing import TYPE_CHECKING, Protocol, runtime_checkable
from bs4 import BeautifulSoup, Tag
from shelfmark.core.config import config
from shelfmark.core.languages import language_alias_map
from shelfmark.release_sources import BrowseRecord, SourceUnavailableError
if TYPE_CHECKING:
from collections.abc import Callable, Iterable
from pathlib import Path
from threading import Event
from shelfmark.core.models import SearchFilters
from shelfmark.core.search_plan import ReleaseSearchPlan
from shelfmark.metadata_providers import BookMetadata
_LANGUAGE_ALIAS_TO_CODE: dict[str, str] | None = None
_LANGUAGE_ALIAS_LOCK = threading.Lock()
_SIZE_UNIT_PATTERN = re.compile(r"(kb|mb|gb|tb)", re.IGNORECASE)
MIN_VALID_FILE_SIZE = 10 * 1024
class DirectDownloadUnavailableError(SourceUnavailableError):
"""Raised when the composite Direct Download source cannot be reached."""
def coerce_str_list(value: object) -> list[str]:
"""Return only string items from a config value."""
if not isinstance(value, list | tuple):
return []
return [item for item in value if isinstance(item, str)]
def get_supported_formats() -> list[str]:
"""Return configured supported formats as a clean string list."""
return coerce_str_list(config.SUPPORTED_FORMATS)
def html_response_text(response: str | tuple[str, str]) -> str:
"""Extract the HTML body from downloader responses."""
if isinstance(response, tuple):
return response[0]
return response
def attr_to_str(value: object) -> str | None:
"""Convert a BeautifulSoup attribute value to a plain string."""
if isinstance(value, str):
return value
if isinstance(value, list):
for item in value:
if isinstance(item, str):
return item
return None
def get_attr(tag: Tag, attr: str) -> str | None:
"""Safely fetch a tag attribute as a string."""
return attr_to_str(tag.get(attr))
@dataclass(frozen=True)
class ParsedSearchResult:
"""Provider-neutral fields extracted from one search-result element."""
key: str
title: str
formats: tuple[str, ...]
record_id: str | None = None
author: str | None = None
publisher: str | None = None
year: str | None = None
language: str | None = None
content: str | None = None
size: str | None = None
preview: str | None = None
source_url: str | None = None
download_path: str | None = None
class DirectDownloadProvider(Protocol):
"""Provider lifecycle used by the composite Direct Download source."""
id: str
display_name: str
def is_enabled(self) -> bool: ...
def handles(self, url: str) -> bool: ...
def search(
self,
book: BookMetadata,
plan: ReleaseSearchPlan,
*,
expand_search: bool = False,
content_type: str = "ebook",
) -> list[BrowseRecord]: ...
def download(
self,
book_info: BrowseRecord,
book_path: Path,
progress_callback: Callable[[float], None] | None,
cancel_flag: Event | None,
status_callback: Callable[[str, str | None], None] | None,
) -> str | None: ...
@runtime_checkable
class RecordLookupProvider(Protocol):
"""Optional capability for providers that can reopen source-native records."""
def get_record(
self, record_id: str, *, fetch_download_count: bool = True
) -> BrowseRecord | None: ...
def normalize_language_token(value: str) -> str:
normalized = value.strip().lower()
for dash in ("‑", "–", "—", "−"):
normalized = normalized.replace(dash, "-")
return normalized
def language_alias_to_code() -> dict[str, str]:
"""Alias to code map, delegating to the shared language data."""
global _LANGUAGE_ALIAS_TO_CODE
cached = _LANGUAGE_ALIAS_TO_CODE
if cached is not None:
return cached
with _LANGUAGE_ALIAS_LOCK:
cached = _LANGUAGE_ALIAS_TO_CODE
if cached is not None:
return cached
_LANGUAGE_ALIAS_TO_CODE = language_alias_map()
return _LANGUAGE_ALIAS_TO_CODE
def normalize_requested_languages(languages: list[str] | None) -> set[str]:
if not languages:
return set()
aliases = language_alias_to_code()
normalized: set[str] = set()
for value in languages:
token = normalize_language_token(str(value))
if not token or token == "all": # noqa: S105 - "all" is a language sentinel
continue
normalized.add(aliases.get(token, token))
return normalized
def book_matches_requested_languages(book_language: str | None, requested: set[str]) -> bool:
"""Return True when a book's language matches the requested filter.
Books with unknown/missing language always pass — the server-side &lang= filter
already narrowed the result set, so dropping unlabelled rows hides valid results.
"""
if not requested:
return True
if not book_language:
return True
aliases = language_alias_to_code()
normalized_book = aliases.get(
normalize_language_token(book_language),
normalize_language_token(book_language),
)
return normalized_book in requested
def normalize_size(size_str: str) -> str:
"""Normalize size string by uppercasing units (e.g., '5.2 mb' -> '5.2 MB')."""
return _SIZE_UNIT_PATTERN.sub(lambda m: m.group(1).upper(), size_str.strip())
def parse_search_items(
items: Iterable[Tag],
filters: SearchFilters | None,
*,
provider_id: str,
extract_item: Callable[[Tag], ParsedSearchResult | None],
filter_languages: bool = True,
) -> list[BrowseRecord]:
"""Normalize provider-specific HTML elements into Direct Download records.
Providers only describe how fields are extracted from their DOM. Language and
format filtering, stable IDs, and BrowseRecord construction stay shared.
Pass ``filter_languages=False`` when the site already filtered by language: its
language cells are free text, and re-matching them locally drops rows it matched.
"""
requested_languages = (
normalize_requested_languages(filters.lang) if filters and filter_languages else set()
)
requested_formats = (
{value.casefold() for value in (filters.format or get_supported_formats())}
if filters
else set()
)
records: list[BrowseRecord] = []
for item in items:
parsed = extract_item(item)
if parsed is None:
continue
normalized_language = normalize_language_token(parsed.language) if parsed.language else ""
language = language_alias_to_code().get(normalized_language, normalized_language) or None
if not book_matches_requested_languages(language, requested_languages):
continue
formats = parsed.formats or ("",)
for book_format in formats:
normalized_format = book_format.casefold()
if (
normalized_format
and requested_formats
and normalized_format not in requested_formats
):
continue
record_id = parsed.record_id
if not record_id or len(formats) > 1:
source_key = f"{parsed.key}#{normalized_format}"
digest = hashlib.blake2b(source_key.encode(), digest_size=16).hexdigest()
record_id = f"{provider_id}:{digest}"
records.append(
BrowseRecord(
id=record_id,
title=parsed.title,
source="direct_download",
author=parsed.author,
publisher=parsed.publisher,
year=parsed.year,
language=language,
format=normalized_format or None,
size=parsed.size,
preview=parsed.preview,
content=parsed.content,
source_url=parsed.source_url,
download_path=parsed.download_path,
)
)
return records
def parse_search_page(
page: str | BeautifulSoup | Tag,
filters: SearchFilters | None,
*,
provider_id: str,
item_selector: str,
extract_item: Callable[[Tag], ParsedSearchResult | None],
filter_languages: bool = True,
) -> list[BrowseRecord]:
"""Parse a result page using provider-specific selectors and extraction."""
root = BeautifulSoup(page, "html.parser") if isinstance(page, str) else page
return parse_search_items(
(item for item in root.select(item_selector) if isinstance(item, Tag)),
filters,
provider_id=provider_id,
extract_item=extract_item,
filter_languages=filter_languages,
)
@@ -0,0 +1,177 @@
"""Direct Download routing, staging, and cancellation."""
from pathlib import Path
from typing import TYPE_CHECKING
from shelfmark.config.env import TMP_DIR
from shelfmark.core.config import config
from shelfmark.core.logger import setup_logger
from shelfmark.core.models import DownloadTask, build_filename
from shelfmark.download import network
from shelfmark.release_sources import (
BrowseRecord,
DownloadHandler,
register_handler,
)
from shelfmark.release_sources.direct_download import registry
if TYPE_CHECKING:
from collections.abc import Callable
from pathlib import Path
from threading import Event
logger = setup_logger(__name__)
def _download_book(
book_info: BrowseRecord,
book_path: Path,
progress_callback: Callable[[float], None] | None = None,
cancel_flag: Event | None = None,
status_callback: Callable[[str, str | None], None] | None = None,
) -> str | None:
"""Route a website record or an Anna's Archive MD5 to its download flow."""
provider = registry.provider_for_record(book_info)
if provider is None:
msg = f"No Direct Download provider owns record {book_info.id!r}"
raise RuntimeError(msg)
return provider.download(book_info, book_path, progress_callback, cancel_flag, status_callback)
@register_handler("direct_download")
class DirectDownloadHandler(DownloadHandler):
"""Route and stage downloads from registered Direct Download providers."""
def download(
self,
task: DownloadTask,
cancel_flag: Event,
progress_callback: Callable[[float], None],
status_callback: Callable[[str, str | None], None],
) -> str | None:
"""Execute a provider-owned direct HTTP download.
Args:
task: Download task with a provider-owned source ID
cancel_flag: Event to check for cancellation
progress_callback: Called with progress percentage (0-100)
status_callback: Called with (status, message) for status updates
Returns:
Path to downloaded file if successful, None otherwise
"""
try:
# Check for cancellation before starting
if cancel_flag.is_set():
logger.info("Download cancelled before starting: %s", task.task_id)
status_callback("cancelled", "Cancelled")
return None
# Reconstruct the provider-owned record without resolving it again.
book_info = BrowseRecord(
id=task.task_id,
title=task.title,
source="direct_download",
author=task.author,
year=task.year,
format=task.format,
size=task.size,
preview=task.preview,
source_url=task.source_url,
)
return self._execute_download(
book_info, cancel_flag, progress_callback, status_callback
)
except Exception as e:
if cancel_flag.is_set():
logger.info("Download cancelled during error handling: %s", task.task_id)
status_callback("cancelled", "Cancelled")
else:
logger.exception("Error downloading book")
status_callback("error", str(e))
return None
def _execute_download(
self,
book_info: BrowseRecord,
cancel_flag: Event,
progress_callback: Callable[[float], None],
status_callback: Callable[[str, str | None], None],
) -> str | None:
"""Execute the direct-download flow with a fetched browse record.
This contains the core download logic: cascade through sources,
handle bypass, move to final location.
"""
try:
logger.debug("Starting download: %s", book_info.title)
# Prepare paths - use descriptive staging filename, orchestrator will rename
# based on FILE_ORGANIZATION setting
file_org = config.get("FILE_ORGANIZATION", "rename")
if file_org == "none":
book_name = f"{book_info.id}.{book_info.format or 'bin'}"
else:
book_name = build_filename(
book_info.title,
book_info.author,
book_info.year,
book_info.format,
)
book_path = TMP_DIR / book_name
# Check cancellation before download
if cancel_flag.is_set():
logger.info("Download cancelled before download call: %s", book_info.id)
status_callback("cancelled", "Cancelled")
return None
# Execute download via _download_book (handles cascade and bypass)
status_callback("resolving", "Finding download source")
success_url = _download_book(
book_info, book_path, progress_callback, cancel_flag, status_callback
)
# Check for cancellation after download
if cancel_flag.is_set():
logger.info("Download cancelled during download: %s", book_info.id)
if book_path.exists():
book_path.unlink()
status_callback("cancelled", "Cancelled")
return None
if not success_url:
if network.dns_interference_detected():
status_callback(
"error",
"All sources failed - your network/ISP appears to be blocking "
"Anna's Archive. Enable DNS-over-HTTPS in settings.",
)
else:
status_callback("error", "All download sources failed")
return None
# Return temp path - orchestrator handles post-processing (archive extraction, ingest)
return str(book_path)
except Exception:
if cancel_flag.is_set():
logger.info("Download cancelled during error handling: %s", book_info.id)
status_callback("cancelled", "Cancelled")
else:
logger.exception("Error downloading book")
return None
def cancel(self, task_id: str) -> bool:
"""Cancel an in-progress download.
Cancellation is handled via the cancel_flag passed to download().
This method exists for the interface but actual cancellation
happens through the Event flag mechanism.
"""
# Cancellation is handled by the orchestrator via cancel_flag
return False
@@ -0,0 +1,93 @@
"""Provider composition and dispatch for the Direct Download release source."""
import re
from typing import TYPE_CHECKING
from shelfmark.core.config import config
from shelfmark.release_sources.direct_download.annas_archive import AnnasArchiveProvider
if TYPE_CHECKING:
from collections.abc import Sequence
from shelfmark.release_sources import BrowseRecord
from shelfmark.release_sources.direct_download.common import DirectDownloadProvider
PROVIDER_TYPES = (AnnasArchiveProvider,)
_AA_MD5_PATTERN = re.compile(r"^[0-9a-f]{32}$", re.IGNORECASE)
def create_providers() -> tuple[DirectDownloadProvider, ...]:
"""Create request-local providers so mutable search state is not shared."""
return tuple(provider_type() for provider_type in PROVIDER_TYPES)
def enabled_providers(
providers: Sequence[DirectDownloadProvider] | None = None,
) -> tuple[DirectDownloadProvider, ...]:
if not config.get("DIRECT_DOWNLOAD_ENABLED", False):
return ()
candidates = providers if providers is not None else create_providers()
return tuple(provider for provider in candidates if provider.is_enabled())
def get_unavailable_reason(
providers: Sequence[DirectDownloadProvider] | None = None,
) -> str | None:
if not config.get("DIRECT_DOWNLOAD_ENABLED", False):
return "Direct Download is disabled. Enable the source in Settings."
if not enabled_providers(providers):
return (
"Direct Download is not configured. Enable and configure at least one "
"download provider in Settings."
)
return None
def provider_by_id(
provider_id: str | None,
providers: Sequence[DirectDownloadProvider] | None = None,
) -> DirectDownloadProvider | None:
if not provider_id:
return None
candidates = providers if providers is not None else create_providers()
return next((provider for provider in candidates if provider.id == provider_id), None)
def provider_for_record(
record: BrowseRecord,
providers: Sequence[DirectDownloadProvider] | None = None,
) -> DirectDownloadProvider | None:
"""Resolve a record explicitly, retaining safe compatibility with legacy tasks."""
candidates = providers if providers is not None else create_providers()
prefix, separator, _remainder = record.id.partition(":")
if separator:
provider = provider_by_id(prefix, candidates)
if provider is not None:
return provider
if record.source_url:
provider = next(
(provider for provider in candidates if provider.handles(record.source_url)),
None,
)
if provider is not None:
return provider
# Anna's Archive records historically carried only their raw MD5. Preserve those
# persisted tasks without treating arbitrary unknown URLs as Anna's Archive.
if _AA_MD5_PATTERN.fullmatch(record.id):
return provider_by_id("annas_archive", candidates)
return None
def provider_for_record_id(
record_id: str,
providers: Sequence[DirectDownloadProvider] | None = None,
) -> DirectDownloadProvider | None:
candidates = providers if providers is not None else create_providers()
prefix, separator, _remainder = record_id.partition(":")
if separator:
return provider_by_id(prefix, candidates)
# Record lookup predates provider-qualified IDs, so unqualified IDs are AA IDs.
return provider_by_id("annas_archive", candidates)
@@ -0,0 +1,236 @@
"""Direct Download search and release-source integration."""
import contextlib
from pathlib import Path
from typing import TYPE_CHECKING, ClassVar
import requests
from shelfmark.core.logger import setup_logger
from shelfmark.core.utils import get_aa_content_type_dir
from shelfmark.core.utils import is_audiobook as check_audiobook
from shelfmark.release_sources import (
BrowseRecord,
ColumnAlign,
ColumnColorHint,
ColumnRenderType,
ColumnSchema,
Release,
ReleaseColumnConfig,
ReleaseProtocol,
ReleaseSource,
SourceUnavailableError,
register_source,
)
from shelfmark.release_sources.direct_download import registry
from shelfmark.release_sources.direct_download.common import (
DirectDownloadUnavailableError,
RecordLookupProvider,
)
if TYPE_CHECKING:
from pathlib import Path
from shelfmark.core.models import DownloadTask
from shelfmark.core.search_plan import ReleaseSearchPlan
from shelfmark.metadata_providers import BookMetadata
logger = setup_logger(__name__)
def _extract_downloads(record: BrowseRecord) -> int | None:
"""Extract download count from record info for Release.extra.downloads."""
downloads = None
if record.info and "Downloads" in record.info:
downloads_value = record.info["Downloads"]
if isinstance(downloads_value, list) and len(downloads_value) > 0:
with contextlib.suppress(ValueError, TypeError):
downloads = int(downloads_value[0])
elif isinstance(downloads_value, (int, float)):
downloads = int(downloads_value)
return downloads
def _browse_record_to_release(record: BrowseRecord) -> Release:
"""Convert a browse record to a Release object.
This bridges the direct source's browse data to the generic release model.
"""
provider = registry.provider_for_record(record)
provider_id = provider.id if provider is not None else None
return Release(
source=record.source,
source_id=record.id,
title=record.title,
format=record.format,
language=record.language, # Top-level language for filtering
size=record.size,
download_url=record.source_url
or (record.download_urls[0] if record.download_urls else None),
info_url=record.source_url,
protocol=ReleaseProtocol.HTTP,
indexer="Direct Download",
content_type=record.content, # Preserve content type from source
extra={
"author": record.author,
"publisher": record.publisher,
"year": record.year,
"language": record.language,
"preview": record.preview,
"description": record.description,
"download_urls": record.download_urls,
"info": record.info,
"direct_download_provider": provider_id,
"downloads": _extract_downloads(record),
# Kept for older frontends and persisted request payloads.
"web_provider": provider_id if provider_id != "annas_archive" else None,
},
)
@register_source("direct_download")
class DirectDownloadSource(ReleaseSource):
"""Direct download source - searches web sources for books.
This wraps the search_books() functionality to provide releases
via the plugin interface.
"""
name = "direct_download"
display_name = "Direct Download"
supported_content_types: ClassVar[list[str]] = ["ebook"] # Direct downloads only support ebooks
def __init__(self) -> None:
"""Initialize per-instance search state for direct downloads."""
self._providers = registry.create_providers()
@property
def last_search_type(self) -> str:
"""Returns the search type used in the last search() call."""
provider = registry.provider_by_id("annas_archive", self._providers)
return str(getattr(provider, "last_search_type", "title_author"))
@property
def total_results(self) -> int | None:
"""Returns the total result count from the last search."""
provider = registry.provider_by_id("annas_archive", self._providers)
return getattr(provider, "total_results", None)
def get_column_config(self) -> ReleaseColumnConfig:
"""Column configuration for Direct Download source.
Shows language, format, size, and downloads for each release.
Language, format, size, and downloads are all shown on mobile.
"""
return ReleaseColumnConfig(
columns=[
ColumnSchema(
key="extra.language",
label="Language",
render_type=ColumnRenderType.BADGE,
align=ColumnAlign.CENTER,
width="60px",
hide_mobile=False, # Language shown on mobile
color_hint=ColumnColorHint(type="map", value="language"),
uppercase=True,
),
ColumnSchema(
key="format",
label="Format",
render_type=ColumnRenderType.BADGE,
align=ColumnAlign.CENTER,
width="80px",
hide_mobile=False, # Format shown on mobile
color_hint=ColumnColorHint(type="map", value="format"),
uppercase=True,
),
ColumnSchema(
key="size",
label="Size",
render_type=ColumnRenderType.SIZE,
align=ColumnAlign.CENTER,
width="80px",
hide_mobile=False, # Size shown on mobile
),
ColumnSchema(
key="extra.downloads",
label="Downloads",
render_type=ColumnRenderType.NUMBER,
align=ColumnAlign.CENTER,
width="80px",
hide_mobile=False, # Downloads shown on mobile
),
],
grid_template="minmax(0,2fr) 60px 80px 80px 80px",
supported_filters=["format", "language"], # AA has reliable language metadata
)
def get_record(
self,
record_id: str,
*,
fetch_download_count: bool = True,
) -> BrowseRecord | None:
"""Resolve a direct-download record for direct-mode info/download flows."""
provider = registry.provider_for_record_id(record_id, self._providers)
if provider is None or not isinstance(provider, RecordLookupProvider):
return None
native_id = record_id.partition(":")[2] or record_id
return provider.get_record(native_id, fetch_download_count=fetch_download_count)
def search_results_are_releases(self) -> bool:
"""Direct search results already represent concrete downloadable releases."""
return True
def get_destination_override(self, task: DownloadTask) -> Path | None:
"""Apply Anna's Archive content-type routing when configured."""
if check_audiobook(task.content_type):
return None
return get_aa_content_type_dir(task.content_type)
def search(
self,
book: BookMetadata,
plan: ReleaseSearchPlan,
*,
expand_search: bool = False,
content_type: str = "ebook",
) -> list[Release]:
"""Search every enabled provider through the shared provider lifecycle."""
unavailable_reason = registry.get_unavailable_reason(self._providers)
if unavailable_reason:
raise DirectDownloadUnavailableError(unavailable_reason)
releases: list[Release] = []
failures: list[Exception] = []
for provider in registry.enabled_providers(self._providers):
try:
records = provider.search(
book,
plan,
expand_search=expand_search,
content_type=content_type,
)
except SourceUnavailableError as exc:
failures.append(exc)
continue
except (
RuntimeError,
TypeError,
ValueError,
requests.exceptions.RequestException,
) as exc:
logger.warning("%s search failed: %s", provider.display_name, exc)
failures.append(exc)
continue
releases.extend(_browse_record_to_release(record) for record in records)
# A provider failure is only quiet when another provider answered. Otherwise the
# caller has to see it, or a failed search reads as a search with no hits.
if failures and not releases:
raise failures[0]
return releases
def is_available(self) -> bool:
"""Check if Direct Download has been explicitly enabled and configured."""
return registry.get_unavailable_reason(self._providers) is None
+68 -20
View File
@@ -13,9 +13,9 @@ if TYPE_CHECKING:
from shelfmark.metadata_providers import BookMetadata from shelfmark.metadata_providers import BookMetadata
from shelfmark.api.websocket import ws_manager from shelfmark.api.websocket import ws_manager
from shelfmark.core.author_match import author_affinity, search_surname
from shelfmark.core.config import config from shelfmark.core.config import config
from shelfmark.core.logger import setup_logger 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.core.utils import is_audiobook
from shelfmark.release_sources import ( from shelfmark.release_sources import (
ColumnColorHint, ColumnColorHint,
@@ -86,6 +86,18 @@ def _emit_status(message: str, phase: str = "searching") -> None:
) )
def _reported_author(release: Release) -> str:
"""The author a result actually claims, with the parser's sentinel read as none.
A filename with no " - " separator has no author to report and parser.py:168
fills in "Unknown". Ranked literally that sorts as a wrong author, below every
result that named someone else; as absent it sorts between agreement and
disagreement, which is what the tier was built for.
"""
author = release.extra.get("author", "")
return "" if author == "Unknown" else author
# Rate limiting to avoid server throttling # Rate limiting to avoid server throttling
MIN_SEARCH_INTERVAL = 15.0 MIN_SEARCH_INTERVAL = 15.0
_last_search_time: float = 0 _last_search_time: float = 0
@@ -227,12 +239,15 @@ class IRCReleaseSource(ReleaseSource):
logger.debug("IRC source is disabled, skipping search") logger.debug("IRC source is disabled, skipping search")
return [] return []
# Build search query query = self._build_query(book, plan)
query = plan.primary_query or self._build_query(book)
if not query: if not query:
logger.warning("No search query could be built") logger.warning("No search query could be built")
return [] return []
# 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
# Get IRC settings # Get IRC settings
server = _config_text("IRC_SERVER") server = _config_text("IRC_SERVER")
port = _config_port("IRC_PORT", 6697) port = _config_port("IRC_PORT", 6697)
@@ -277,7 +292,9 @@ class IRCReleaseSource(ReleaseSource):
if cached: if cached:
_emit_status("Using cached results", phase="complete") _emit_status("Using cached results", phase="complete")
self._online_servers = set(cached.get("online_servers", [])) self._online_servers = set(cached.get("online_servers", []))
return self._filter_by_content_type(cached["releases"], requested) return self._rank_by_author(
self._filter_by_content_type(cached["releases"], requested), wanted_author
)
# Anti-spam cap: the exact same query may only be POSTED a limited number of times # Anti-spam cap: the exact same query may only be POSTED a limited number of times
# per window, even via refresh. Beyond that, serve whatever is cached rather than # per window, even via refresh. Beyond that, serve whatever is cached rather than
@@ -294,7 +311,9 @@ class IRCReleaseSource(ReleaseSource):
cached = get_cached_results(query_key) cached = get_cached_results(query_key)
if cached: if cached:
self._online_servers = set(cached.get("online_servers", [])) self._online_servers = set(cached.get("online_servers", []))
return self._filter_by_content_type(cached["releases"], requested) return self._rank_by_author(
self._filter_by_content_type(cached["releases"], requested), wanted_author
)
return [] return []
logger.info("IRC search: %s", query) logger.info("IRC search: %s", query)
@@ -370,7 +389,12 @@ class IRCReleaseSource(ReleaseSource):
ebook_releases + audiobook_releases, ebook_releases + audiobook_releases,
online_servers=online_servers, online_servers=online_servers,
) )
releases = audiobook_releases if requested == "audiobook" else ebook_releases # Ranked on the way out, never before the cache: one query identity is
# shared by every book that produced the same query, so the order has to
# follow the author asked for now, not the one that filled the cache.
releases = self._rank_by_author(
audiobook_releases if requested == "audiobook" else ebook_releases, wanted_author
)
except DCCError as e: except DCCError as e:
logger.exception("DCC error during search") logger.exception("DCC error during search")
@@ -388,24 +412,48 @@ class IRCReleaseSource(ReleaseSource):
else: else:
return releases return releases
def _build_query(self, book: BookMetadata) -> str: def _build_query(self, book: BookMetadata, plan: ReleaseSearchPlan) -> str:
"""Build search query from book metadata.""" """Build the line posted to the channel: a title, plus a surname.
parts = []
if book.search_title or book.title: Both come off the variant rather than the plan: an ISBN fallback variant
parts.append(book.search_title or book.title) carries `author=""` on purpose (search_plan.py:246), and so does a manual
query, so reading `plan.author` here would append a surname to searches
# Only ever the first author: both metadata fields can arrive holding every that deliberately have none.
# 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)
Returns "" without a title, so the caller reports "no query" rather than
posting one. A surname on its own is not a search: `@search Petrie` asks
the bot for every Petrie on the channel, and a bare over-broad line is the
kind of post `is_available` refuses queries to avoid being banned for.
"""
variant = plan.title_variants[0] if plan.title_variants else None
title = variant.title if variant else (book.search_title or book.title)
if not title:
return ""
author = variant.author if variant else plan.author
parts = [part for part in (title, search_surname(author)) if part]
return " ".join(parts) return " ".join(parts)
def _rank_by_author(self, releases: list[Release], wanted_author: str) -> list[Release]:
"""Order releases by author agreement, under the server's availability.
A surname is a weak filter - it also matches a different author who shares
it - so the full name decides the order while the search bot decides the
set. Availability stays the outer key: downloading asks one named bot and
waits 120s for it (handler.py:133-139), so a release from a bot that is not
in the channel must not outrank one that can actually answer. Sorting is
stable, so format and server order survive inside each tier.
"""
if not wanted_author:
return releases
online = self._online_servers or set()
return sorted(
releases,
key=lambda release: (
0 if release.extra.get("server", "") in online else 1,
author_affinity(wanted_author, _reported_author(release)),
),
)
# Format priority for sorting (lower = higher priority) # Format priority for sorting (lower = higher priority)
EBOOK_FORMAT_PRIORITY: ClassVar[dict[str, int]] = { EBOOK_FORMAT_PRIORITY: ClassVar[dict[str, int]] = {
"epub": 0, "epub": 0,
@@ -0,0 +1,6 @@
"""Libgen release source - direct catalogue search over the libgen.li family."""
# Import to trigger registration
from shelfmark.release_sources.libgen import handler as handler
from shelfmark.release_sources.libgen import settings as settings
from shelfmark.release_sources.libgen import source as source
+114
View File
@@ -0,0 +1,114 @@
"""Libgen download handler - resolves an md5 to a file via the ads.php cascade.
Selected by ``get_handler(task.source)`` for ``source == "libgen"``. It mirrors
DirectDownloadHandler's shape (stage into TMP_DIR, let the orchestrator post-process) but
only knows the libgen ``ads.php?md5= -> get.php`` path, keyed on the md5 the search source
put in ``source_id``.
"""
from typing import TYPE_CHECKING
from shelfmark.config.env import TMP_DIR
from shelfmark.core.config import config
from shelfmark.core.logger import setup_logger
from shelfmark.core.models import build_filename
from shelfmark.download import http as downloader
from shelfmark.release_sources import DownloadHandler, register_handler
from shelfmark.release_sources.libgen import scraper
if TYPE_CHECKING:
from collections.abc import Callable
from threading import Event
from shelfmark.core.models import DownloadTask
logger = setup_logger(__name__)
# Files under this size are almost certainly an error/challenge page, not a book. Same
# threshold direct_download uses; duplicated to keep the package self-contained.
_MIN_VALID_FILE_SIZE = 10 * 1024
@register_handler("libgen")
class LibgenHandler(DownloadHandler):
"""Download handler for Libgen search releases."""
def download(
self,
task: DownloadTask,
cancel_flag: Event,
progress_callback: Callable[[float], None],
status_callback: Callable[[str, str | None], None],
) -> str | None:
"""Resolve the md5 through each configured mirror and download the file.
Returns the staged temp path on success (orchestrator handles post-processing) or
None if every mirror fails.
"""
from shelfmark.core import mirrors
try:
if cancel_flag.is_set():
status_callback("cancelled", "Cancelled")
return None
# source_id was namespaced "libgen:<md5>" to avoid a queue-key collision with
# direct_download; strip it back to the bare (lowercase) md5 the download page expects.
md5 = task.task_id.split(":", 1)[-1].lower()
if config.get("FILE_ORGANIZATION", "rename") == "none":
book_name = f"{md5}.{task.format or 'bin'}"
else:
book_name = build_filename(task.title, task.author, task.year, task.format)
book_path = TMP_DIR / book_name
for base in mirrors.get_libgen_mirrors():
if cancel_flag.is_set():
status_callback("cancelled", "Cancelled")
return None
ads_url = f"{base.rstrip('/')}/ads.php?md5={md5}"
status_callback("resolving", "Resolving Libgen")
ads_html = scraper.fetch_page(ads_url, (5, 10))
if not ads_html:
continue
get_url = scraper.resolve_download_url(ads_html, base)
if not get_url:
continue
# _selector=None: download_url builds its own AAMirrorSelector (a no-op for
# non-AA URLs), so we avoid initialising dead AA-mirror state here.
data = downloader.download_url(
get_url,
task.size or "",
progress_callback,
cancel_flag,
None,
status_callback,
referer=ads_url,
)
if not data:
continue
if data.tell() < _MIN_VALID_FILE_SIZE:
logger.warning("Libgen file too small from %s, treating as failure", base)
continue
data.seek(0)
with book_path.open("wb") as file:
file.write(data.getbuffer())
return str(book_path)
except Exception as exc:
if cancel_flag.is_set():
status_callback("cancelled", "Cancelled")
else:
logger.exception("Error downloading from Libgen")
status_callback("error", str(exc))
return None
else:
# Loop exhausted without returning: every mirror failed to resolve/download.
status_callback("error", "All Libgen mirrors failed")
return None
def cancel(self, task_id: str) -> bool:
"""Cancellation is handled by the orchestrator via the cancel_flag."""
return False
+257
View File
@@ -0,0 +1,257 @@
"""Libgen catalogue scraping: search results and download-link resolution.
This is the pure fetch+parse core of the Libgen source. It talks to the libgen.li
family of mirrors (``index.php?req=`` search, ``ads.php?md5=`` download pages) using
plain HTTP -- these mirrors are not behind DDoS-Guard, so no browser/bypasser is
needed. All shelfmark-stateful behaviour lives in source.py/handler.py.
"""
import re
from http import HTTPStatus
from urllib.parse import quote, urlsplit
import requests
from bs4 import BeautifulSoup, Tag
from shelfmark.core.languages import normalize_language
from shelfmark.core.logger import setup_logger
from shelfmark.download import http as downloader
from shelfmark.download import network
from shelfmark.release_sources import BrowseRecord
logger = setup_logger(__name__)
# The libgen.li results table. Both full (9-cell) and compact (5-cell) rows live in it.
_RESULTS_TABLE_ID = "tablelibgen"
# md5 appears in the row's Mirrors cell as get.php?md5=<hash> and an AA /md5/<hash> link.
_MD5_RE = re.compile(r"md5=([0-9a-f]{32})", re.IGNORECASE)
# Patterns for the keyed GET link on an ads.php page. Kept in sync with the resolution
# libgen download has always used (direct_download._LIBGEN_GET_PATTERNS); duplicated here
# on purpose so the Libgen source stays self-contained and does not import that module's
# internals (which an in-flight upstream refactor is relocating).
_GET_KEY_PATTERNS = [
re.compile(
r'<a\s+href=["\']([^"\']*get\.php\?md5=[^"\']+&key=[^"\']+)["\'][^>]*>\s*'
r"<h2[^>]*>GET</h2>\s*</a>",
re.IGNORECASE,
),
re.compile(
r'<a[^>]+href=["\']([^"\']*get\.php\?md5=[^"\']+&(?:amp;)?key=[^"\']+)["\']',
re.IGNORECASE,
),
re.compile(
r'<a\s+href=["\']([^"\']*get\.php[^"\']*)["\'][^>]*>[\s\S]*?<h2[^>]*>GET</h2>',
re.IGNORECASE,
),
re.compile(
r'href=["\']([^"\']*get\.php\?[^"\']*md5=[^"\']*&[^"\']*key=[^"\']+)["\']',
re.IGNORECASE,
),
]
# Labels that terminate a metadata value on an ads.php page, so e.g. "Year: 2003 ISBN: ..."
# stops Year at "ISBN:" rather than swallowing it. We only emit a subset (see _parse_ads_metadata).
_METADATA_STOP_LABELS = [
"Title",
"Series",
"Author(s)",
"Publisher",
"Year",
"Language",
"Pages",
"ISBN",
"Edition",
"Extension",
"Size",
"Time added",
"ID",
"Filename",
"Description",
]
def fetch_page(url: str, timeout: tuple[int, int] = (5, 15)) -> str | None:
"""GET a libgen page, returning its text on HTTP 200 or None on any failure.
Public (not underscore-prefixed) because handler.py fetches ads.php pages through it
and tests patch it. Uses the app's proxy/SSL/DNS configuration so egress stays on
whatever network the container is bound to (the VPN namespace, in the deployed stack).
"""
# libgen.li's ads.php returns an empty 200 body to requests without a Referer (an
# anti-hotlinking check the mirrors added). A same-origin Referer is enough and is
# harmless for the search page, so send one for every fetch.
parts = urlsplit(url)
headers = {**downloader.DOWNLOAD_HEADERS, "Referer": f"{parts.scheme}://{parts.netloc}/"}
try:
response = requests.get(
url,
headers=headers,
timeout=timeout,
allow_redirects=True,
proxies=network.get_proxies(url),
verify=network.get_ssl_verify(url),
)
except requests.exceptions.RequestException as exc:
logger.debug("Libgen fetch failed for %s: %s", url, exc)
return None
if response.status_code != HTTPStatus.OK:
logger.debug("Libgen fetch %s returned %s", url, response.status_code)
return None
return response.text
def search_libgen(
query: str,
mirrors: list[str],
*,
max_results: int,
timeout: tuple[int, int] = (5, 15),
) -> list[BrowseRecord]:
"""Search each mirror's catalogue until one answers with a results table.
The first mirror that returns a parseable ``#tablelibgen`` wins -- including when that
table is empty ([] is returned as final). Mirrors can lag independently, but falling
through on every empty result would multiply latency under the shared search deadline,
so an empty-but-well-formed answer is trusted rather than re-queried elsewhere.
"""
for base in mirrors:
url = f"{base.rstrip('/')}/index.php?req={quote(query)}&res={max_results}"
html = fetch_page(url, timeout)
if html is None:
continue
records = _parse_results(html, base)
if records is not None:
return records
return []
def fetch_record_by_md5(
md5: str,
mirrors: list[str],
*,
timeout: tuple[int, int] = (5, 10),
) -> BrowseRecord | None:
"""Resolve a single record from its md5 by parsing an ads.php page's metadata.
libgen's ``index.php?req=<md5>`` does not match on md5 (req= indexes title/author/
description), so md5 -> record must go through the ads.php page instead.
"""
for base in mirrors:
html = fetch_page(f"{base.rstrip('/')}/ads.php?md5={md5}", timeout)
if html is None:
continue
record = _parse_ads_metadata(html, md5, base)
if record is not None:
return record
return None
def resolve_download_url(ads_html: str, base_url: str) -> str | None:
"""Extract the keyed get.php download URL from an ads.php page, or None."""
if "get.php" not in ads_html:
return None
for pattern in _GET_KEY_PATTERNS:
match = pattern.search(ads_html)
if not match:
continue
url = match.group(1).replace("&amp;", "&").replace("&gt;", ">").replace("&lt;", "<")
if not url.startswith("http"):
url = f"{base_url.rstrip('/')}/{url.lstrip('/')}"
return url
return None
def _cell_text(cell: Tag) -> str:
"""Cell text with runs of whitespace (incl. &nbsp; / \\xa0) collapsed to single spaces."""
return re.sub(r"\s+", " ", cell.get_text(" ", strip=True)).strip()
def _parse_results(html: str, base_url: str) -> list[BrowseRecord] | None:
"""Parse a libgen search page.
Returns None when the page has no results table (a challenge/error page -> the caller
tries the next mirror), or a list (possibly empty) when the table is present.
Row shapes vary and carry NO rowspans: full rows have 9 cells
``[Title, Author, Publisher, Year, Language, Pages, Size, Ext, Mirrors]`` and compact
rows (extra files under one edition) have 5 ``[Title, Pages, Size, Ext, Mirrors]``. The
file-level columns are stable from the right, so index from the end: Mirrors[-1] (md5),
Ext[-2], Size[-3]. Title is always [0]. Author/Language exist only on full rows.
The two shapes are the only ones libgen.li is known to emit; an unexpected width just
fails safe (author/language read as None) rather than mis-columning.
"""
soup = BeautifulSoup(html, "html.parser")
table = soup.find("table", id=_RESULTS_TABLE_ID)
if not isinstance(table, Tag):
return None
records: list[BrowseRecord] = []
for row in table.find_all("tr")[1:]: # skip the header row
cells = row.find_all("td")
if len(cells) < 5:
continue
# Scope the md5 to the Mirrors cell (last column): scanning the whole row could match
# an md5-shaped string elsewhere (e.g. a cover-image URL) and misattribute it.
md5_match = _MD5_RE.search(str(cells[-1]))
if not md5_match:
continue # spacer/section rows carry no md5
md5 = md5_match.group(1).lower()
title = _cell_text(cells[0])
fmt = _cell_text(cells[-2]).lower() or None
size = _cell_text(cells[-3]) or None
author = None
language = None
if len(cells) >= 9: # full row: middle metadata columns are present
author = _cell_text(cells[1]) or None
language = normalize_language(_cell_text(cells[4]))
records.append(
BrowseRecord(
id=md5,
title=title,
source="libgen",
author=author,
language=language,
size=size,
format=fmt,
source_url=f"{base_url.rstrip('/')}/ads.php?md5={md5}",
)
)
return records
def _parse_ads_metadata(html: str, md5: str, base_url: str) -> BrowseRecord | None:
"""Build a BrowseRecord from an ads.php page's labelled metadata.
The page's metadata lives in a deeply nested table, so read it from the visible text by
label rather than by cell position -- the labels (Title:, Series:, Author(s): ...) are
stable even though the surrounding markup is not. Returns None if the page has no title.
"""
text = re.sub(r"\s+", " ", BeautifulSoup(html, "html.parser").get_text(" ", strip=True))
def field(name: str) -> str | None:
others = "|".join(
re.escape(other) + r":" for other in _METADATA_STOP_LABELS if other != name
)
match = re.search(re.escape(name) + r":\s*(.*?)\s*(?:" + others + r"|$)", text)
value = match.group(1).strip() if match else ""
return value or None
title = field("Title")
if not title:
return None
return BrowseRecord(
id=md5,
title=title,
source="libgen",
author=field("Author(s)"),
publisher=field("Publisher"),
year=field("Year"),
language=normalize_language(field("Language") or ""),
source_url=f"{base_url.rstrip('/')}/ads.php?md5={md5}",
)
@@ -0,0 +1,34 @@
"""Libgen search settings registration."""
from shelfmark.core.settings_registry import (
CheckboxField,
NumberField,
SettingsField,
register_settings,
)
@register_settings("libgen_config", "Libgen Search", icon="download", order=46)
def libgen_config_settings() -> list[SettingsField]:
"""Libgen search configuration settings."""
return [
CheckboxField(
key="LIBGEN_SEARCH_ENABLED",
label="Enable Libgen Search",
description=(
"Search the Libgen catalogue directly, including CBZ/CBR comics and manga "
"that Anna's Archive does not index. Uses the Libgen mirrors configured "
"under Mirrors for both search and download."
),
default=False,
),
NumberField(
key="LIBGEN_SEARCH_MAX_RESULTS",
label="Max Results",
description="Maximum number of results to request per search (1-100).",
default=25,
min_value=1,
max_value=100,
show_when={"field": "LIBGEN_SEARCH_ENABLED", "value": True},
),
]
+198
View File
@@ -0,0 +1,198 @@
"""Libgen release source - searches the libgen catalogue directly.
Anna's Archive is shelfmark's only other web search source, and libgen appears there
purely as a download mirror keyed by an AA md5. This source searches libgen's own
catalogue, which surfaces content AA does not index -- most visibly CBZ/CBR comics and
manga volumes. Downloads reuse the existing ``ads.php?md5=`` resolution (see handler.py).
"""
from typing import TYPE_CHECKING, ClassVar
from shelfmark.core import mirrors
from shelfmark.core.config import config
from shelfmark.core.logger import setup_logger
from shelfmark.release_sources import (
BrowseRecord,
ColumnAlign,
ColumnColorHint,
ColumnRenderType,
ColumnSchema,
Release,
ReleaseColumnConfig,
ReleaseProtocol,
ReleaseSource,
register_source,
)
from shelfmark.release_sources.libgen import scraper
if TYPE_CHECKING:
from shelfmark.core.models import DownloadTask # noqa: F401
from shelfmark.core.search_plan import ReleaseSearchPlan
from shelfmark.metadata_providers import BookMetadata
logger = setup_logger(__name__)
_DEFAULT_MAX_RESULTS = 25
def _coerce_positive_int(value: object, default: int) -> int:
"""Return a positive integer config value or the provided default."""
if isinstance(value, bool):
return default
if isinstance(value, int) and value > 0:
return value
return default
def _build_query_candidates(plan: ReleaseSearchPlan, book: BookMetadata) -> list[str]:
"""Build ordered, de-duplicated search queries from the plan (mirrors AudiobookBay)."""
candidates: list[str] = []
if plan.manual_query:
candidates.append(plan.manual_query.strip())
elif plan.title_variants:
variant = plan.title_variants[0]
combined = f"{variant.title} {variant.author}".strip()
title_only = (variant.title or "").strip()
if combined:
candidates.append(combined)
if title_only and title_only.lower() != combined.lower():
candidates.append(title_only)
elif book.title:
candidates.append(book.title.strip())
deduped: list[str] = []
seen: set[str] = set()
for candidate in candidates:
normalized = candidate.strip()
if not normalized or normalized.lower() in seen:
continue
seen.add(normalized.lower())
deduped.append(normalized)
return deduped
@register_source("libgen")
class LibgenSource(ReleaseSource):
"""Release source that searches the libgen catalogue for downloadable files."""
name = "libgen"
display_name = "Libgen"
supported_content_types: ClassVar[list[str]] = ["ebook"] # incl. comics/manga (cbz/cbr)
def is_available(self) -> bool:
"""Available only when explicitly enabled and libgen mirrors are configured.
``is True`` rather than ``bool(...)`` matches the AudiobookBay idiom and avoids a
truthy string ever enabling network egress to an unmoderated site.
"""
return (
config.get("LIBGEN_SEARCH_ENABLED", False) is True
and mirrors.has_libgen_mirror_configuration()
)
def search(
self,
book: BookMetadata,
plan: ReleaseSearchPlan,
*,
expand_search: bool = False,
content_type: str = "ebook",
) -> list[Release]:
"""Search libgen for releases of a book."""
if content_type != "ebook":
return []
if not self.is_available():
return []
queries = _build_query_candidates(plan, book)
if not queries:
return []
max_results = _coerce_positive_int(
config.get("LIBGEN_SEARCH_MAX_RESULTS", _DEFAULT_MAX_RESULTS), _DEFAULT_MAX_RESULTS
)
mirror_list = mirrors.get_libgen_mirrors()
# One search_libgen call per candidate; it already retries every mirror internally.
# Worst case (all mirrors dead) stays within the shared search deadline.
for query in queries:
logger.info("Searching Libgen for: %s", query)
records = scraper.search_libgen(query, mirror_list, max_results=max_results)
if records:
return [self._record_to_release(record) for record in records]
return []
def _record_to_release(self, record: BrowseRecord) -> Release:
"""Normalize a libgen catalogue record into a Release.
``source_id`` is namespaced ``libgen:<md5>`` so the download queue key never
collides with a direct_download release for the same md5 (Anna's Archive heavily
indexes libgen, so the same md5 routinely appears from both sources). The handler
strips the prefix back to the bare md5.
"""
return Release(
source="libgen",
source_id=f"libgen:{record.id}",
title=record.title,
format=record.format,
language=record.language,
size=record.size,
download_url=None, # handler builds ads.php?md5= from the md5
info_url=record.source_url,
protocol=ReleaseProtocol.HTTP,
indexer="Libgen",
content_type="ebook",
extra={
"author": record.author,
"year": record.year,
"md5": record.id,
"language": record.language,
},
)
def search_results_are_releases(self) -> bool:
"""Libgen search rows are concrete, directly downloadable releases."""
return True
def get_record(
self,
record_id: str,
*,
fetch_download_count: bool = True,
) -> BrowseRecord | None:
"""Resolve a libgen record by (possibly prefixed) md5, or None if not found."""
md5 = record_id.split(":", 1)[-1].lower()
return scraper.fetch_record_by_md5(md5, mirrors.get_libgen_mirrors())
def get_column_config(self) -> ReleaseColumnConfig:
"""Language, format and size badges -- same layout as Direct Download."""
return ReleaseColumnConfig(
columns=[
ColumnSchema(
key="extra.language",
label="Language",
render_type=ColumnRenderType.BADGE,
align=ColumnAlign.CENTER,
width="60px",
color_hint=ColumnColorHint(type="map", value="language"),
uppercase=True,
),
ColumnSchema(
key="format",
label="Format",
render_type=ColumnRenderType.BADGE,
align=ColumnAlign.CENTER,
width="80px",
color_hint=ColumnColorHint(type="map", value="format"),
uppercase=True,
),
ColumnSchema(
key="size",
label="Size",
render_type=ColumnRenderType.SIZE,
align=ColumnAlign.CENTER,
width="80px",
),
],
grid_template="minmax(0,2fr) 60px 80px 80px",
supported_filters=["format", "language"],
)
+25 -9
View File
@@ -9,7 +9,12 @@ if TYPE_CHECKING:
from shelfmark.core.logger import setup_logger from shelfmark.core.logger import setup_logger
from shelfmark.core.request_helpers import normalize_optional_text from shelfmark.core.request_helpers import normalize_optional_text
from shelfmark.download.clients import DownloadClient, get_client, list_configured_clients from shelfmark.download.clients import (
DownloadClient,
client_prefers_torrent_file,
get_client,
list_configured_clients,
)
from shelfmark.download.clients.base_handler import ( from shelfmark.download.clients.base_handler import (
COMPLETED_PATH_MAX_ATTEMPTS as _DEFAULT_COMPLETED_PATH_MAX_ATTEMPTS, COMPLETED_PATH_MAX_ATTEMPTS as _DEFAULT_COMPLETED_PATH_MAX_ATTEMPTS,
) )
@@ -54,13 +59,15 @@ def _get_protocol(result: dict) -> str:
return "usenet" return "usenet"
def _get_download_url(result: dict) -> str: def _get_download_url(result: dict, *, prefer_torrent_file: bool = False) -> str:
"""Pick the best URL to hand to a download client.""" """Pick the best URL to hand to a download client."""
protocol = _get_protocol(result) protocol = _get_protocol(result)
magnet_url = str(result.get("magnetUrl") or "").strip() magnet_url = str(result.get("magnetUrl") or "").strip()
download_url = str(result.get("downloadUrl") or "").strip() download_url = str(result.get("downloadUrl") or "").strip()
if protocol == "torrent": if protocol == "torrent":
if prefer_torrent_file:
return download_url or magnet_url
return magnet_url or download_url return magnet_url or download_url
return download_url or magnet_url return download_url or magnet_url
@@ -93,9 +100,15 @@ class NewznabHandler(ExternalClientHandler):
if result is None: if result is None:
return {} return {}
protocol = _get_protocol(result)
return { return {
"retry_download_url": normalize_optional_text(_get_download_url(result)), "retry_download_url": normalize_optional_text(
"retry_download_protocol": normalize_optional_text(_get_protocol(result)), _get_download_url(
result,
prefer_torrent_file=client_prefers_torrent_file(protocol),
)
),
"retry_download_protocol": normalize_optional_text(protocol),
} }
@classmethod @classmethod
@@ -137,16 +150,19 @@ class NewznabHandler(ExternalClientHandler):
status_callback("error", "Release not found in cache (may have expired)") status_callback("error", "Release not found in cache (may have expired)")
return None return None
download_url = _get_download_url(result)
if not download_url:
status_callback("error", "No download URL available")
return None
protocol = _get_protocol(result) protocol = _get_protocol(result)
if protocol not in ("torrent", "usenet"): if protocol not in ("torrent", "usenet"):
status_callback("error", "Could not determine download protocol") status_callback("error", "Could not determine download protocol")
return None return None
download_url = _get_download_url(
result,
prefer_torrent_file=client_prefers_torrent_file(protocol),
)
if not download_url:
status_callback("error", "No download URL available")
return None
release_name = result.get("title") or task.title or "Unknown" release_name = result.get("title") or task.title or "Unknown"
expected_hash = str(result.get("infoHash") or "").strip() or None expected_hash = str(result.get("infoHash") or "").strip() or None
+29
View File
@@ -2,6 +2,7 @@
from collections.abc import Mapping from collections.abc import Mapping
from contextlib import suppress from contextlib import suppress
from datetime import UTC, datetime
from http import HTTPStatus from http import HTTPStatus
from typing import Any, TypedDict from typing import Any, TypedDict
@@ -235,6 +236,34 @@ class ProwlarrClient:
logger.exception("Failed to get indexers") logger.exception("Failed to get indexers")
return [] return []
def get_disabled_indexers(self, *, now: datetime | None = None) -> dict[int, str]:
"""Get indexers in failure back-off, keyed by ID with their disabledTill value."""
try:
entries = _normalize_json_object_list(
self._request("GET", "/api/v1/indexerstatus"),
context="Prowlarr indexer status",
)
except _PROWLARR_CLIENT_ERRORS:
logger.exception("Failed to get indexer status")
return {}
current = now or datetime.now(UTC)
disabled: dict[int, str] = {}
for entry in entries:
indexer_id = coerce_int_like(entry.get("indexerId"))
disabled_till_raw = entry.get("disabledTill")
if indexer_id is None or not disabled_till_raw:
continue
try:
disabled_till = datetime.fromisoformat(str(disabled_till_raw))
except ValueError:
continue
if disabled_till.tzinfo is None:
disabled_till = disabled_till.replace(tzinfo=UTC)
if disabled_till > current:
disabled[indexer_id] = str(disabled_till_raw)
return disabled
def get_enabled_indexers_detailed( def get_enabled_indexers_detailed(
self, *, raise_on_error: bool = False self, *, raise_on_error: bool = False
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
@@ -12,6 +12,7 @@ from shelfmark.core.search_plan import build_release_search_plan
from shelfmark.core.utils import normalize_http_url from shelfmark.core.utils import normalize_http_url
from shelfmark.download.clients import ( from shelfmark.download.clients import (
DownloadClient, DownloadClient,
client_prefers_torrent_file,
get_client, get_client,
list_configured_clients, list_configured_clients,
) )
@@ -247,18 +248,20 @@ class ProwlarrHandler(ExternalClientHandler):
status_callback("error", EXPIRED_LINK_REFRESH_ERROR) status_callback("error", EXPIRED_LINK_REFRESH_ERROR)
return None return None
# Extract download URL
download_url = get_preferred_download_url(prowlarr_result)
if not download_url:
status_callback("error", "No download URL available")
return None
# Determine protocol # Determine protocol
protocol = get_protocol(prowlarr_result) protocol = get_protocol(prowlarr_result)
if protocol == "unknown": if protocol == "unknown":
status_callback("error", "Could not determine download protocol") status_callback("error", "Could not determine download protocol")
return None return None
download_url = get_preferred_download_url(
prowlarr_result,
prefer_torrent_file=client_prefers_torrent_file(protocol),
)
if not download_url:
status_callback("error", "No download URL available")
return None
release_name = prowlarr_result.get("title") or task.title or "Unknown" release_name = prowlarr_result.get("title") or task.title or "Unknown"
expected_hash = str(prowlarr_result.get("infoHash") or "").strip() or None expected_hash = str(prowlarr_result.get("infoHash") or "").strip() or None
+29 -2
View File
@@ -12,6 +12,7 @@ if TYPE_CHECKING:
from shelfmark.core.search_plan import ReleaseSearchPlan from shelfmark.core.search_plan import ReleaseSearchPlan
from shelfmark.metadata_providers import BookMetadata from shelfmark.metadata_providers import BookMetadata
from shelfmark.core.author_match import AUTHOR_UNKNOWN, author_affinity
from shelfmark.core.config import config from shelfmark.core.config import config
from shelfmark.core.languages import normalize_language from shelfmark.core.languages import normalize_language
from shelfmark.core.logger import setup_logger from shelfmark.core.logger import setup_logger
@@ -41,8 +42,6 @@ from shelfmark.release_sources.prowlarr.api import (
) )
from shelfmark.release_sources.prowlarr.cache import cache_release from shelfmark.release_sources.prowlarr.cache import cache_release
from shelfmark.release_sources.prowlarr.utils import ( from shelfmark.release_sources.prowlarr.utils import (
AUTHOR_UNKNOWN,
author_affinity,
build_source_id, build_source_id,
coerce_float_like, coerce_float_like,
coerce_int_like, coerce_int_like,
@@ -297,6 +296,7 @@ class _IndexerSearchOutcome:
results: list[dict] results: list[dict]
attempted: int = 0 attempted: int = 0
failed: int = 0 failed: int = 0
skipped: int = 0
last_error: str | None = None last_error: str | None = None
@@ -1010,6 +1010,22 @@ class ProwlarrSource(ReleaseSource):
if time.monotonic() > deadline: if time.monotonic() > deadline:
_raise_timeout_error(f"Prowlarr search timed out after {int(search_budget)}s") _raise_timeout_error(f"Prowlarr search timed out after {int(search_budget)}s")
# Prowlarr's own search skips an indexer in failure back-off; the
# per-indexer Torznab endpoint answers 429 instead.
try:
disabled_indexers = client.get_disabled_indexers()
except _PROWLARR_SOURCE_ERRORS as e:
logger.warning("Failed to load Prowlarr indexer status: %s", e)
disabled_indexers = {}
if disabled_indexers:
logger.info(
"Prowlarr: skipping indexer(s) in failure back-off: %s",
", ".join(
f"{indexer_id} (till {till})"
for indexer_id, till in sorted(disabled_indexers.items())
),
)
def search_indexers(query: str, cats: list[int] | None) -> _IndexerSearchOutcome: def search_indexers(query: str, cats: list[int] | None) -> _IndexerSearchOutcome:
"""Search indexers with given categories via Torznab/Newznab. """Search indexers with given categories via Torznab/Newznab.
@@ -1027,6 +1043,9 @@ class ProwlarrSource(ReleaseSource):
return outcome return outcome
for indexer_id in target_indexer_ids: for indexer_id in target_indexer_ids:
if indexer_id in disabled_indexers:
outcome.skipped += 1
continue
_check_timeout() _check_timeout()
outcome.attempted += 1 outcome.attempted += 1
try: try:
@@ -1052,6 +1071,7 @@ class ProwlarrSource(ReleaseSource):
all_results: list[dict] = [] all_results: list[dict] = []
attempted_searches = 0 attempted_searches = 0
failed_searches = 0 failed_searches = 0
skipped_searches = 0
last_search_error: str | None = None last_search_error: str | None = None
for idx, variant in enumerate(variants, start=1): for idx, variant in enumerate(variants, start=1):
@@ -1070,6 +1090,7 @@ class ProwlarrSource(ReleaseSource):
if ( if (
not outcome.results not outcome.results
and not outcome.failed and not outcome.failed
and outcome.attempted
and categories and categories
and auto_expand_enabled and auto_expand_enabled
): ):
@@ -1082,11 +1103,13 @@ class ProwlarrSource(ReleaseSource):
outcome.results = expanded.results outcome.results = expanded.results
outcome.attempted += expanded.attempted outcome.attempted += expanded.attempted
outcome.failed += expanded.failed outcome.failed += expanded.failed
outcome.skipped += expanded.skipped
outcome.last_error = expanded.last_error or outcome.last_error outcome.last_error = expanded.last_error or outcome.last_error
self.last_search_type = "expanded" self.last_search_type = "expanded"
attempted_searches += outcome.attempted attempted_searches += outcome.attempted
failed_searches += outcome.failed failed_searches += outcome.failed
skipped_searches += outcome.skipped
last_search_error = outcome.last_error or last_search_error last_search_error = outcome.last_error or last_search_error
for r in outcome.results: for r in outcome.results:
@@ -1192,6 +1215,10 @@ class ProwlarrSource(ReleaseSource):
f"({last_search_error})" f"({last_search_error})"
) )
raise SourceUnavailableError(msg) raise SourceUnavailableError(msg)
if not results and not attempted_searches and skipped_searches:
until = max(disabled_indexers.values(), default="later")
msg = f"every indexer is disabled by Prowlarr after recent failures (until {until})"
raise SourceUnavailableError(msg)
return results return results
def is_available(self) -> bool: def is_available(self) -> bool:
+5 -60
View File
@@ -14,20 +14,6 @@ if TYPE_CHECKING:
_INTEGER_LIKE_PATTERN = re.compile(r"^[+-]?\d+$") _INTEGER_LIKE_PATTERN = re.compile(r"^[+-]?\d+$")
_FLOAT_LIKE_PATTERN = re.compile(r"^[+-]?(?:\d+(?:\.\d*)?|\.\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: def coerce_int_like(value: object) -> int | None:
@@ -46,49 +32,6 @@ def coerce_int_like(value: object) -> int | None:
return int(normalized) 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: def build_source_id(result: dict) -> str:
"""Build the Release.source_id for a raw Prowlarr result. """Build the Release.source_id for a raw Prowlarr result.
@@ -149,17 +92,19 @@ def get_protocol(result: dict) -> str:
return "unknown" return "unknown"
def get_preferred_download_url(result: dict) -> str: def get_preferred_download_url(result: dict, *, prefer_torrent_file: bool = False) -> str:
"""Pick the best URL to hand to a download client. """Pick the best URL to hand to a download client.
For torrent results, prefer magnetUrl when available (downloadUrl may be a For torrent results, prefer magnetUrl when available unless the configured
Prowlarr proxy URL that needs auth/headers). client needs the fetched .torrent bytes.
""" """
protocol = str(result.get("protocol", "")).lower() protocol = str(result.get("protocol", "")).lower()
magnet_url = str(result.get("magnetUrl") or "").strip() magnet_url = str(result.get("magnetUrl") or "").strip()
download_url = sanitize_download_url(str(result.get("downloadUrl") or "").strip()) download_url = sanitize_download_url(str(result.get("downloadUrl") or "").strip())
if protocol == "torrent": if protocol == "torrent":
if prefer_torrent_file:
return download_url or magnet_url
return magnet_url or download_url return magnet_url or download_url
if protocol == "usenet": if protocol == "usenet":
return download_url or magnet_url return download_url or magnet_url
+196
View File
@@ -0,0 +1,196 @@
---
name: shelfmark
description: Tool for downloading books.
license: Complete terms in LICENSE.txt
---
# Shelfmark Book Download Skill
Use this skill to search for and download books from a local Shelfmark instance using Playwright.
## Prerequisites
- Shelfmark must be running at `http://localhost:8084/`
- Use `playwright-cli` skill for browser automation capabilities
- Python 3.10+ with `playwright` package installed
## Quick Start
```bash
# Show help information for download script
python3 /home/username/.agents/skills/shelfmark/download_books.py -h
# Download a single book
python3 /home/username/.agents/skills/shelfmark/download_books.py '[{"title": "The Great Gatsby", "author": "F. Scott Fitzgerald"}]'
# Download multiple books
python3 /home/username/.agents/skills/shelfmark/download_books.py '[{"title": "The Great Gatsby", "author": "F. Scott Fitzgerald"}, {"title": "Oliver Twist", "author": "Charles Dickens"}, {"title": "Frankenstein", "author": "Marry Shelley"}]'
# Check calibre database before downloading (skip if already present)
python3 /home/username/.agents/skills/shelfmark/download_books.py --check-calibre '[{"title": "Frankenstein", "author": "Marry Shelley"}]'
# Load books from a JSON file
python3 /home/username/.agents/skills/shelfmark/download_books.py --file books.json
```
The JSON file (`books.json`) should contain an array of book objects:
```json
[
{"title": "The Great Gatsby", "author": "F. Scott Fitzgerald"},
{"title": "Frankenstein", "author": "Mary Shelley"},
{"title": "Oliver Twist", "author": "Charles Dickens"}
]
```
Array format is also supported: `[["The Great Gatsby", "F. Scott Fitzgerald"], ["Oliver Twist", "Charles Dickens"], ["Frankenstein", "Marry Shelley"]]`
Title-only (no author) is supported: `["Frankenstein"]`
## Key Characteristics
- **Shelfmark is a React SPA** — raw HTML is a shell; JavaScript dynamically populates the DOM
- **Desktop viewport required** (`1280x900`) — download buttons use `hidden sm:flex` and won't render on mobile
- **Download count pattern** in HTML: `<span>•</span> <span>NUMBER</span> </div>` (the last number before the download button)
- **Download button selector**: `<button ... data-action="download" ...>Download</button>`
- **Search input**: `<input type="search" placeholder="Search Books">`
- **Results indicator**: `<span class="text-sm font-medium whitespace-nowrap">Most relevant</span>`
## How It Works
### 1. Search for a Book
The script navigates to the main page, enters the search query, and waits for "Most relevant" to appear:
```python
# Navigate to main page first to reset SPA state
page.goto('http://localhost:8084/')
page.wait_for_load_state('networkidle')
# Enter search query and submit
page.fill('input[type="search"]', f'{title} {author}')
page.press('input[type="search"]', 'Enter')
# Wait for "Most relevant" to appear (indicates search results are fully rendered)
page.wait_for_selector('span.text-sm.font-medium:has-text("Most relevant")', timeout=60000)
```
- Always navigate to the main page before each search to reset React SPA state
- Include both title and author in the search query
- Wait for `span.text-sm.font-medium:has-text("Most relevant")` to appear — this indicates search results are fully loaded
- **Do not use timers** to wait for results — always wait for a specific page element
### 2. Find and Parse Download Buttons
```python
def parse_books(page):
btns = page.query_selector_all('button[data-action="download"]')
books = []
for i, btn in enumerate(btns):
content = btn.evaluate_handle('el => el.parentElement.parentElement').inner_html()
title_match = re.search(r'<h3[^>]*>(.*?)</h3>', content, re.IGNORECASE | re.DOTALL)
title = title_match.group(1).strip() if title_match else 'Unknown'
author_match = re.search(r'class="min-w-0 truncate text-xs[^"]*"[^>]*>(.*?)<', content, re.IGNORECASE | re.DOTALL)
author = author_match.group(1).strip() if author_match else 'Unknown'
dl_match = re.search(r'<span>•</span>\s*<span>([\d,]+)</span>', content)
downloads = int(dl_match.group(1).replace(',', '')) if dl_match else 0
books.append({'index': i, 'title': title, 'author': author, 'downloads': downloads})
return books
```
### 3. Select and Download the Book with Most Downloads
```python
# Filter books matching the search criteria
matching_books = [b for b in books if matches_search(b['title'], b['author'], title, author)]
if matching_books:
best = max(matching_books, key=lambda x: x['downloads'])
# CRITICAL: Click on the article h3 to open detail view
h3s = page.query_selector_all('article h3')
if best['index'] < len(h3s):
h3s[best['index']].click()
page.wait_for_timeout(300)
# Then click the download button
btn = page.query_selector_all('button[data-action="download"]')[best['index']]
btn.click()
page.wait_for_timeout(500)
```
**Important**: The React SPA requires clicking on the article's `<h3>` element first to open the detail view. Simply clicking the download button directly often fails silently.
### 4. Wait for Download to Complete
```python
for i in range(60):
page.wait_for_timeout(5000)
activity_text = page.inner_text('aside')
if 'IN PROGRESS' in activity_text:
print("Download started!")
elif 'Complete' in activity_text or 'Saved' in activity_text:
print("Download complete!")
break
elif 'No activity' in activity_text:
print("Download not started")
break
```
### 5. Clear Completed Downloads
```python
# Click "Clear Completed" using JavaScript
page.evaluate('''
() => {
for (const b of document.querySelectorAll('button')) {
if (b.textContent.includes('Clear Completed')) {
b.click();
return;
}
}
}
''')
page.wait_for_timeout(2000)
```
### Checking Calibre Database
Use `--check-calibre` (or `-c`) to check if books are already in your calibre database before downloading:
```bash
python3 download_books.py --check-calibre '[{"title": "Frankenstein", "author": "Mary Shelley"}]'
```
Books found in calibre are skipped with a message. If all books are already present, the script exits early without launching the browser.
## Important Notes
- **Always click the article element first** before clicking the download button — the React SPA requires this to properly initialize the download workflow
- **Wait for "Most relevant" text** to appear after search — this indicates results are fully loaded (don't use timers)
- **Navigate to main page** (`http://localhost:8084/`) before each new search to reset React SPA state
- **Some books may have different authors listed** than what's in your source file — the script falls back to title-only search if author search fails
- **The download count** is the last number in the format `• NUMBER` before the download button
- **Book titles may include series info** in brackets, e.g., `(The Locked Tomb Trilogy)`
- **Use `page.evaluate_handle`** to get parent element HTML for parsing — the button's `parentElement.parentElement` contains the card content
- **Title matching** prefers exact matches over partial matches (e.g., "Yesteryear" matches "Yesteryear: A Novel" but not "The Piers of Yesteryear")
- **Books are passed as JSON** — use `--check-calibre` to optionally skip books already in your calibre database
## Common Issues
| Issue | Solution |
|-------|----------|
| No download buttons found | Use desktop viewport (1280x900), wait for `span.text-sm.font-medium:has-text("Most relevant")` |
| Download button click does nothing | Click the article's `<h3>` element first, then click the download button |
| Search returns no results | The script falls back to title-only search automatically |
| Download count shows 0 | The parsing regex may need adjustment — check the HTML structure |
| Sidebar shows "No activity" after click | Ensure you clicked the `<h3>` element first, and wait at least 2 seconds before checking |
| Search results don't update between books | Navigate to `http://localhost:8084/` before each new search to reset SPA state |
| Book downloaded is wrong title | The script prefers exact title matches — if the title is ambiguous, the author search will help narrow it down |
| Books passed incorrectly | Books must be valid JSON — use `{"title": "...", "author": "..."}` format, not `Title: Author` |
+353
View File
@@ -0,0 +1,353 @@
#!/usr/bin/env python3
"""Shelfmark Book Downloader - Downloads books from a local Shelfmark instance."""
from playwright.sync_api import sync_playwright
import re, time, sys, argparse, json
from urllib.parse import quote
SHELFMARK_URL = 'http://localhost:8084/'
def parse_books(page):
btns = page.query_selector_all('button[data-action="download"]')
books = []
for i, btn in enumerate(btns):
content = btn.evaluate_handle('el => el.parentElement.parentElement').inner_html()
title_match = re.search(r'<h3[^>]*>(.*?)</h3>', content, re.IGNORECASE | re.DOTALL)
title = title_match.group(1).strip() if title_match else 'Unknown'
author_match = re.search(r'class="min-w-0 truncate text-xs[^"]*"[^>]*>(.*?)<', content, re.IGNORECASE | re.DOTALL)
author = author_match.group(1).strip() if author_match else 'Unknown'
dl_match = re.search(r'<span>•</span>\s*<span>([\d,]+)</span>', content)
downloads = int(dl_match.group(1).replace(',', '')) if dl_match else 0
books.append({'index': i, 'title': title, 'author': author, 'downloads': downloads})
return books
def do_search(page, title, author, search_type="author"):
"""Search with title+author, fall back to title only"""
# Navigate to main page first to reset SPA state
page.goto(SHELFMARK_URL)
page.wait_for_load_state('networkidle')
# Enter search query and submit
search_query = f'{title} {author}' if search_type == "author" else title
page.fill('input[type="search"]', search_query)
page.press('input[type="search"]', 'Enter')
# Wait for "Most relevant" to appear (indicates search results are fully rendered)
try:
page.wait_for_selector('span.text-sm.font-medium:has-text("Most relevant")', timeout=60000)
except:
if search_type == "author":
print(" -> Trying title only...")
sys.stdout.flush()
return do_search(page, title, author, search_type="title")
else:
print(" >> Timeout waiting for search results")
sys.stdout.flush()
return None
books = parse_books(page)
def clean_title_for_match(book_title, search_title):
"""Clean book title to check if it matches the search title"""
# Remove common subtitle patterns
clean = re.sub(r'\s*[:–—]\s*(A Novel|Reese\'s Book Club.*?|The Hilarious.*?|A GMA Book Club Pick.*?|Movie Tie-In.*?|eBook.*?|\[.*?\].*?)$', '', book_title, flags=re.IGNORECASE)
clean = clean.strip()
# Remove trailing punctuation
clean = clean.rstrip(':,;.')
return clean.lower().strip() == search_title.lower().strip()
def matches_search(book_title, book_author, search_title, search_author):
"""Check if book matches search criteria more strictly"""
title_match = search_title.lower() in book_title.lower()
author_match = search_author.lower() in book_author.lower()
# For title+author search, ensure title starts with search title (not just contains it)
if title_match and author_match:
return clean_title_for_match(book_title, search_title) or book_title.lower().startswith(search_title.lower())
# Also check if author name appears in reverse order (e.g., "Grann, David" matches "David Grann")
if title_match:
author_parts = search_author.lower().split()
if len(author_parts) >= 2:
reversed_author = f"{author_parts[-1]} {author_parts[0]}"
if reversed_author in book_author.lower() or book_author.lower().startswith(reversed_author):
return clean_title_for_match(book_title, search_title) or book_title.lower().startswith(search_title.lower())
return False
if search_type == "author":
matching = [b for b in books if matches_search(b['title'], b['author'], title, author)]
print(f" With author: {len(books)} total, {len(matching)} matching")
for b in books[:3]:
print(f" [{b['index']}] '{b['title']}' by {b['author']} ({b['downloads']})")
sys.stdout.flush()
if matching:
# Check if any matching book is already in the download queue
try:
sidebar_text = page.inner_text('aside')
if 'IN PROGRESS' in sidebar_text:
# Get the title of the book currently downloading
current_download = sidebar_text.split('IN PROGRESS')[1].split('—')[0].strip().split('\n')[0].strip()
# Check if the current download matches our search
if title.lower() in current_download.lower():
print(f" >> '{current_download}' already downloading, skipping")
sys.stdout.flush()
return None
except:
pass
if matching:
return matching, books
# Fall back to title only
print(" -> Trying title only...")
sys.stdout.flush()
return do_search(page, title, author, search_type="title")
# Title-only search - prefer exact matches first
matching = [b for b in books if title.lower() in b['title'].lower()]
exact_matches = [b for b in matching if clean_title_for_match(b['title'], title)]
if exact_matches:
matching = exact_matches
print(f" Title-only: {len(books)} total, {len(matching)} matching ({len(exact_matches)} exact)")
for b in books[:5]:
print(f" [{b['index']}] '{b['title']}' by {b['author']} ({b['downloads']})")
sys.stdout.flush()
if not matching:
return None
return (matching, books)
def download_book(page, title, author):
result = do_search(page, title, author)
if not result:
print("\n >>> NOT FOUND")
sys.stdout.flush()
return
matching, all_books = result
if not matching:
print("\n >>> NOT FOUND")
sys.stdout.flush()
return
# Find the best book that is not disabled
btns = page.query_selector_all('button[data-action="download"]')
best = None
for b in sorted(matching, key=lambda x: x['downloads'], reverse=True):
if b['index'] < len(btns) and not btns[b['index']].is_disabled():
best = b
break
if not best:
print("\n >>> All matching books are already in download queue")
sys.stdout.flush()
return
print(f"\n >>> DOWNLOADING: '{best['title']}' by {best['author']} ({best['downloads']} dl) [idx={best['index']}]")
sys.stdout.flush()
# Click on the article h3 to open detail view (required for download to work)
h3s = page.query_selector_all('article h3')
if best['index'] < len(h3s):
h3s[best['index']].click()
page.wait_for_timeout(300)
else:
print(f" >> Warning: h3 index {best['index']} out of range ({len(h3s)} h3s)")
sys.stdout.flush()
# Click the download button
btn = btns[best['index']]
btn.click()
# Wait a moment for the click to register
page.wait_for_timeout(500)
print(f" >> Click sent")
sys.stdout.flush()
# Check sidebar immediately
time.sleep(2)
try:
txt = page.inner_text('aside')
if 'IN PROGRESS' in txt:
print(f" >> Download started!")
sys.stdout.flush()
else:
print(f" >> Sidebar after 2s: {txt[:150]}")
sys.stdout.flush()
except:
print(f" >> Sidebar not visible after 2s")
sys.stdout.flush()
# Wait for download to complete - check sidebar every 2 seconds, max 5 minutes
last_state = None
for attempt in range(150):
time.sleep(2)
try:
txt = page.inner_text('aside')
if 'IN PROGRESS' in txt:
state = 'downloading'
elif 'Complete' in txt or 'Saved' in txt or 'No activity' in txt:
state = 'done'
else:
state = 'other'
if state != last_state:
if state == 'downloading':
print(f" >> Download started!")
elif state == 'done':
if 'No activity' in txt:
print(f" >> Download not started (no activity)")
else:
print(f" >> Download complete!")
last_state = state
if state == 'done':
break
except:
if last_state is None:
print(f" >> Sidebar not visible yet")
last_state = 'no_sidebar'
sys.stdout.flush()
else:
if last_state != 'done':
print(" >> Timeout waiting for download")
sys.stdout.flush()
def clear_completed_downloads(page):
"""Clear completed downloads from previous sessions"""
page.evaluate('''
() => {
for (const b of document.querySelectorAll('button')) {
if (b.textContent.includes('Clear Completed')) { b.click(); return; }
}
}
''')
page.wait_for_timeout(2000)
def check_calibre(title):
"""Check if a book is already in the calibre database."""
import subprocess
cmd = f'calibredb list --search title:="{title}"'
result = subprocess.run(cmd, shell=True, capture_output=True, text=True)
output = result.stdout.strip()
# calibredb output has header line "id title authors", count non-header lines
lines = [l.strip() for l in output.split('\n') if l.strip()]
# Remove header if present (first line starts with 'id')
if lines and lines[0].startswith('id'):
lines = lines[1:]
return len(lines) > 0
def load_books_from_json(json_str, source):
"""Load books from a JSON string or file path."""
try:
data = json.loads(json_str)
except json.JSONDecodeError as e:
print(f"Error: Invalid JSON in {source}: {e}")
sys.exit(1)
if not isinstance(data, list):
print(f"Error: JSON in {source} must be an array of book objects")
sys.exit(1)
books = []
for item in data:
if isinstance(item, dict):
if 'title' not in item or 'author' not in item:
print(f"Error: Each book object must have 'title' and 'author' keys: {item}")
sys.exit(1)
books.append((item['title'].strip(), item['author'].strip()))
elif isinstance(item, list) and len(item) >= 2:
books.append((str(item[0]).strip(), str(item[1]).strip()))
elif isinstance(item, str):
# Support plain strings as title-only (author will be searched separately)
books.append((item.strip(), ""))
else:
print(f"Warning: Skipping unrecognized book entry: {item}")
return books
def main():
parser = argparse.ArgumentParser(description='Download books from Shelfmark')
parser.add_argument('books', nargs='*', help='Books as JSON array of [title, author] pairs or {"title": ..., "author": ...} objects')
parser.add_argument('--file', '-f', help='Path to JSON file containing an array of books')
parser.add_argument('-c', '--check-calibre', action='store_true', help='Check calibre database before downloading, skip if already present')
args = parser.parse_args()
# Load books from --file, positional JSON arg, or both
BOOKS = []
if args.file:
try:
with open(args.file, 'r') as f:
BOOKS = load_books_from_json(f.read(), f"file '{args.file}'")
except FileNotFoundError:
print(f"Error: File not found: {args.file}")
sys.exit(1)
except IOError as e:
print(f"Error reading file: {e}")
sys.exit(1)
for book_arg in args.books:
BOOKS.extend(load_books_from_json(book_arg, 'command line argument'))
if not BOOKS:
print("Error: No books specified. Use positional JSON args or --file <path>")
sys.exit(1)
# Check calibre database if requested
if args.check_calibre:
print("\nChecking calibre database...")
sys.stdout.flush()
remaining = []
for title, author in BOOKS:
if check_calibre(title):
print(f" >> '{title}' already in calibre, skipping")
sys.stdout.flush()
else:
remaining.append((title, author))
BOOKS = remaining
if not BOOKS:
print("All books already in calibre, nothing to download.")
sys.stdout.flush()
return
print(f"\n{'='*60}")
print(f"Downloading {len(BOOKS)} book(s) from Shelfmark")
print('='*60)
sys.stdout.flush()
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page(viewport={'width': 1280, 'height': 900})
page.goto(SHELFMARK_URL)
page.wait_for_load_state('networkidle')
# Clear completed downloads at start of batch
print(f"\n{'='*60}")
print("Clearing completed downloads from previous sessions...")
print('='*60)
sys.stdout.flush()
clear_completed_downloads(page)
for i, (title, author) in enumerate(BOOKS, 1):
print(f"\n{'='*60}")
print(f"[{i}/{len(BOOKS)}] '{title}' by {author}")
print('='*60)
sys.stdout.flush()
download_book(page, title, author)
browser.close()
print("\nDone!")
sys.stdout.flush()
if __name__ == '__main__':
main()
+482 -561
View File
File diff suppressed because it is too large Load Diff
+12 -12
View File
@@ -18,23 +18,23 @@
}, },
"dependencies": { "dependencies": {
"@tailwindcss/vite": "^4.3.3", "@tailwindcss/vite": "^4.3.3",
"react": "^19.2.8", "react": "^19.3.0",
"react-dom": "^19.2.8", "react-dom": "^19.3.0",
"react-router-dom": "^7.18.2", "react-router-dom": "^7.18.3",
"socket.io-client": "^4.7.5" "socket.io-client": "^4.7.5"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^26.3.0", "@types/node": "^26.5.1",
"@types/react": "^19.2.18", "@types/react": "^19.3.0",
"@types/react-dom": "^19.2.5", "@types/react-dom": "^19.3.0",
"@vitejs/plugin-react": "^6.1.0", "@vitejs/plugin-react": "^6.1.1",
"knip": "^6.32.2", "knip": "^6.35.1",
"oxfmt": "^0.65.0", "oxfmt": "^0.68.0",
"oxlint": "^1.80.0", "oxlint": "^1.83.0",
"oxlint-tsgolint": "^7.0.2001", "oxlint-tsgolint": "^7.0.2001",
"tailwindcss": "^4.2.2", "tailwindcss": "^4.2.2",
"typescript": "^7.0.2", "typescript": "^7.0.2",
"vite": "^8.2.2", "vite": "^8.3.0",
"vitest": "^4.1.11" "vitest": "^5.0.0"
} }
} }
+112 -11
View File
@@ -41,7 +41,7 @@ import { useRequests } from './hooks/useRequests';
import { useSearch } from './hooks/useSearch'; import { useSearch } from './hooks/useSearch';
import { primeSettingsCache } from './hooks/useSettings'; import { primeSettingsCache } from './hooks/useSettings';
import { useToast } from './hooks/useToast'; import { useToast } from './hooks/useToast';
import { useUrlSearch } from './hooks/useUrlSearch'; import { useExternalHashChange, useSyncUrlSearchHash, useUrlSearch } from './hooks/useUrlSearch';
import { primeUsersCache } from './hooks/useUsersFetch'; import { primeUsersCache } from './hooks/useUsersFetch';
import { LoginPage } from './pages/LoginPage'; import { LoginPage } from './pages/LoginPage';
import { import {
@@ -93,7 +93,7 @@ import { getConfiguredMetadataProviderForContentType } from './utils/metadataPro
import { getEffectiveMetadataSort } from './utils/metadataSort'; import { getEffectiveMetadataSort } from './utils/metadataSort';
import { isRecord } from './utils/objectHelpers'; import { isRecord } from './utils/objectHelpers';
import { policyTrace } from './utils/policyTrace'; import { policyTrace } from './utils/policyTrace';
import { buildQueryTargets, getDefaultQueryTargetKey } from './utils/queryTargets'; import { buildQueryTargets, findQueryTarget, getDefaultQueryTargetKey } from './utils/queryTargets';
import { buildReleaseDownloadPayload, type ReleaseDownloadOptions } from './utils/releasePayload'; import { buildReleaseDownloadPayload, type ReleaseDownloadOptions } from './utils/releasePayload';
import { applyRequestNoteToPayload } from './utils/requestConfirmation'; import { applyRequestNoteToPayload } from './utils/requestConfirmation';
import { bookFromRequestData } from './utils/requestFulfil'; import { bookFromRequestData } from './utils/requestFulfil';
@@ -110,6 +110,8 @@ import {
applyDirectPolicyModeToButtonState, applyDirectPolicyModeToButtonState,
applyUniversalPolicyModeToButtonState, applyUniversalPolicyModeToButtonState,
} from './utils/requestPolicyUi'; } from './utils/requestPolicyUi';
import { getSearchByPreference, setSearchByPreference } from './utils/searchByPreference';
import { buildUrlSearchHash } from './utils/urlSearchHash';
// eslint-disable-next-line import/no-unassigned-import -- global app stylesheet is loaded for side effects // eslint-disable-next-line import/no-unassigned-import -- global app stylesheet is loaded for side effects
import './styles.css'; import './styles.css';
@@ -476,6 +478,7 @@ function App() {
updateAdvancedFilters, updateAdvancedFilters,
handleSearch, handleSearch,
handleResetSearch, handleResetSearch,
reSortByDownloads,
searchFieldValues, searchFieldValues,
updateSearchFieldValue, updateSearchFieldValue,
searchFieldLabels, searchFieldLabels,
@@ -484,6 +487,7 @@ function App() {
isLoadingMore, isLoadingMore,
loadMore, loadMore,
totalFound, totalFound,
directTotalResults,
resultsSourceUrl, resultsSourceUrl,
} = useSearch({ } = useSearch({
showToast, showToast,
@@ -617,7 +621,12 @@ function App() {
const [configuredCombinedMetadataProvider, setConfiguredCombinedMetadataProvider] = useState< const [configuredCombinedMetadataProvider, setConfiguredCombinedMetadataProvider] = useState<
string | null string | null
>(null); >(null);
const [activeQueryTarget, setActiveQueryTarget] = useState('general'); // Falls back to the stored "Search By" default from the user's last-used mode;
// an invalid/stale value is harmless since effectiveActiveQueryTarget below re-validates
// it against the current queryTargets once config/search fields are known.
const [activeQueryTarget, setActiveQueryTarget] = useState(
() => getSearchByPreference() || 'general',
);
const [downloadsSidebarOpen, setDownloadsSidebarOpen] = useState(false); const [downloadsSidebarOpen, setDownloadsSidebarOpen] = useState(false);
const [sidebarPinnedOpen, setSidebarPinnedOpen] = useState<boolean>(() => const [sidebarPinnedOpen, setSidebarPinnedOpen] = useState<boolean>(() =>
getInitialPinnedPreference(), getInitialPinnedPreference(),
@@ -693,8 +702,22 @@ function App() {
// URL-based search: parse URL params for automatic search on page load // URL-based search: parse URL params for automatic search on page load
const urlSearchEnabled = isAuthenticated && config !== null; const urlSearchEnabled = isAuthenticated && config !== null;
const { parsedParams, wasProcessed } = useUrlSearch({ enabled: urlSearchEnabled }); // Bumped when the hash changes to something we didn't write - a shared link pasted into
// an already-open tab. Re-parses the URL and remounts the bootstrap so it applies.
const [urlSearchNonce, setUrlSearchNonce] = useState(0);
const { parsedParams, wasProcessed } = useUrlSearch({
enabled: urlSearchEnabled,
nonce: urlSearchNonce,
});
const [hasExecutedUrlSearchBootstrap, setHasExecutedUrlSearchBootstrap] = useState(false); const [hasExecutedUrlSearchBootstrap, setHasExecutedUrlSearchBootstrap] = useState(false);
// Same fact as the state above, readable from loadConfig's async continuation, which
// closes over the render it started in and would otherwise see a stale `false`.
const urlSearchBootstrapAppliedRef = useRef(false);
useExternalHashChange(() => {
urlSearchBootstrapAppliedRef.current = false;
setHasExecutedUrlSearchBootstrap(false);
setUrlSearchNonce((value) => value + 1);
});
const prevSearchModeRef = useRef<string | undefined>(undefined); const prevSearchModeRef = useRef<string | undefined>(undefined);
@@ -825,12 +848,16 @@ function App() {
// Determine the default sort based on search mode // Determine the default sort based on search mode
const defaultSort = const defaultSort =
cfg.search_mode === 'universal' cfg.search_mode === 'universal' ? resolvedMetadataDefaultSort : (cfg.default_sort ?? '');
? resolvedMetadataDefaultSort
: cfg.default_sort || 'relevance';
if (cfg?.supported_formats) { if (cfg?.supported_formats) {
if (mode === 'initial') { // Seeding the defaults must not undo filters a shared link already applied.
// The URL bootstrap is gated on config being loaded, so normally it runs after
// this and wins on its own - but nothing guarantees this is the only 'initial'
// load (React's StrictMode double-invokes the mount effect that triggers it in
// development), and a late one would reset `formats` to the full supported list
// and drop the link's own sort.
if (mode === 'initial' && !urlSearchBootstrapAppliedRef.current) {
setAdvancedFilters((prev) => ({ setAdvancedFilters((prev) => ({
...prev, ...prev,
formats: cfg.supported_formats, formats: cfg.supported_formats,
@@ -924,6 +951,7 @@ function App() {
contentTypeOverride?: ContentType; contentTypeOverride?: ContentType;
searchModeOverride?: SearchMode; searchModeOverride?: SearchMode;
providerOverride?: string; providerOverride?: string;
sort?: string;
}) => { }) => {
void refreshRequestPolicy(); void refreshRequestPolicy();
void handleSearch({ void handleSearch({
@@ -933,6 +961,7 @@ function App() {
contentTypeOverride: opts.contentTypeOverride, contentTypeOverride: opts.contentTypeOverride,
searchMode: opts.searchModeOverride, searchMode: opts.searchModeOverride,
providerOverride: opts.providerOverride, providerOverride: opts.providerOverride,
sort: opts.sort,
}); });
}, },
[refreshRequestPolicy, handleSearch, config], [refreshRequestPolicy, handleSearch, config],
@@ -1934,6 +1963,15 @@ function App() {
return getDefaultQueryTargetKey(queryTargets); return getDefaultQueryTargetKey(queryTargets);
}, [queryTargets, activeQueryTarget]); }, [queryTargets, activeQueryTarget]);
// Persist only what the user explicitly picked in the selector. Persisting the derived
// `effectiveActiveQueryTarget` instead would overwrite the stored default with `general`
// every time it collapses for reasons the user didn't choose: a cold load before the
// metadata search fields resolve, the logo reset, logout, or a `view_series` browse.
const handleQueryTargetChange = useCallback((nextTarget: string) => {
setActiveQueryTarget(nextTarget);
setSearchByPreference(nextTarget);
}, []);
const activeQueryOption = useMemo( const activeQueryOption = useMemo(
() => () =>
queryTargets.find((target) => target.key === effectiveActiveQueryTarget) ?? queryTargets[0], queryTargets.find((target) => target.key === effectiveActiveQueryTarget) ?? queryTargets[0],
@@ -1986,6 +2024,40 @@ function App() {
return searchFieldValues[activeQueryOption.field.key] ?? ''; return searchFieldValues[activeQueryOption.field.key] ?? '';
}, [activeQueryOption, searchInput, searchFieldValues]); }, [activeQueryOption, searchInput, searchFieldValues]);
// The sort the app applies with no user choice, mirroring what loadConfig seeds
// advancedFilters.sort with - a sort equal to it is a default, not a shared intent.
const urlHashDefaultSort =
effectiveSearchMode === 'universal'
? resolvedMetadataDefaultSort
: (config?.default_sort ?? '');
// Keep the URL hash fragment live as search state changes. Gated until any URL-driven
// bootstrap has applied (or there was nothing to apply), so we don't clobber a shared
// link's params with the initial default state before they've been read.
const readyToSyncUrlHash = wasProcessed && (!parsedParams || hasExecutedUrlSearchBootstrap);
const urlSearchHash = useMemo(
() =>
buildUrlSearchHash({
queryValue: activeQueryValue,
searchBy: effectiveActiveQueryTarget,
contentType,
combinedMode,
advancedFilters,
defaultSort: urlHashDefaultSort,
defaultFormats: supportedFormats,
}),
[
activeQueryValue,
effectiveActiveQueryTarget,
contentType,
combinedMode,
advancedFilters,
urlHashDefaultSort,
supportedFormats,
],
);
useSyncUrlSearchHash({ enabled: readyToSyncUrlHash, hash: urlSearchHash });
const activeQueryValueLabel = useMemo(() => { const activeQueryValueLabel = useMemo(() => {
if (!activeQueryOption?.field) { if (!activeQueryOption?.field) {
return undefined; return undefined;
@@ -2290,6 +2362,7 @@ function App() {
fieldValues: request.fieldValues, fieldValues: request.fieldValues,
searchModeOverride: effectiveSearchMode, searchModeOverride: effectiveSearchMode,
providerOverride: request.providerOverride, providerOverride: request.providerOverride,
sort: advancedFilters.sort,
}); });
}, [ }, [
activeQueryOption, activeQueryOption,
@@ -2401,7 +2474,7 @@ function App() {
onCombinedModeChange={combinedModeAllowed ? setCombinedMode : undefined} onCombinedModeChange={combinedModeAllowed ? setCombinedMode : undefined}
queryTargets={queryTargets} queryTargets={queryTargets}
activeQueryTarget={effectiveActiveQueryTarget} activeQueryTarget={effectiveActiveQueryTarget}
onQueryTargetChange={setActiveQueryTarget} onQueryTargetChange={handleQueryTargetChange}
activeQueryField={activeQueryField} activeQueryField={activeQueryField}
/> />
</div> </div>
@@ -2468,7 +2541,7 @@ function App() {
onQueryValueChange={handleActiveQueryValueChange} onQueryValueChange={handleActiveQueryValueChange}
queryTargets={queryTargets} queryTargets={queryTargets}
activeQueryTarget={effectiveActiveQueryTarget} activeQueryTarget={effectiveActiveQueryTarget}
onQueryTargetChange={setActiveQueryTarget} onQueryTargetChange={handleQueryTargetChange}
showAdvanced={effectiveShowAdvanced} showAdvanced={effectiveShowAdvanced}
onAdvancedToggle={ onAdvancedToggle={
hasAdvancedContent ? () => setShowAdvanced(!effectiveShowAdvanced) : undefined hasAdvancedContent ? () => setShowAdvanced(!effectiveShowAdvanced) : undefined
@@ -2513,11 +2586,19 @@ function App() {
updateAdvancedFilters({ sort: request.appliedSort }); updateAdvancedFilters({ sort: request.appliedSort });
} }
setActiveResultsSort(request.appliedSort); setActiveResultsSort(request.appliedSort);
// "Most downloads" is a client-side sort — just re-sort existing books
if (request.appliedSort === 'downloads' && effectiveSearchMode === 'direct') {
reSortByDownloads();
return;
}
runSearchWithPolicyRefresh({ runSearchWithPolicyRefresh({
query: request.query, query: request.query,
fieldValues: request.fieldValues, fieldValues: request.fieldValues,
searchModeOverride: effectiveSearchMode, searchModeOverride: effectiveSearchMode,
providerOverride: request.providerOverride, providerOverride: request.providerOverride,
sort: request.appliedSort,
}); });
}} }}
metadataSortOptions={resolvedMetadataSortOptions} metadataSortOptions={resolvedMetadataSortOptions}
@@ -2527,6 +2608,7 @@ function App() {
void loadMore(config, effectiveSearchMode); void loadMore(config, effectiveSearchMode);
}} }}
totalFound={totalFound} totalFound={totalFound}
directTotalResults={directTotalResults}
onShowToast={showToast} onShowToast={showToast}
resultsSourceUrl={resultsSourceUrl} resultsSourceUrl={resultsSourceUrl}
/> />
@@ -2749,14 +2831,31 @@ function App() {
const adminSettingsWarmup = adminSettingsWarmupKey ? ( const adminSettingsWarmup = adminSettingsWarmupKey ? (
<AdminSettingsWarmupMount key={adminSettingsWarmupKey} /> <AdminSettingsWarmupMount key={adminSettingsWarmupKey} />
) : null; ) : null;
// A `search_by` deep link can name a metadata provider field that isn't in queryTargets
// until the search-fields fetch resolves. Bootstrapping before then runs the search
// against the wrong target *and* lets the sync effect rewrite the shared hash without
// `search_by`, so hold the one-shot mount until the fields have settled (the session
// resolves to null on failure, so this can't hang).
const searchFieldsSettled =
metadataConfigSessionKey === null ||
activeMetadataConfigState?.sessionKey === metadataConfigSessionKey;
const awaitingSearchByTarget = Boolean(
parsedParams?.searchBy && !findQueryTarget(queryTargets, parsedParams.searchBy),
);
const urlSearchBootstrapMount = const urlSearchBootstrapMount =
wasProcessed && parsedParams && config && !hasExecutedUrlSearchBootstrap ? ( wasProcessed &&
parsedParams &&
config &&
!hasExecutedUrlSearchBootstrap &&
(searchFieldsSettled || !awaitingSearchByTarget) ? (
<UrlSearchBootstrapMount <UrlSearchBootstrapMount
key={urlSearchNonce}
parsedParams={parsedParams} parsedParams={parsedParams}
config={config} config={config}
contentType={contentType} contentType={contentType}
combinedMode={combinedMode} combinedMode={combinedMode}
combinedModeAllowed={combinedModeAllowed} combinedModeAllowed={combinedModeAllowed}
queryTargets={queryTargets}
advancedFilters={advancedFilters} advancedFilters={advancedFilters}
resolvedMetadataDefaultSort={resolvedMetadataDefaultSort} resolvedMetadataDefaultSort={resolvedMetadataDefaultSort}
resolvedMetadataSortOptions={resolvedMetadataSortOptions} resolvedMetadataSortOptions={resolvedMetadataSortOptions}
@@ -2766,8 +2865,10 @@ function App() {
setAdvancedFilters={setAdvancedFilters} setAdvancedFilters={setAdvancedFilters}
setShowAdvanced={setShowAdvanced} setShowAdvanced={setShowAdvanced}
setActiveQueryTarget={setActiveQueryTarget} setActiveQueryTarget={setActiveQueryTarget}
setSearchFieldValue={updateSearchFieldValue}
runSearchWithPolicyRefresh={runSearchWithPolicyRefresh} runSearchWithPolicyRefresh={runSearchWithPolicyRefresh}
onComplete={() => { onComplete={() => {
urlSearchBootstrapAppliedRef.current = true;
setHasExecutedUrlSearchBootstrap(true); setHasExecutedUrlSearchBootstrap(true);
}} }}
/> />
+4 -6
View File
@@ -1,3 +1,5 @@
import { shortBuildId } from '../utils/buildVersion';
interface FooterProps { interface FooterProps {
buildVersion?: string; buildVersion?: string;
releaseVersion?: string; releaseVersion?: string;
@@ -8,11 +10,7 @@ export const Footer = ({ buildVersion, releaseVersion, debug }: FooterProps) =>
// Determine version display - show "dev" if no version is set // Determine version display - show "dev" if no version is set
const versionDisplay = releaseVersion && releaseVersion !== 'N/A' ? releaseVersion : 'dev'; const versionDisplay = releaseVersion && releaseVersion !== 'N/A' ? releaseVersion : 'dev';
// Truncate long build versions (e.g., git hashes) to 7 chars const buildId = shortBuildId(buildVersion);
let truncatedBuild: string | null = null;
if (buildVersion && buildVersion !== 'N/A') {
truncatedBuild = buildVersion.length > 7 ? buildVersion.slice(0, 7) : buildVersion;
}
return ( return (
<footer <footer
@@ -35,7 +33,7 @@ export const Footer = ({ buildVersion, releaseVersion, debug }: FooterProps) =>
title={buildVersion && buildVersion !== 'N/A' ? `Build: ${buildVersion}` : undefined} title={buildVersion && buildVersion !== 'N/A' ? `Build: ${buildVersion}` : undefined}
> >
{versionDisplay} {versionDisplay}
{truncatedBuild && ` (${truncatedBuild})`} {buildId && ` (${buildId})`}
</span> </span>
{debug && ( {debug && (
<span <span
+30 -2
View File
@@ -1769,7 +1769,7 @@ const ReleaseModalSession = ({
</div> </div>
)} )}
{!sourcesLoading && allTabs.length > 0 && ( {!sourcesLoading && allTabs.length > 0 && (
<div className="flex items-center justify-between px-5"> <div className="flex items-center px-5">
{/* Tabs - scrollable on narrow screens */} {/* Tabs - scrollable on narrow screens */}
<div className="scrollbar-hide min-w-0 flex-1 overflow-x-auto"> <div className="scrollbar-hide min-w-0 flex-1 overflow-x-auto">
<div className="relative flex gap-1"> <div className="relative flex gap-1">
@@ -1801,7 +1801,35 @@ const ReleaseModalSession = ({
</div> </div>
</div> </div>
<div className="flex items-center gap-3 pr-1 pl-2"> {/* Center: Results count from release sources */}
<div className="flex flex-1 justify-center">
{(() => {
const searchInfo = releasesBySource[activeTab]?.search_info?.[activeTab];
const totalCount = searchInfo?.total_results;
if (totalCount === undefined || totalCount === null || totalCount === 0)
return null;
const isCapped = totalCount === '500+';
const totalCountNum = isCapped ? 500 : Number(totalCount);
// AA-style: show page range + total
if (totalCountNum === 1) {
return (
<span className="mx-2 shrink-0 text-xs whitespace-nowrap text-gray-500 dark:text-gray-400">
Result 1 (1 Total)
</span>
);
}
const shownEnd = Math.min(totalCountNum, 50);
const totalStr = isCapped ? '500+' : String(totalCountNum);
return (
<span className="mx-2 shrink-0 text-xs whitespace-nowrap text-gray-500 dark:text-gray-400">
Results 1-{shownEnd} ({totalStr} Total)
</span>
);
})()}
</div>
{/* Right: Controls */}
<div className="ml-auto flex items-center gap-3 pr-1 pl-2">
{/* Multi-book pack toggle (fallback for releases that can't be inspected) */} {/* Multi-book pack toggle (fallback for releases that can't be inspected) */}
{!isCombinedMode && ( {!isCombinedMode && (
<button <button
+58 -27
View File
@@ -33,6 +33,8 @@ interface ResultsSectionProps {
isLoadingMore?: boolean; isLoadingMore?: boolean;
onLoadMore?: () => void; onLoadMore?: () => void;
totalFound?: number; totalFound?: number;
// Direct mode: total result count from release sources
directTotalResults?: number | string | null;
onShowToast?: (message: string, type: 'success' | 'error' | 'info') => void; onShowToast?: (message: string, type: 'success' | 'error' | 'info') => void;
resultsSourceUrl?: string; resultsSourceUrl?: string;
} }
@@ -53,6 +55,7 @@ export const ResultsSection = ({
isLoadingMore, isLoadingMore,
onLoadMore, onLoadMore,
totalFound, totalFound,
directTotalResults,
onShowToast, onShowToast,
resultsSourceUrl, resultsSourceUrl,
}: ResultsSectionProps) => { }: ResultsSectionProps) => {
@@ -88,34 +91,62 @@ export const ResultsSection = ({
return ( return (
<section id="results-section" className="mb-4 w-full sm:mb-8"> <section id="results-section" className="mb-4 w-full sm:mb-8">
<div className="relative z-10 mb-2 flex items-center justify-between sm:mb-3"> <div className="relative z-10 mb-2 flex items-center sm:mb-3">
{showSortControl && ( {/* Left: Sort control or Hardcover link */}
<SortControl <div>
value={sortValue} {showSortControl && (
onChange={onSortChange} <SortControl
metadataSortOptions={metadataSortOptions} value={sortValue}
/> onChange={onSortChange}
)} metadataSortOptions={metadataSortOptions}
{!showSortControl && resultsSourceUrl && ( />
<a )}
href={resultsSourceUrl} {!showSortControl && resultsSourceUrl && (
target="_blank" <a
rel="noopener noreferrer" href={resultsSourceUrl}
className="animate-pop-up inline-flex items-center gap-1.5 px-3 py-2 text-sm font-medium text-gray-600 transition-colors hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-200" target="_blank"
> rel="noopener noreferrer"
View list on Hardcover className="animate-pop-up inline-flex items-center gap-1.5 px-3 py-2 text-sm font-medium text-gray-600 transition-colors hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-200"
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> >
<path View list on Hardcover
strokeLinecap="round" <svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
strokeLinejoin="round" <path
strokeWidth={2} strokeLinecap="round"
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" strokeLinejoin="round"
/> strokeWidth={2}
</svg> d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
</a> />
)} </svg>
</a>
)}
</div>
{/* View toggle buttons - Desktop: show all 3, Mobile: show Compact and List only */} {/* Center: Results count */}
<div className="flex flex-1 justify-center">
{(() => {
const count = directTotalResults ?? null;
if (count === null || count === 0) return null;
const isCapped = directTotalResults === '500+';
const totalCount = isCapped ? 500 : Number(count);
// AA-style: show page range + total
if (totalCount === 1) {
return (
<span className="mx-2 mt-4 px-2 text-xs whitespace-nowrap text-gray-500 dark:text-gray-400">
Result 1 (1 Total)
</span>
);
}
const shownEnd = Math.min(totalCount, 50);
const totalStr = isCapped ? '500+' : String(totalCount);
return (
<span className="mx-2 mt-4 px-2 text-xs whitespace-nowrap text-gray-500 dark:text-gray-400">
Results 1-{shownEnd} ({totalStr} Total)
</span>
);
})()}
</div>
{/* Right: View toggle buttons - Desktop: show all 3, Mobile: show Compact and List only */}
<div className="ml-auto flex items-center gap-2"> <div className="ml-auto flex items-center gap-2">
{isDesktop && ( {isDesktop && (
<button <button
@@ -1,11 +1,19 @@
import type { Dispatch, SetStateAction } from 'react'; import type { Dispatch, SetStateAction } from 'react';
import { useMountEffect } from '@/hooks/useMountEffect'; import { useMountEffect } from '@/hooks/useMountEffect';
import type { AppConfig, AdvancedFilterState, ContentType, SearchMode, SortOption } from '@/types'; import type {
AppConfig,
AdvancedFilterState,
ContentType,
QueryTargetOption,
SearchMode,
SortOption,
} from '@/types';
import { buildSearchQuery } from '@/utils/buildSearchQuery'; import { buildSearchQuery } from '@/utils/buildSearchQuery';
import { resolveDefaultLanguageCodes } from '@/utils/languageFilters'; import { resolveDefaultLanguageCodes } from '@/utils/languageFilters';
import { getEffectiveMetadataSort } from '@/utils/metadataSort'; import { getEffectiveMetadataSort } from '@/utils/metadataSort';
import type { ParsedUrlSearch } from '@/utils/parseUrlSearchParams'; import type { ParsedUrlSearch } from '@/utils/parseUrlSearchParams';
import { findQueryTarget } from '@/utils/queryTargets';
const ADVANCED_FILTER_VISIBILITY_KEYS = ['content', 'lang', 'formats'] as const; const ADVANCED_FILTER_VISIBILITY_KEYS = ['content', 'lang', 'formats'] as const;
@@ -15,6 +23,7 @@ interface UrlSearchBootstrapMountProps {
contentType: ContentType; contentType: ContentType;
combinedMode: boolean; combinedMode: boolean;
combinedModeAllowed: boolean; combinedModeAllowed: boolean;
queryTargets: QueryTargetOption[];
advancedFilters: AdvancedFilterState; advancedFilters: AdvancedFilterState;
resolvedMetadataDefaultSort: string; resolvedMetadataDefaultSort: string;
resolvedMetadataSortOptions: SortOption[]; resolvedMetadataSortOptions: SortOption[];
@@ -24,10 +33,13 @@ interface UrlSearchBootstrapMountProps {
setAdvancedFilters: Dispatch<SetStateAction<AdvancedFilterState>>; setAdvancedFilters: Dispatch<SetStateAction<AdvancedFilterState>>;
setShowAdvanced: (value: boolean) => void; setShowAdvanced: (value: boolean) => void;
setActiveQueryTarget: (value: string) => void; setActiveQueryTarget: (value: string) => void;
setSearchFieldValue: (key: string, value: string | number | boolean, label?: string) => void;
runSearchWithPolicyRefresh: (opts: { runSearchWithPolicyRefresh: (opts: {
query: string; query: string;
contentTypeOverride?: ContentType; contentTypeOverride?: ContentType;
searchModeOverride?: SearchMode; searchModeOverride?: SearchMode;
fieldValues?: Record<string, string | number | boolean>;
sort?: string;
}) => void; }) => void;
onComplete: () => void; onComplete: () => void;
} }
@@ -38,6 +50,7 @@ export const UrlSearchBootstrapMount = ({
contentType, contentType,
combinedMode, combinedMode,
combinedModeAllowed, combinedModeAllowed,
queryTargets,
advancedFilters, advancedFilters,
resolvedMetadataDefaultSort, resolvedMetadataDefaultSort,
resolvedMetadataSortOptions, resolvedMetadataSortOptions,
@@ -47,6 +60,7 @@ export const UrlSearchBootstrapMount = ({
setAdvancedFilters, setAdvancedFilters,
setShowAdvanced, setShowAdvanced,
setActiveQueryTarget, setActiveQueryTarget,
setSearchFieldValue,
runSearchWithPolicyRefresh, runSearchWithPolicyRefresh,
onComplete, onComplete,
}: UrlSearchBootstrapMountProps) => { }: UrlSearchBootstrapMountProps) => {
@@ -69,6 +83,15 @@ export const UrlSearchBootstrapMount = ({
setCombinedMode(false); setCombinedMode(false);
} }
const urlSearchByTarget = findQueryTarget(queryTargets, parsedParams.searchBy);
const urlSearchByOverride = urlSearchByTarget?.key;
// Search By target can be deep-linked on its own (e.g. `#search_by=manual`, no query),
// so apply it even when there's nothing else to search for.
if (urlSearchByOverride) {
setActiveQueryTarget(urlSearchByOverride);
}
if (!parsedParams.hasSearchParams) { if (!parsedParams.hasSearchParams) {
return; return;
} }
@@ -79,12 +102,8 @@ export const UrlSearchBootstrapMount = ({
bookLanguages, bookLanguages,
); );
if (parsedParams.searchInput) { let nextQueryTarget = urlSearchByOverride || 'general';
setSearchInput(parsedParams.searchInput); if (parsedSearchMode === 'direct' && !urlSearchByOverride) {
}
let nextQueryTarget = 'general';
if (parsedSearchMode === 'direct') {
if (parsedParams.advancedFilters.isbn) { if (parsedParams.advancedFilters.isbn) {
nextQueryTarget = 'isbn'; nextQueryTarget = 'isbn';
} else if (parsedParams.advancedFilters.author) { } else if (parsedParams.advancedFilters.author) {
@@ -95,6 +114,39 @@ export const UrlSearchBootstrapMount = ({
} }
setActiveQueryTarget(nextQueryTarget); setActiveQueryTarget(nextQueryTarget);
// Route `q` through the active target, mirroring how the live search dispatch reads it:
// direct fields and text fields are typed into searchInput, other provider fields are
// dispatched as fieldValues. Legacy links carry the value under the field's own param
// (`?author=herbert`) instead, so fall back to that.
const targetKey = urlSearchByTarget?.field?.key ?? nextQueryTarget;
const legacyDirectValue =
targetKey === 'isbn' || targetKey === 'author' || targetKey === 'title'
? parsedParams.advancedFilters[targetKey]
: undefined;
const targetQueryValue = parsedParams.searchInput || legacyDirectValue || '';
const usesProviderFieldValue =
urlSearchByTarget?.source === 'provider-field' &&
urlSearchByTarget.field !== undefined &&
urlSearchByTarget.field.type !== 'TextSearchField';
if (usesProviderFieldValue && urlSearchByTarget?.field && targetQueryValue) {
setSearchFieldValue(urlSearchByTarget.field.key, targetQueryValue);
} else if (targetQueryValue) {
setSearchInput(targetQueryValue);
}
const urlFieldValues =
urlSearchByTarget?.source === 'provider-field' && urlSearchByTarget.field && targetQueryValue
? { [urlSearchByTarget.field.key]: targetQueryValue }
: undefined;
// Manual mode opens the release browser off an explicit submit - it has no results
// list to bootstrap, so a deep link fills the input and stops there.
if (urlSearchByTarget?.source === 'manual') {
return;
}
const resolvedUrlMetadataSort = const resolvedUrlMetadataSort =
parsedSearchMode === 'universal' parsedSearchMode === 'universal'
? getEffectiveMetadataSort({ ? getEffectiveMetadataSort({
@@ -134,16 +186,13 @@ export const UrlSearchBootstrapMount = ({
}; };
const query = buildSearchQuery({ const query = buildSearchQuery({
searchInput: searchInput: nextQueryTarget === 'general' ? targetQueryValue : '',
parsedSearchMode === 'direct' && nextQueryTarget !== 'general'
? ''
: parsedParams.searchInput,
showAdvanced: true, showAdvanced: true,
advancedFilters: { advancedFilters: {
...mergedFilters, ...mergedFilters,
isbn: nextQueryTarget === 'isbn' ? parsedParams.advancedFilters.isbn || '' : '', isbn: nextQueryTarget === 'isbn' ? targetQueryValue : '',
author: nextQueryTarget === 'author' ? parsedParams.advancedFilters.author || '' : '', author: nextQueryTarget === 'author' ? targetQueryValue : '',
title: nextQueryTarget === 'title' ? parsedParams.advancedFilters.title || '' : '', title: nextQueryTarget === 'title' ? targetQueryValue : '',
}, },
bookLanguages, bookLanguages,
defaultLanguage: defaultLanguageCodes, defaultLanguage: defaultLanguageCodes,
@@ -154,6 +203,8 @@ export const UrlSearchBootstrapMount = ({
query, query,
contentTypeOverride: urlContentTypeOverride, contentTypeOverride: urlContentTypeOverride,
searchModeOverride: parsedSearchMode, searchModeOverride: parsedSearchMode,
fieldValues: urlFieldValues,
sort: parsedParams?.advancedFilters?.sort,
}); });
}); });
@@ -1,4 +1,5 @@
import type { Book, RequestRecord, StatusData } from '../../types'; import type { Book, RequestRecord, StatusData } from '../../types';
import { getDownloadsCount } from '../../types';
import { STATUS_LABELS, isActiveDownloadStatus } from './activityStyles.js'; import { STATUS_LABELS, isActiveDownloadStatus } from './activityStyles.js';
import type { ActivityItem, ActivityVisualStatus } from './activityTypes'; import type { ActivityItem, ActivityVisualStatus } from './activityTypes';
@@ -87,10 +88,14 @@ export const downloadToActivityItem = (book: Book, statusKey: DownloadStatusKey)
typeof book.request_id === 'number' && Number.isFinite(book.request_id) && book.request_id > 0 typeof book.request_id === 'number' && Number.isFinite(book.request_id) && book.request_id > 0
? Math.trunc(book.request_id) ? Math.trunc(book.request_id)
: undefined; : undefined;
const downloadsCount = getDownloadsCount(book);
const downloadsText =
downloadsCount != null ? `${downloadsCount.toLocaleString()} downloads` : undefined;
const metaLine = joinMetaParts([ const metaLine = joinMetaParts([
toOptionalText(book.format)?.toUpperCase(), toOptionalText(book.format)?.toUpperCase(),
toOptionalText(book.size), toOptionalText(book.size),
toOptionalText(book.source_display_name) || toSourceLabel(book.source), toOptionalText(book.source_display_name) || toSourceLabel(book.source),
downloadsText,
toOptionalText(book.username), toOptionalText(book.username),
]); ]);
const progress = getDownloadProgress(visualStatus, book.progress); const progress = getDownloadProgress(visualStatus, book.progress);
@@ -115,6 +120,7 @@ export const downloadToActivityItem = (book: Book, statusKey: DownloadStatusKey)
downloadRetryAvailable, downloadRetryAvailable,
downloadPath: toOptionalText(book.download_path), downloadPath: toOptionalText(book.download_path),
sizeRaw: toOptionalText(book.size), sizeRaw: toOptionalText(book.size),
downloads: downloadsCount ?? undefined,
requestId, requestId,
}; };
}; };
@@ -32,6 +32,7 @@ export interface ActivityItem {
progress?: number; progress?: number;
progressAnimated?: boolean; progressAnimated?: boolean;
sizeRaw?: string; sizeRaw?: string;
downloads?: number;
timestamp: number; timestamp: number;
username?: string; username?: string;
@@ -2,6 +2,7 @@ import { useState } from 'react';
import { useSearchMode } from '../../contexts/SearchModeContext'; import { useSearchMode } from '../../contexts/SearchModeContext';
import type { Book, ButtonStateInfo } from '../../types'; import type { Book, ButtonStateInfo } from '../../types';
import { getDownloadsCount } from '../../types';
import { bookSupportsTargets } from '../../utils/bookTargetLoader'; import { bookSupportsTargets } from '../../utils/bookTargetLoader';
import { BookActionButton } from '../BookActionButton'; import { BookActionButton } from '../BookActionButton';
import { BookTargetDropdown } from '../BookTargetDropdown'; import { BookTargetDropdown } from '../BookTargetDropdown';
@@ -210,6 +211,16 @@ export const CardView = ({
<span>{book.size}</span> <span>{book.size}</span>
</> </>
)} )}
{searchMode !== 'universal' &&
(() => {
const d = getDownloadsCount(book);
return d != null && d > 0 ? (
<>
{' '}
<span>•</span> <span>{d.toLocaleString()}</span>{' '}
</>
) : null;
})()}
</div> </div>
)} )}
</div> </div>
@@ -2,6 +2,7 @@ import { useState } from 'react';
import { useSearchMode } from '../../contexts/SearchModeContext'; import { useSearchMode } from '../../contexts/SearchModeContext';
import type { Book, ButtonStateInfo } from '../../types'; import type { Book, ButtonStateInfo } from '../../types';
import { getDownloadsCount } from '../../types';
import { bookSupportsTargets } from '../../utils/bookTargetLoader'; import { bookSupportsTargets } from '../../utils/bookTargetLoader';
import { BookActionButton } from '../BookActionButton'; import { BookActionButton } from '../BookActionButton';
import { BookTargetDropdown } from '../BookTargetDropdown'; import { BookTargetDropdown } from '../BookTargetDropdown';
@@ -221,6 +222,15 @@ export const CompactView = ({
<span>{book.size}</span> <span>{book.size}</span>
</> </>
)} )}
{(() => {
const d = getDownloadsCount(book);
return d != null && d > 0 ? (
<>
{' '}
<span>•</span> <span>{d.toLocaleString()}</span>{' '}
</>
) : null;
})()}
</div> </div>
)} )}
@@ -2,6 +2,7 @@ import { useState } from 'react';
import { useSearchMode } from '../../contexts/SearchModeContext'; import { useSearchMode } from '../../contexts/SearchModeContext';
import type { Book, ButtonStateInfo, DisplayField } from '../../types'; import type { Book, ButtonStateInfo, DisplayField } from '../../types';
import { getDownloadsCount } from '../../types';
import { bookSupportsTargets } from '../../utils/bookTargetLoader'; import { bookSupportsTargets } from '../../utils/bookTargetLoader';
import { getFormatColor, getLanguageColor } from '../../utils/colorMaps'; import { getFormatColor, getLanguageColor } from '../../utils/colorMaps';
import { BookActionButton } from '../BookActionButton'; import { BookActionButton } from '../BookActionButton';
@@ -167,7 +168,7 @@ export const ListView = ({
className={`grid w-full items-center gap-2 sm:gap-x-0.5 sm:gap-y-1 ${ className={`grid w-full items-center gap-2 sm:gap-x-0.5 sm:gap-y-1 ${
searchMode === 'universal' searchMode === 'universal'
? 'grid-cols-[auto_minmax(0,1fr)_auto_auto] sm:grid-cols-[auto_minmax(0,2fr)_minmax(50px,0.25fr)_minmax(90px,0.5fr)_minmax(90px,0.5fr)_minmax(120px,0.7fr)_auto]' ? 'grid-cols-[auto_minmax(0,1fr)_auto_auto] sm:grid-cols-[auto_minmax(0,2fr)_minmax(50px,0.25fr)_minmax(90px,0.5fr)_minmax(90px,0.5fr)_minmax(120px,0.7fr)_auto]'
: 'grid-cols-[auto_minmax(0,1fr)_auto_auto] sm:grid-cols-[auto_minmax(0,2fr)_minmax(50px,0.25fr)_minmax(60px,0.3fr)_minmax(60px,0.3fr)_minmax(60px,0.3fr)_auto]' : 'grid-cols-[auto_minmax(0,1fr)_auto_auto] sm:grid-cols-[auto_minmax(0,2fr)_minmax(50px,0.25fr)_minmax(60px,0.3fr)_minmax(60px,0.3fr)_minmax(60px,0.3fr)_minmax(70px,0.35fr)_auto]'
}`} }`}
> >
{/* Thumbnail */} {/* Thumbnail */}
@@ -287,6 +288,16 @@ export const ListView = ({
</div> </div>
)} )}
{/* Direct mode: Downloads - Desktop only */}
{searchMode !== 'universal' && (
<div className="hidden justify-center text-xs text-gray-700 sm:flex dark:text-gray-200">
{(() => {
const d = getDownloadsCount(book);
return d != null && d > 0 ? d.toLocaleString() : '-';
})()}
</div>
)}
{/* Action Buttons */} {/* Action Buttons */}
<div className="flex flex-row justify-end gap-0.5 sm:gap-1 sm:pr-3"> <div className="flex flex-row justify-end gap-0.5 sm:gap-1 sm:pr-3">
{bookSupportsTargets(book) && targetProvider && targetBookId && ( {bookSupportsTargets(book) && targetProvider && targetBookId && (
@@ -4,6 +4,7 @@ import type { TextFieldConfig } from '../../../types/settings';
import { import {
buildNamingTemplatePreview, buildNamingTemplatePreview,
NAMING_TEMPLATE_TOKENS, NAMING_TEMPLATE_TOKENS,
resolveWordSeparator,
type NamingTemplateContent, type NamingTemplateContent,
type NamingTemplateMode, type NamingTemplateMode,
type NamingTemplateToken, type NamingTemplateToken,
@@ -66,7 +67,8 @@ export const NamingTemplateField = ({
(token) => !token.audiobookOnly || content === 'audiobook', (token) => !token.audiobookOnly || content === 'audiobook',
); );
const tokenGroups = groupTokens(availableTokens); const tokenGroups = groupTokens(availableTokens);
const preview = buildNamingTemplatePreview(value, mode, content); const wordSeparator = resolveWordSeparator(values.NAMING_WORD_SEPARATOR);
const preview = buildNamingTemplatePreview(value, mode, content, wordSeparator);
const hasPathSeparatorInFilename = mode === 'filename' && /[\\/]/.test(value); const hasPathSeparatorInFilename = mode === 'filename' && /[\\/]/.test(value);
const insertToken = (token: string) => { const insertToken = (token: string) => {
+2 -1
View File
@@ -1,6 +1,7 @@
// Direct download mode sort options // Direct download mode sort options
export const SORT_OPTIONS = [ export const SORT_OPTIONS = [
{ value: '', label: 'Most relevant' }, { value: 'downloads', label: 'Most downloads' },
{ value: 'relevance', label: 'Most relevant' },
{ value: 'newest', label: 'Newest (publication year)' }, { value: 'newest', label: 'Newest (publication year)' },
{ value: 'oldest', label: 'Oldest (publication year)' }, { value: 'oldest', label: 'Oldest (publication year)' },
{ value: 'largest', label: 'Largest (filesize)' }, { value: 'largest', label: 'Largest (filesize)' },
@@ -288,6 +288,7 @@ export function useReleaseSearchSession(
[tabName]: { [tabName]: {
...existing, ...existing,
releases: [...existing.releases, ...mergedReleases], releases: [...existing.releases, ...mergedReleases],
search_info: response.search_info,
}, },
}; };
}); });
+1
View File
@@ -103,6 +103,7 @@ const parseHistoryBook = (value: unknown): Book | null => {
...(typeof value.retry_available === 'boolean' ...(typeof value.retry_available === 'boolean'
? { retry_available: value.retry_available } ? { retry_available: value.retry_available }
: {}), : {}),
...(typeof value.downloads === 'number' ? { downloads: value.downloads } : {}),
}; };
}; };
+50 -6
View File
@@ -39,9 +39,11 @@ interface UseSearchReturn {
contentTypeOverride?: ContentType; contentTypeOverride?: ContentType;
searchMode?: SearchMode; searchMode?: SearchMode;
providerOverride?: string; providerOverride?: string;
sort?: string;
}) => Promise<void>; }) => Promise<void>;
handleResetSearch: (config: AppConfig | null) => void; handleResetSearch: (config: AppConfig | null) => void;
resetSortFilter: () => void; resetSortFilter: () => void;
reSortByDownloads: () => void;
// Universal mode search field values // Universal mode search field values
searchFieldValues: SearchFieldValues; searchFieldValues: SearchFieldValues;
updateSearchFieldValue: (key: string, value: string | number | boolean, label?: string) => void; updateSearchFieldValue: (key: string, value: string | number | boolean, label?: string) => void;
@@ -51,6 +53,8 @@ interface UseSearchReturn {
isLoadingMore: boolean; isLoadingMore: boolean;
loadMore: (config: AppConfig | null, searchMode?: SearchMode) => Promise<void>; loadMore: (config: AppConfig | null, searchMode?: SearchMode) => Promise<void>;
totalFound: number; totalFound: number;
// Direct mode: total result count from release sources (e.g., "500+" for capped)
directTotalResults: number | string | null;
// Source URL and title for the current result set (e.g. Hardcover list page) // Source URL and title for the current result set (e.g. Hardcover list page)
resultsSourceUrl: string | undefined; resultsSourceUrl: string | undefined;
resultsSourceTitle: string | undefined; resultsSourceTitle: string | undefined;
@@ -90,6 +94,8 @@ export function useSearch(options: UseSearchOptions): UseSearchReturn {
const [hasMore, setHasMore] = useState(false); const [hasMore, setHasMore] = useState(false);
const [isLoadingMore, setIsLoadingMore] = useState(false); const [isLoadingMore, setIsLoadingMore] = useState(false);
const [totalFound, setTotalFound] = useState(0); const [totalFound, setTotalFound] = useState(0);
// Direct mode: total result count from release sources (e.g., Anna's Archive)
const [directTotalResults, setDirectTotalResults] = useState<number | string | null>(null);
const [resultsSourceUrl, setResultsSourceUrl] = useState<string | undefined>(); const [resultsSourceUrl, setResultsSourceUrl] = useState<string | undefined>();
const [resultsSourceTitle, setResultsSourceTitle] = useState<string | undefined>(); const [resultsSourceTitle, setResultsSourceTitle] = useState<string | undefined>();
@@ -106,6 +112,24 @@ export function useSearch(options: UseSearchOptions): UseSearchReturn {
setAdvancedFilters((prev) => ({ ...prev, ...updates })); setAdvancedFilters((prev) => ({ ...prev, ...updates }));
}, []); }, []);
// Sort books by downloads descending (no downloads go to the end)
const sortBooksByDownloads = useCallback((bookList: Book[]): Book[] => {
const sorted = bookList.toSorted((a, b) => {
const aDownloads = a.downloads ?? 0;
const bDownloads = b.downloads ?? 0;
// Books with downloads come first, sorted by count descending
if (aDownloads > 0 && bDownloads === 0) return -1;
if (aDownloads === 0 && bDownloads > 0) return 1;
return bDownloads - aDownloads;
});
return sorted;
}, []);
// Re-sort current books by downloads descending (client-side only, no search)
const reSortByDownloads = useCallback(() => {
setBooks((prev) => sortBooksByDownloads(prev));
}, [sortBooksByDownloads]);
const updateSearchFieldValue = useCallback( const updateSearchFieldValue = useCallback(
(key: string, value: string | number | boolean, label?: string) => { (key: string, value: string | number | boolean, label?: string) => {
setSearchFieldValues((prev) => ({ ...prev, [key]: value })); setSearchFieldValues((prev) => ({ ...prev, [key]: value }));
@@ -157,6 +181,7 @@ export function useSearch(options: UseSearchOptions): UseSearchReturn {
contentTypeOverride, contentTypeOverride,
searchMode: searchModeOverride, searchMode: searchModeOverride,
providerOverride, providerOverride,
sort,
}: { }: {
query: string; query: string;
config: AppConfig | null; config: AppConfig | null;
@@ -164,6 +189,7 @@ export function useSearch(options: UseSearchOptions): UseSearchReturn {
contentTypeOverride?: ContentType; contentTypeOverride?: ContentType;
searchMode?: SearchMode; searchMode?: SearchMode;
providerOverride?: string; providerOverride?: string;
sort?: string;
}) => { }) => {
const effectiveContentType = contentTypeOverride ?? contentType; const effectiveContentType = contentTypeOverride ?? contentType;
const searchMode = (searchModeOverride ?? config?.search_mode) || 'universal'; const searchMode = (searchModeOverride ?? config?.search_mode) || 'universal';
@@ -177,7 +203,7 @@ export function useSearch(options: UseSearchOptions): UseSearchReturn {
const hasFieldValues = Object.values(effectiveFieldValues).some( const hasFieldValues = Object.values(effectiveFieldValues).some(
(v) => v !== '' && v !== false, (v) => v !== '' && v !== false,
); );
const sort = params.get('sort') || 'relevance'; const sortOrder = params.get('sort') || 'relevance';
if (!searchQuery && !hasFieldValues) { if (!searchQuery && !hasFieldValues) {
setBooks([]); setBooks([]);
@@ -202,7 +228,7 @@ export function useSearch(options: UseSearchOptions): UseSearchReturn {
const result = await searchMetadata( const result = await searchMetadata(
searchQuery, searchQuery,
40, 40,
sort, sortOrder,
effectiveFieldValues, effectiveFieldValues,
1, 1,
effectiveContentType, effectiveContentType,
@@ -221,7 +247,7 @@ export function useSearch(options: UseSearchOptions): UseSearchReturn {
// Store params for loadMore // Store params for loadMore
lastSearchParamsRef.current = { lastSearchParamsRef.current = {
query: searchQuery, query: searchQuery,
sort, sort: sortOrder,
fieldValues: effectiveFieldValues, fieldValues: effectiveFieldValues,
providerOverride, providerOverride,
contentType: effectiveContentType, contentType: effectiveContentType,
@@ -252,12 +278,20 @@ export function useSearch(options: UseSearchOptions): UseSearchReturn {
setLastSearchQuery(query); setLastSearchQuery(query);
try { try {
const results = await searchBooks(query); const { books: results, totalResults } = await searchBooks(query);
if (results.length > 0) { if (results.length > 0) {
setBooks(results); // When no explicit server-side sort is selected (empty or "downloads"),
// sort locally by downloads descending so the most popular results
// appear first.
const effectiveSort = sort ?? advancedFilters.sort;
const isDownloadsSort = !effectiveSort || effectiveSort === 'downloads';
const sorted = isDownloadsSort ? sortBooksByDownloads(results) : results;
setBooks(sorted);
setDirectTotalResults(totalResults);
} else { } else {
showToast('No results found', 'error'); showToast('No results found', 'error');
setDirectTotalResults(null);
} }
} catch (error) { } catch (error) {
if (error instanceof AuthenticationError) { if (error instanceof AuthenticationError) {
@@ -270,7 +304,14 @@ export function useSearch(options: UseSearchOptions): UseSearchReturn {
setIsSearching(false); setIsSearching(false);
} }
}, },
[showToast, searchFieldValues, handleSearchError, contentType], [
showToast,
searchFieldValues,
handleSearchError,
contentType,
advancedFilters,
sortBooksByDownloads,
],
); );
const handleResetSearch = useCallback( const handleResetSearch = useCallback(
@@ -300,6 +341,7 @@ export function useSearch(options: UseSearchOptions): UseSearchReturn {
setCurrentPage(1); setCurrentPage(1);
setHasMore(false); setHasMore(false);
setTotalFound(0); setTotalFound(0);
setDirectTotalResults(null);
setResultsSourceUrl(undefined); setResultsSourceUrl(undefined);
setResultsSourceTitle(undefined); setResultsSourceTitle(undefined);
lastSearchParamsRef.current = null; lastSearchParamsRef.current = null;
@@ -367,6 +409,7 @@ export function useSearch(options: UseSearchOptions): UseSearchReturn {
handleSearch, handleSearch,
handleResetSearch, handleResetSearch,
resetSortFilter, resetSortFilter,
reSortByDownloads,
// Universal mode search field values // Universal mode search field values
searchFieldValues, searchFieldValues,
updateSearchFieldValue, updateSearchFieldValue,
@@ -376,6 +419,7 @@ export function useSearch(options: UseSearchOptions): UseSearchReturn {
isLoadingMore, isLoadingMore,
loadMore, loadMore,
totalFound, totalFound,
directTotalResults,
resultsSourceUrl, resultsSourceUrl,
resultsSourceTitle, resultsSourceTitle,
}; };
+138 -11
View File
@@ -1,12 +1,18 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useSearchParams } from 'react-router-dom';
import type { ParsedUrlSearch } from '../utils/parseUrlSearchParams'; import type { ParsedUrlSearch } from '../utils/parseUrlSearchParams';
import { parseUrlSearchParams } from '../utils/parseUrlSearchParams'; import { parseUrlSearchParams } from '../utils/parseUrlSearchParams';
import { useLatestCallback } from './useLatestCallback';
import { useDependencyEffect, useMountEffect } from './useMountEffect';
interface UseUrlSearchOptions { interface UseUrlSearchOptions {
/** Only process URL params after auth check and config are loaded */ /** Only process URL params after auth check and config are loaded */
enabled: boolean; enabled: boolean;
/**
* Bump to re-read the URL - used when the hash changes underneath us
* (someone pastes a shared link into an already-open tab).
*/
nonce?: number;
} }
interface UseUrlSearchReturn { interface UseUrlSearchReturn {
@@ -16,11 +22,39 @@ interface UseUrlSearchReturn {
wasProcessed: boolean; wasProcessed: boolean;
} }
/** Debounce for the write side: searchInput changes on every keystroke, and Safari
* throws SecurityError past ~100 replaceState calls per 30s. */
const HASH_SYNC_DEBOUNCE_MS = 300;
/** /**
* Hook to parse URL search parameters on initial page load. * Last hash this module wrote. Lets the hashchange listener tell "the user pasted a
* new link" apart from "our own sync effect just ran".
*/
let lastWrittenHash: string | null = null;
const stripHash = (value: string): string => (value.startsWith('#') ? value.slice(1) : value);
const readUrlSearchParams = (): { params: URLSearchParams; fromQueryString: boolean } => {
const hash = stripHash(window.location.hash);
if (hash) {
return { params: new URLSearchParams(hash), fromQueryString: false };
}
const search = window.location.search.startsWith('?')
? window.location.search.slice(1)
: window.location.search;
return { params: new URLSearchParams(search), fromQueryString: Boolean(search) };
};
/**
* Hook to parse the URL on initial page load.
* *
* This is a read-only operation - URL params are parsed once when enabled, * Search config lives in a hash fragment (e.g. `#q=dune&search_by=manual`)
* and the URL is not updated when users perform searches. * rather than query params, so it stays browser-side only and never looks
* like a server-processed query string.
*
* Query-string links (`?q=dune`) shipped before the hash and are still honoured
* when the hash is empty: they're read once and rewritten to `#…` so the two
* can't drift as the live sync below updates the URL.
* *
* @example * @example
* // In App.tsx: * // In App.tsx:
@@ -33,19 +67,112 @@ interface UseUrlSearchReturn {
* // Trigger search with parsed params * // Trigger search with parsed params
* } * }
*/ */
export function useUrlSearch({ enabled }: UseUrlSearchOptions): UseUrlSearchReturn { export function useUrlSearch({ enabled, nonce = 0 }: UseUrlSearchOptions): UseUrlSearchReturn {
const [searchParams] = useSearchParams(); const read = useMemo(() => {
const parsedParams = useMemo(() => {
if (!enabled) { if (!enabled) {
return null; return null;
} }
const parsed = parseUrlSearchParams(searchParams); const { params, fromQueryString } = readUrlSearchParams();
return parsed.hasSearchParams || parsed.contentType || parsed.combinedMode ? parsed : null; const parsed = parseUrlSearchParams(params);
}, [enabled, searchParams]); const hasAnything = Boolean(
parsed.hasSearchParams || parsed.contentType || parsed.combinedMode || parsed.searchBy,
);
return { parsed: hasAnything ? parsed : null, fromQueryString, hasAnything };
// Intentionally read once per enable/nonce - live updates come from useSyncUrlSearchHash.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [enabled, nonce]);
// Migrate a legacy query-string link to the hash form once, so the live sync has a single
// source of truth and the URL the user re-shares is the one the app keeps updating.
const shouldMigrate = Boolean(read?.fromQueryString && read.hasAnything);
useDependencyEffect(() => {
if (!shouldMigrate) {
return;
}
const nextHash = new URLSearchParams(window.location.search).toString();
lastWrittenHash = nextHash;
try {
window.history.replaceState(
window.history.state,
'',
`${window.location.pathname}#${nextHash}`,
);
} catch {
// replaceState is rate-limited in Safari - the parsed params still apply
}
}, [shouldMigrate]);
return { return {
parsedParams, parsedParams: read?.parsed ?? null,
wasProcessed: enabled, wasProcessed: enabled,
}; };
} }
interface UseSyncUrlSearchHashOptions {
/** Only write once URL params (if any) have been applied to search state */
enabled: boolean;
/** Hash fragment (without leading `#`) that should reflect current search state */
hash: string;
}
/**
* Keeps the URL hash fragment in sync with the current search state.
*
* Uses history.replaceState (not pushState), so every keystroke or
* Search By change updates the URL live without pushing a new browser
* history entry per change. Debounced, because Safari throws SecurityError
* past roughly 100 replaceState calls per 30 seconds.
*/
export function useSyncUrlSearchHash({ enabled, hash }: UseSyncUrlSearchHashOptions): void {
useDependencyEffect(() => {
// Debounced, so a burst of keystrokes collapses into one history write.
const timer = enabled
? window.setTimeout(() => {
if (stripHash(window.location.hash) === hash) {
lastWrittenHash = hash;
return;
}
const url = `${window.location.pathname}${window.location.search}${hash ? `#${hash}` : ''}`;
lastWrittenHash = hash;
try {
window.history.replaceState(window.history.state, '', url);
} catch {
// Rate-limited (Safari) or a sandboxed frame - the UI state is still correct,
// only the shareable URL lags behind.
}
}, HASH_SYNC_DEBOUNCE_MS)
: undefined;
return () => {
if (timer !== undefined) {
window.clearTimeout(timer);
}
};
}, [enabled, hash]);
}
/**
* Calls `onExternalChange` when the hash changes to something this module didn't write -
* i.e. someone pasted a shared link into an already-open tab, or used back/forward.
*/
export function useExternalHashChange(onExternalChange: () => void): void {
// The listener outlives the Effect that registers it and fires from a DOM event,
// which is outside useEffectEvent's contract - see useLatestCallback.
const notify = useLatestCallback(onExternalChange);
useMountEffect(() => {
const handleHashChange = () => {
if (stripHash(window.location.hash) === lastWrittenHash) {
return;
}
notify();
};
window.addEventListener('hashchange', handleHashChange);
return () => window.removeEventListener('hashchange', handleHashChange);
});
}
+10 -3
View File
@@ -279,14 +279,20 @@ async function fetchJSON<T>(
} }
// API functions // API functions
export const searchBooks = async (query: string): Promise<Book[]> => { export const searchBooks = async (
if (!query) return []; query: string,
): Promise<{ books: Book[]; totalResults: number | string | null }> => {
if (!query) return { books: [], totalResults: null };
const response = await fetchJSON<ReleasesResponse>( const response = await fetchJSON<ReleasesResponse>(
`${API_BASE}/releases?source=direct_download&${query}`, `${API_BASE}/releases?source=direct_download&${query}`,
{}, {},
searchTimeoutMs, searchTimeoutMs,
); );
return response.releases.map(transformReleaseToDirectBook); // Extract total count from search_info if available
const firstSource = response.sources_searched?.[0];
const searchInfo = firstSource ? response.search_info?.[firstSource] : undefined;
const totalResults = searchInfo?.total_results ?? null;
return { books: response.releases.map(transformReleaseToDirectBook), totalResults };
}; };
// Metadata search response type (internal) // Metadata search response type (internal)
@@ -540,6 +546,7 @@ export type DownloadReleasePayload = {
format?: string; format?: string;
size?: string; size?: string;
size_bytes?: number; size_bytes?: number;
downloads?: number;
download_url?: string; download_url?: string;
protocol?: string; protocol?: string;
indexer?: string; indexer?: string;
@@ -0,0 +1,30 @@
import { describe, it, expect } from 'vitest';
import { shortBuildId } from '../utils/buildVersion';
const SHA = '1a5b37d0c2e4f6a8b0c2d4e6f8a0b2c4d6e8f0a2';
describe('buildVersion.shortBuildId', () => {
it('shows the commit of a date-stamped image, not the date', () => {
expect(shortBuildId(`2026-09-20-${SHA}`)).toBe('1a5b37d');
});
it('shows the commit of a PR image', () => {
expect(shortBuildId(`pr-${SHA}`)).toBe('1a5b37d');
});
it('shortens a bare commit sha', () => {
expect(shortBuildId(SHA)).toBe('1a5b37d');
});
it('keeps any other stamp to its first seven characters', () => {
expect(shortBuildId('local-build')).toBe('local-b');
expect(shortBuildId('abc')).toBe('abc');
});
it('returns null for an unstamped build', () => {
expect(shortBuildId(undefined)).toBe(null);
expect(shortBuildId('')).toBe(null);
expect(shortBuildId('N/A')).toBe(null);
});
});
@@ -4,6 +4,7 @@ import {
buildNamingTemplatePreview, buildNamingTemplatePreview,
NAMING_TEMPLATE_TOKENS, NAMING_TEMPLATE_TOKENS,
renderNamingTemplate, renderNamingTemplate,
resolveWordSeparator,
SAMPLE_NAMING_METADATA, SAMPLE_NAMING_METADATA,
} from '../utils/namingTemplatePreview'; } from '../utils/namingTemplatePreview';
@@ -40,6 +41,23 @@ describe('namingTemplatePreview', () => {
expect(preview.value).toBe('Arthur Conan Doyle/The Hound of the Baskervilles'); expect(preview.value).toBe('Arthur Conan Doyle/The Hound of the Baskervilles');
}); });
it('keeps only the first of several authors for FirstAuthor', () => {
const preview = renderNamingTemplate(
'{FirstAuthor}/{Year}',
{ ...SAMPLE_NAMING_METADATA, Author: 'Terry Pratchett, Neil Gaiman', FirstAuthor: '' },
{ allowPathSeparators: true },
);
expect(preview.value).toBe('Terry Pratchett/1902');
});
it('offers FirstAuthor as a core variable', () => {
const token = NAMING_TEMPLATE_TOKENS.find((t) => t.token === 'FirstAuthor');
expect(token?.group).toBe('Core');
expect(token?.audiobookOnly).toBeFalsy();
});
it('reports unknown bare variables', () => { it('reports unknown bare variables', () => {
const preview = renderNamingTemplate('{Author}/{NotAThing}', SAMPLE_NAMING_METADATA, { const preview = renderNamingTemplate('{Author}/{NotAThing}', SAMPLE_NAMING_METADATA, {
allowPathSeparators: true, allowPathSeparators: true,
@@ -80,6 +98,41 @@ describe('namingTemplatePreview', () => {
expect(swedish.value).not.toBe(english.value); expect(swedish.value).not.toBe(english.value);
}); });
it('replaces internal whitespace with the configured word separator', () => {
const preview = renderNamingTemplate('{Author}/{PrimaryTitle}', SAMPLE_NAMING_METADATA, {
allowPathSeparators: true,
wordSeparator: '.',
});
expect(preview.value).toBe('Arthur.Conan.Doyle/The.Hound.of.the.Baskervilles');
});
it('leaves values unchanged for the default space separator', () => {
const preview = renderNamingTemplate('{Author}', SAMPLE_NAMING_METADATA, {
allowPathSeparators: true,
});
expect(preview.value).toBe('Arthur Conan Doyle');
});
it('never touches literal template characters, only placeholder values', () => {
const preview = renderNamingTemplate('{Author}.-.{PrimaryTitle}', SAMPLE_NAMING_METADATA, {
allowPathSeparators: true,
wordSeparator: '.',
});
expect(preview.value).toBe('Arthur.Conan.Doyle.-.The.Hound.of.the.Baskervilles');
});
it('resolves the word separator setting like the backend policy module', () => {
expect(resolveWordSeparator('.')).toBe('.');
expect(resolveWordSeparator('_')).toBe('_');
expect(resolveWordSeparator('-')).toBe('-');
expect(resolveWordSeparator('~')).toBe('~');
expect(resolveWordSeparator('')).toBe(' ');
expect(resolveWordSeparator(undefined)).toBe(' ');
});
it('keeps the picker and the known-token list in lockstep', () => { it('keeps the picker and the known-token list in lockstep', () => {
// KNOWN_TOKENS is a hand-maintained duplicate of the Python list. A token // KNOWN_TOKENS is a hand-maintained duplicate of the Python list. A token
// added to the picker but not to it would render as an unknown variable. // added to the picker but not to it would render as an unknown variable.
@@ -60,4 +60,32 @@ describe('parseUrlSearchParams', () => {
expect(parsed.contentType).toBe(undefined); expect(parsed.contentType).toBe(undefined);
expect(parsed.combinedMode).toBe(true); expect(parsed.combinedMode).toBe(true);
}); });
it('parses search_by as the Search By target', () => {
const parsed = parseUrlSearchParams(new URLSearchParams('search_by=manual&q=dune'));
expect(parsed.searchBy).toBe('manual');
expect(parsed.searchInput).toBe('dune');
expect(parsed.hasSearchParams).toBe(true);
});
it('trims search_by but keeps its casing for case-sensitive provider field keys', () => {
const parsed = parseUrlSearchParams(new URLSearchParams('search_by=+MANUAL+'));
expect(parsed.searchBy).toBe('MANUAL');
});
it('keeps search_by-only links from auto-triggering a blank search', () => {
const parsed = parseUrlSearchParams(new URLSearchParams('search_by=manual'));
expect(parsed.searchBy).toBe('manual');
expect(parsed.searchInput).toBe('');
expect(parsed.hasSearchParams).toBe(false);
});
it('leaves search_by undefined when absent', () => {
const parsed = parseUrlSearchParams(new URLSearchParams('q=dune'));
expect(parsed.searchBy).toBe(undefined);
});
}); });
+35 -1
View File
@@ -1,6 +1,10 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { buildQueryTargets, getDefaultQueryTargetKey } from '../utils/queryTargets'; import {
buildQueryTargets,
findQueryTarget,
getDefaultQueryTargetKey,
} from '../utils/queryTargets';
describe('queryTargets', () => { describe('queryTargets', () => {
it('builds direct-mode query targets', () => { it('builds direct-mode query targets', () => {
@@ -44,3 +48,33 @@ describe('queryTargets', () => {
expect(getDefaultQueryTargetKey([])).toBe('general'); expect(getDefaultQueryTargetKey([])).toBe('general');
}); });
}); });
describe('findQueryTarget', () => {
const targets = buildQueryTargets({ searchMode: 'direct' });
it('returns undefined for a missing or empty key', () => {
expect(findQueryTarget(targets, undefined)).toBeUndefined();
expect(findQueryTarget(targets, '')).toBeUndefined();
expect(findQueryTarget(targets, 'series')).toBeUndefined();
});
it('matches an exact key', () => {
expect(findQueryTarget(targets, 'author')?.key).toBe('author');
});
it('falls back to a case-insensitive match for custom provider field keys', () => {
const providerTargets = buildQueryTargets({
searchMode: 'universal',
metadataSearchFields: [
{
key: 'hardcoverList',
label: 'List',
type: 'TextSearchField',
},
],
});
expect(findQueryTarget(providerTargets, 'hardcoverlist')?.key).toBe('hardcoverList');
expect(findQueryTarget(providerTargets, 'hardcoverList')?.key).toBe('hardcoverList');
});
});
@@ -0,0 +1,60 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { getSearchByPreference, setSearchByPreference } from '../utils/searchByPreference';
// No jsdom in this project's vitest setup, so stub the slice of localStorage this uses.
const makeStorage = (impl?: Partial<Storage>) => {
const store = new Map<string, string>();
return {
getItem: (key: string) => store.get(key) ?? null,
setItem: (key: string, value: string) => {
store.set(key, value);
},
...impl,
};
};
const stubWindow = (storage: unknown) => {
vi.stubGlobal('window', { localStorage: storage });
};
describe('searchByPreference', () => {
beforeEach(() => {
stubWindow(makeStorage());
});
afterEach(() => {
vi.unstubAllGlobals();
});
it('returns null when nothing is stored', () => {
expect(getSearchByPreference()).toBe(null);
});
it('round-trips a value through set/get', () => {
setSearchByPreference('manual');
expect(getSearchByPreference()).toBe('manual');
});
it('overwrites a previously stored value', () => {
setSearchByPreference('manual');
setSearchByPreference('author');
expect(getSearchByPreference()).toBe('author');
});
it('survives storage being unavailable', () => {
stubWindow(
makeStorage({
getItem: () => {
throw new Error('storage disabled');
},
setItem: () => {
throw new Error('storage disabled');
},
}),
);
expect(() => setSearchByPreference('author')).not.toThrow();
expect(getSearchByPreference()).toBe(null);
});
});
@@ -0,0 +1,216 @@
import { describe, expect, it } from 'vitest';
import { buildUrlSearchHash } from '../utils/urlSearchHash';
describe('buildUrlSearchHash', () => {
it('builds a hash reflecting query, search_by and a non-default content_type', () => {
const hash = buildUrlSearchHash({
queryValue: 'dune',
searchBy: 'manual',
contentType: 'audiobook',
combinedMode: false,
advancedFilters: {},
});
expect(new URLSearchParams(hash).get('q')).toBe('dune');
expect(new URLSearchParams(hash).get('search_by')).toBe('manual');
expect(new URLSearchParams(hash).get('content_type')).toBe('audiobook');
});
it('omits content_type when it is the ebook default', () => {
const hash = buildUrlSearchHash({
queryValue: 'dune',
searchBy: 'general',
contentType: 'ebook',
combinedMode: false,
advancedFilters: {},
});
expect(new URLSearchParams(hash).has('content_type')).toBe(false);
});
it('omits search_by when it is the general default', () => {
const hash = buildUrlSearchHash({
queryValue: 'dune',
searchBy: 'general',
contentType: 'ebook',
combinedMode: false,
advancedFilters: {},
});
expect(new URLSearchParams(hash).has('search_by')).toBe(false);
});
it('sets content_type=combined when combinedMode is true', () => {
const hash = buildUrlSearchHash({
queryValue: 'dune',
searchBy: 'general',
contentType: 'ebook',
combinedMode: true,
advancedFilters: {},
});
expect(new URLSearchParams(hash).get('content_type')).toBe('combined');
});
it('mirrors advanced filters (isbn/author/title/sort/content/lang/format)', () => {
const hash = buildUrlSearchHash({
queryValue: '',
searchBy: 'author',
contentType: 'ebook',
combinedMode: false,
advancedFilters: {
author: 'frank herbert',
lang: ['en', 'de'],
formats: ['epub'],
sort: 'newest',
},
});
const params = new URLSearchParams(hash);
expect(params.get('author')).toBe('frank herbert');
expect(params.get('sort')).toBe('newest');
expect(params.getAll('lang')).toEqual(['en', 'de']);
expect(params.getAll('format')).toEqual(['epub']);
});
it('produces an empty string when there is nothing to reflect', () => {
const hash = buildUrlSearchHash({
queryValue: '',
searchBy: 'general',
contentType: 'ebook',
combinedMode: false,
advancedFilters: {},
});
expect(hash).toBe('');
});
it('serializes a non-text provider field value so the target round-trips', () => {
const hash = buildUrlSearchHash({
queryValue: 'id:1234',
searchBy: 'series',
contentType: 'ebook',
combinedMode: false,
advancedFilters: {},
});
expect(new URLSearchParams(hash).get('q')).toBe('id:1234');
expect(new URLSearchParams(hash).get('search_by')).toBe('series');
});
it('serializes numeric and checkbox field values', () => {
const numeric = buildUrlSearchHash({
queryValue: 2024,
searchBy: 'year',
contentType: 'ebook',
combinedMode: false,
advancedFilters: {},
});
expect(new URLSearchParams(numeric).get('q')).toBe('2024');
const checked = buildUrlSearchHash({
queryValue: true,
searchBy: 'signed',
contentType: 'ebook',
combinedMode: false,
advancedFilters: {},
});
expect(new URLSearchParams(checked).get('q')).toBe('1');
const unchecked = buildUrlSearchHash({
queryValue: false,
searchBy: 'signed',
contentType: 'ebook',
combinedMode: false,
advancedFilters: {},
});
expect(new URLSearchParams(unchecked).has('q')).toBe(false);
});
it('omits filters still sitting at their defaults', () => {
const hash = buildUrlSearchHash({
queryValue: 'dune',
searchBy: 'general',
contentType: 'ebook',
combinedMode: false,
defaultSort: 'relevance',
defaultFormats: ['epub', 'mobi', 'azw3'],
advancedFilters: {
sort: 'relevance',
lang: ['default'],
formats: ['epub', 'mobi', 'azw3'],
},
});
expect(hash).toBe('q=dune');
});
it('treats a reordered default selection as the default', () => {
const hash = buildUrlSearchHash({
queryValue: 'dune',
searchBy: 'general',
contentType: 'ebook',
combinedMode: false,
defaultFormats: ['epub', 'mobi', 'azw3'],
advancedFilters: { formats: ['azw3', 'epub', 'mobi'] },
});
expect(hash).toBe('q=dune');
});
it('keeps filters the user actually changed', () => {
const params = new URLSearchParams(
buildUrlSearchHash({
queryValue: 'dune',
searchBy: 'general',
contentType: 'ebook',
combinedMode: false,
defaultSort: 'relevance',
defaultFormats: ['epub', 'mobi', 'azw3'],
advancedFilters: {
sort: 'newest',
lang: ['en', 'de'],
formats: ['epub'],
},
}),
);
expect(params.get('sort')).toBe('newest');
expect(params.getAll('lang')).toEqual(['en', 'de']);
expect(params.getAll('format')).toEqual(['epub']);
});
it('keeps a narrowed selection that happens to be the same length', () => {
const params = new URLSearchParams(
buildUrlSearchHash({
queryValue: 'dune',
searchBy: 'general',
contentType: 'ebook',
combinedMode: false,
defaultFormats: ['epub', 'mobi', 'azw3'],
advancedFilters: { formats: ['epub', 'mobi', 'pdf'] },
}),
);
expect(params.getAll('format')).toEqual(['epub', 'mobi', 'pdf']);
});
it('leaves a plain default-state search with no hash at all', () => {
const hash = buildUrlSearchHash({
queryValue: '',
searchBy: 'general',
contentType: 'ebook',
combinedMode: false,
defaultSort: 'relevance',
defaultFormats: ['epub', 'mobi', 'azw3'],
advancedFilters: {
sort: 'relevance',
lang: ['default'],
formats: ['epub', 'mobi', 'azw3'],
},
});
expect(hash).toBe('');
});
});
+18
View File
@@ -56,6 +56,23 @@ export interface Book {
titles_by_language?: Record<string, string>; titles_by_language?: Record<string, string>;
username?: string; username?: string;
retry_available?: boolean; retry_available?: boolean;
downloads?: number;
extra?: Record<string, unknown>;
}
/**
* Extract download count from a book's data.
* Checks both the direct `downloads` field and the `extra.downloads` fallback.
*/
export function getDownloadsCount(book: Book): number | null {
if (book.downloads != null && book.downloads > 0) {
return book.downloads;
}
const extraDownloads = book.extra?.downloads;
if (extraDownloads != null && typeof extraDownloads === 'number' && extraDownloads > 0) {
return extraDownloads;
}
return null;
} }
// Status response types // Status response types
@@ -458,6 +475,7 @@ export interface Release {
// Search info returned by release sources // Search info returned by release sources
export interface SourceSearchInfo { export interface SourceSearchInfo {
search_type: 'isbn' | 'title_author' | 'categories' | 'expanded' | 'manual' | 'query'; search_type: 'isbn' | 'title_author' | 'categories' | 'expanded' | 'manual' | 'query';
total_results?: number | string | null;
} }
// Response from /api/releases endpoint // Response from /api/releases endpoint
@@ -62,6 +62,7 @@ interface SourceBackedBookData {
language?: unknown; language?: unknown;
format?: unknown; format?: unknown;
size?: unknown; size?: unknown;
downloads?: unknown;
preview?: unknown; preview?: unknown;
publisher?: unknown; publisher?: unknown;
info?: Record<string, string | string[]>; info?: Record<string, string | string[]>;
@@ -157,6 +158,7 @@ const transformSourceBackedDataToBook = (data: SourceBackedBookData): Book => {
language: toOptionalText(data.language), language: toOptionalText(data.language),
format: toOptionalText(data.format), format: toOptionalText(data.format),
size: toOptionalText(data.size), size: toOptionalText(data.size),
downloads: typeof data.downloads === 'number' ? data.downloads : undefined,
preview: toOptionalText(data.preview), preview: toOptionalText(data.preview),
publisher: toOptionalText(data.publisher), publisher: toOptionalText(data.publisher),
info: data.info, info: data.info,
@@ -181,6 +183,7 @@ export function transformReleaseToDirectBook(release: Release): Book {
language: release.language || extra.language, language: release.language || extra.language,
format: release.format, format: release.format,
size: release.size, size: release.size,
downloads: extra.downloads,
preview: extra.preview, preview: extra.preview,
publisher: extra.publisher, publisher: extra.publisher,
info: parseBookInfo(extra.info), info: parseBookInfo(extra.info),
+5 -2
View File
@@ -47,8 +47,11 @@ export const buildSearchQuery = ({
if (content) queryParts.push(`content=${encodeURIComponent(content)}`); if (content) queryParts.push(`content=${encodeURIComponent(content)}`);
formats.forEach((format) => queryParts.push(`format=${encodeURIComponent(format)}`)); formats.forEach((format) => queryParts.push(`format=${encodeURIComponent(format)}`));
if (advancedFilters.sort) { // "downloads" is a client-side-only sort — send empty string so the server
queryParts.push(`sort=${encodeURIComponent(advancedFilters.sort)}`); // doesn't receive an invalid sort parameter.
const serverSort = advancedFilters.sort === 'downloads' ? '' : advancedFilters.sort;
if (serverSort) {
queryParts.push(`sort=${encodeURIComponent(serverSort)}`);
} }
return queryParts.join('&'); return queryParts.join('&');
+15
View File
@@ -0,0 +1,15 @@
const COMMIT_SHA = /[0-9a-f]{40}$/i;
/**
* The short commit id a build was made from, or null when the build is unstamped.
*
* CI stamps BUILD_VERSION as `<yyyy-mm-dd>-<sha>` for published images and `pr-<sha>` for
* PR images, so its first seven characters are the date ("2026-09"), not the commit.
*/
export const shortBuildId = (buildVersion?: string): string | null => {
if (!buildVersion || buildVersion === 'N/A') {
return null;
}
const sha = COMMIT_SHA.exec(buildVersion)?.[0];
return (sha ?? buildVersion).slice(0, 7);
};
@@ -12,6 +12,8 @@ export interface NamingTemplateToken {
interface RenderOptions { interface RenderOptions {
allowPathSeparators: boolean; allowPathSeparators: boolean;
/** Mirrors `word_separator` in shelfmark/core/naming.py. Defaults to ' ' (no change). */
wordSeparator?: string;
} }
interface RenderResult { interface RenderResult {
@@ -27,6 +29,13 @@ export const NAMING_TEMPLATE_TOKENS: NamingTemplateToken[] = [
value: 'Arthur Conan Doyle', value: 'Arthur Conan Doyle',
group: 'Core', group: 'Core',
}, },
{
token: 'FirstAuthor',
label: 'First author',
description: 'First author only, when metadata lists several',
value: 'Arthur Conan Doyle',
group: 'Core',
},
{ {
token: 'Title', token: 'Title',
label: 'Full title', label: 'Full title',
@@ -104,6 +113,7 @@ const KNOWN_TOKENS = [
'seriesposition', 'seriesposition',
'primarytitle', 'primarytitle',
'originalname', 'originalname',
'firstauthor',
'partnumber', 'partnumber',
'language', 'language',
'subtitle', 'subtitle',
@@ -114,8 +124,13 @@ const KNOWN_TOKENS = [
'user', 'user',
]; ];
// Mirrors AUTHOR_LIST_SEPARATOR in shelfmark/core/naming.py: authors arrive
// pre-joined with ',' or ';' and {FirstAuthor} keeps only the first entry.
const firstAuthor = (value: string): string => value.split(/\s*[,;]\s*/)[0]?.trim() ?? '';
const BRACE_PATTERN = /\{([^}]+)\}/g; const BRACE_PATTERN = /\{([^}]+)\}/g;
const INVALID_CHARS_PATTERN = /[\\/:*?"<>|]/g; const INVALID_CHARS_PATTERN = /[\\/:*?"<>|]/g;
const WHITESPACE_RUN_PATTERN = /\s+/g;
export const SAMPLE_NAMING_METADATA = NAMING_TEMPLATE_TOKENS.reduce<Record<string, string>>( export const SAMPLE_NAMING_METADATA = NAMING_TEMPLATE_TOKENS.reduce<Record<string, string>>(
(metadata, token) => { (metadata, token) => {
@@ -163,6 +178,9 @@ export const renderNamingTemplate = (
const unknownTokens: string[] = []; const unknownTokens: string[] = [];
const placeholderValue = (placeholderName: string): string => { const placeholderValue = (placeholderName: string): string => {
if (placeholderName === 'firstauthor' && !normalized['firstauthor']) {
return firstAuthor(normalized['author'] ?? '');
}
return (normalized[placeholderName] ?? '').trim(); return (normalized[placeholderName] ?? '').trim();
}; };
@@ -178,11 +196,16 @@ export const renderNamingTemplate = (
const prefix = content.slice(0, index); const prefix = content.slice(0, index);
const suffix = content.slice(index + name.length); const suffix = content.slice(index + name.length);
const rawValue = placeholderValue(name); let rawValue = placeholderValue(name);
if (!rawValue) { if (!rawValue) {
return ''; return '';
} }
const wordSeparator = options.wordSeparator ?? ' ';
if (wordSeparator !== ' ') {
rawValue = rawValue.replace(WHITESPACE_RUN_PATTERN, wordSeparator);
}
const value = sanitizeFilename( const value = sanitizeFilename(
options.allowPathSeparators ? rawValue : rawValue.replace(/\//g, '_'), options.allowPathSeparators ? rawValue : rawValue.replace(/\//g, '_'),
); );
@@ -238,13 +261,20 @@ export const renderNamingTemplate = (
return { value: result, unknownTokens }; return { value: result, unknownTokens };
}; };
// Mirrors get_word_separator() in shelfmark/download/postprocess/policy.py.
export const resolveWordSeparator = (value: unknown): string => {
return (typeof value === 'string' ? value : '') || ' ';
};
export const buildNamingTemplatePreview = ( export const buildNamingTemplatePreview = (
template: string, template: string,
mode: NamingTemplateMode, mode: NamingTemplateMode,
content: NamingTemplateContent, content: NamingTemplateContent,
wordSeparator = ' ',
): RenderResult => { ): RenderResult => {
const rendered = renderNamingTemplate(template, SAMPLE_NAMING_METADATA, { const rendered = renderNamingTemplate(template, SAMPLE_NAMING_METADATA, {
allowPathSeparators: mode === 'path', allowPathSeparators: mode === 'path',
wordSeparator,
}); });
const fallback = SAMPLE_NAMING_METADATA.PrimaryTitle; const fallback = SAMPLE_NAMING_METADATA.PrimaryTitle;
const extension = content === 'audiobook' ? 'mp3' : 'epub'; const extension = content === 'audiobook' ? 'mp3' : 'epub';

Some files were not shown because too many files have changed in this diff Show More