Compare commits

...
121 Commits
Author SHA1 Message Date
Kris AndersonandClaude Sonnet 5.5 0d97110f01 fix(network): apply HTTP proxy to Anna's Archive download-count lookups (#1686)
Fixes #1685

`_fetch_download_count_inline` fetches `/dyn/md5/inline_info/<md5>` for
every search result (5 workers) through a module-level
`requests.Session`, with no `proxies=`. `get_proxies()` reads the app
config rather than the environment, so with the proxy set in Settings →
Network the search page and the bypasser used it while these requests
left from the host's own IP, about 50 per search. It now passes
`network.get_proxies(url)` on each request, as the Libgen fast path in
the same file already does. The session stays module-level for
keep-alive, and proxies are resolved per request, so a settings change
applies without a restart.

Verification (macOS, `uv run --frozen pytest`):
- New `tests/direct_download/test_download_count_proxy.py` passes. With
the source change reverted it fails (`proxies` is `None`).
- `tests/direct_download`: 135 passed, including
`test_download_count_reuse.py`.
- `ruff check` and `ruff format --check` on the changed files pass.
- Not re-run live with this branch built. The behaviour before the
change was observed on a running v1.6.2 container: 5 sockets from the
`ThreadPoolExecutor` threads straight to the AA mirror's IP on :443, and
1 to the proxy.

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


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

<!-- coderabbit-ai-generated:v1 -->

## Summary by CodeRabbit

* **Bug Fixes**
* Anna’s Archive download counts are now retrieved using the configured
network proxies. This improves access to download-count information in
environments where requests need a proxy, while preserving the existing
response handling and timeout behavior.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-09 17:58:01 -06:00
splitsec2 bb13fae0bb feat(download): sweep dead torrents and Soulseek leftovers on their own timer (#1619)
This adds two cleanups that nobody notices until they matter, and puts
them on their own timer. Both do nothing until their setting and client
are configured.

The dead-torrent sweep removes a torrent Shelfmark added once it has
been stalled, or stuck fetching metadata, for `STALE_TORRENT_HOURS` with
no running download owning it, and its partial data goes with it. It
only touches torrents this instance added, tracked in a ledger of
hashes, whatever category they are in, and only on qBittorrent. The
default is 6 hours (3 to 72, and 0 turns it off). I am not sure 6 is
right for everyone, so say if you would rather ship it off.

The Soulseek sweep covers what a restart leaves in slskd's downloads
folder. A finished folder nothing imported gets imported, a partial
folder is removed after `SLSKD_CLEAN_LEFTOVERS_DAYS`, and unfinished
transfers no running download owns are picked up again when their
request still waits for them.

Both run from a small background thread every 15 minutes, after a 30
second startup delay. I first ran them from the Hardcover scheduler, but
that only runs when Hardcover sync or auto-download is on, so anyone
using neither would never get the cleanup. A separate thread keeps them
independent, and the interval is fixed so there is no new setting.

The new tests mock the sweeps. I also started a scratch instance, and
the thread starts and the first cycle is a no-op when nothing is
configured. The timer has not run against a real qBittorrent or slskd,
though the two sweeps themselves have been in my fork's builds since
October. Full Python suite, ruff and basedpyright pass.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
<!-- coderabbit-ai-generated:v1 -->

## Summary by CodeRabbit

* **New Features**
* Shelfmark periodically recovers eligible complete Soulseek downloads
and reattaches unfinished transfers. It can also clean up eligible old
folders while protecting active, wanted, and already-imported downloads.
* Shelfmark can remove tracked qBittorrent torrents that have stalled or
exceeded the configured inactivity limit, while preserving torrents
associated with active tasks. Set the limit to 0 to disable cleanup.
* **Bug Fixes**
* Isolated Soulseek downloads reject task IDs that could produce unsafe
or mismatched destination folders.
* **Documentation**
* Added configuration guidance for Soulseek recovery and cleanup, and
qBittorrent stale-torrent cleanup, including defaults and eligibility
rules.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-11-09 13:20:21 -06:00
splitsec2 e575ff0fcb feat(activity): clearer cards, a Rejected view and Reopen for failed deliveries (#1618)
A batch of activity list changes from running it as an admin on my own
instance. They are separate commits that build on each other, so they
can be reviewed in order.

The cards read better. Every card gets a format pill (violet for audio,
orange for ebooks), the requester is named once, and finished cards say
how long a download took and how long ago it ended ("took 5m · 35m
ago"). A download that picked up work already under way shows a
Reattached pill.

Admins can tell what is automatic and what needs a person. A declined
request shows who declined it and when, anything with an `[auto]` note
reads "Declined automatically", and "Needs Review" becomes "No Match
Yet" with a line saying the search repeats on every sync.

There is a Rejected view next to History for admins, with Reopen on each
card and a way to hide the dead ones.

The part to read most carefully is the backend in `requests_service.py`
and `user_db.py`. An admin can now reopen a fulfilled request whose
download failed or was cancelled, and the reopen a failed download
triggers remembers which release failed, so the next search doesn't pick
it again.

I ran it on a scratch instance (when pulling them together to move from
my fork to upstream) as an admin with seeded requests and looked at the
pills, the durations, the wording and the Rejected view in a browser.
Reopening a fulfilled request I only exercised in the tests. The
frontend and Python suites, type check, ruff and basedpyright pass.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

<!-- coderabbit-ai-generated:v1 -->

## Summary by CodeRabbit

* **New Features**
* Admins can review rejected requests in a dedicated view, hide or
restore cards, and reopen requests.
* Fulfilled requests with failed or cancelled deliveries can be
reopened; the failed release is skipped in the next automatic search.
* Activity cards now show format badges, download timing, and when a
download resumes existing work.
* **Improvements**
* Activity views clarify automatic retries and actions, and show
reviewer names for admins.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-11-09 12:02:21 -06:00
CaliBrain be803ed5ca Adding Coderabbit as a PR sponsor (#1682)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
<!-- coderabbit-ai-generated:v1 -->

## Summary by CodeRabbit

* **Documentation**
* Added a Sponsors section to the README recognizing CodeRabbit and
linking to its logo.
* **Chores**
* Removed the automated pull-request review workflows for repository and
fork pull requests. These workflows no longer trigger review runs for
the pull-request events they previously handled.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-11-09 12:58:40 -05:00
splitsec2 76d7df306f fix(requests): retrying a request after its client failed offers the same release again (#1677)
When a request's download fails in a way the queue can't resume, the
request is reopened and its Retry button opens the release list. The
release that just failed was drawn there as a red, disabled "Failed"
button for as long as the failed task stayed in the queue status, so you
couldn't pick it again even when the release was fine and the download
client was the problem. A bad qBittorrent password is the easy way to
hit it. Thanks @joshft91 for the report and the steps.

What changes:

- In the release list, a failed request download now shows Retry, the
same as a failure the queue can retry already does. A direct download
with nothing to retry from still shows Failed. The rule lives in one
small helper used by the release modal and both button-state paths in
`useDownloadTracking`.
- A client that refuses the login is no longer held against the release.
qbittorrent-api raises `LoginFailed` with no message for a wrong
password, and only "Forbidden" or "Unauthorized" for a bad session or
API key, so none of it matched the unreachable-client markers from #1655
and the release went into `failed_releases`. The qBittorrent client now
says "qBittorrent authentication failed (...)" for these.
- The request reopen and the auto-download retry used two copies of the
same marker list. They now share one in `status_messages.py`, which also
counts an explicit authentication failure as the client's fault. A bare
403 or 401 is not on it, since a dead .torrent link fails that way too
and that one is the release's. Genuine release failures (no seeders, a
rejected torrent, a dead .torrent link) are still remembered as #1655
intended.

Tested with a scratch instance against a mock Prowlarr and a fake
qBittorrent that rejects the login: before, the request's Retry opened
the list with the failed release as a red X; after, it shows Retry,
picking it fulfils the request again, and the request reopens without
the release in `failed_releases`. New tests cover the qBittorrent
messages, both reopen paths and the button state.

Fixes #1674


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
<!-- coderabbit-ai-generated:v1 -->

## Summary by CodeRabbit

* **Bug Fixes**
* Failed releases are no longer skipped after download-client connection
or login failures; download failures and stall cancellations remain
eligible to be skipped on the next search.
* Failed requests with an associated request can now show a retry
option, while direct downloads without retries remain marked as failed.
* qBittorrent login and authorization errors are reported more clearly.

* **Documentation**
* Clarified which failures cause a release to be skipped on a later
search.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-11-08 23:11:25 -07:00
dependabot[bot] efc0f3df6b build(deps): bump publicsuffixlist from 1.1.2.20261101 to 1.1.2.20261105 (#1680)
Bumps [publicsuffixlist](https://github.com/ko-zu/psl) from
1.1.2.20261101 to 1.1.2.20261105.
<details>
<summary>Commits</summary>
<ul>
<li>See full diff in <a
href="https://github.com/ko-zu/psl/commits">compare view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=publicsuffixlist&package-manager=uv&previous-version=1.1.2.20261101&new-version=1.1.2.20261105)](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>

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

<!-- coderabbit-ai-generated:v1 -->

## Summary by CodeRabbit

* **Chores**
  * Updated the minimum required version of a dependency.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-08 23:03:14 -07:00
dependabot[bot] f9d0dbfcc4 build(deps): bump the python-deps group with 6 updates (#1679)
Bumps the python-deps group with 6 updates:

| Package | From | To |
| --- | --- | --- |
| [dnspython](https://github.com/rthalley/dnspython) | `2.9.1` | `2.9.2`
|
| [authlib](https://github.com/authlib/authlib) | `1.9.2` | `1.9.5` |
| [joserfc](https://github.com/authlib/joserfc) | `1.7.7` | `1.8.0` |
| [seleniumbase](https://github.com/seleniumbase/SeleniumBase) |
`4.56.5` | `4.57.1` |
| [prek](https://github.com/j178/prek) | `0.5.9` | `0.5.10` |
| [ruff](https://github.com/astral-sh/ruff) | `0.17.3` | `0.17.4` |

Updates `dnspython` from 2.9.1 to 2.9.2
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/rthalley/dnspython/releases">dnspython's
releases</a>.</em></p>
<blockquote>
<h2>dnspython 2.9.2</h2>
<p>A malicious WKS record in text format can consume excessive memory
and CPU when parsed, possibly causing the process to exit due to memory
exhaustion. Thanks to Jace (GitHub <a
href="https://github.com/manus-use"><code>@​manus-use</code></a>) for
discovering and reporting this issue.</p>
<p>See <a
href="https://dnspython.readthedocs.io/en/stable/whatsnew.html">What's
New</a> for details.</p>
<p>The minimum supported version of Python is 3.11.</p>
<p>My thanks to the many people who have contributed to this release.
Also thanks to my co-maintainers: Nicki Křížek, Petr Špaček, and Brian
Wellington.</p>
<p>This release was published to PyPI using Trusted Publishing.</p>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/rthalley/dnspython/blob/main/doc/whatsnew.rst">dnspython's
changelog</a>.</em></p>
<blockquote>
<h2>2.9.2</h2>
<ul>
<li>A maliciously crafted zone file could use a WKS record to consume
excessive
memory and CPU, possibly causing the process to exit due to memory
exhaustion.
Thanks to Jace (GitHub <a
href="https://github.com/manus-use"><code>@​manus-use</code></a>) for
discovering and reporting this issue.</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/rthalley/dnspython/commit/854bedd6d641cb42785d26ca674ed2c0f20131e0"><code>854bedd</code></a>
bump README.md version</li>
<li><a
href="https://github.com/rthalley/dnspython/commit/388db14ee9923288f6e73f86e482c77706e59d73"><code>388db14</code></a>
Constrain WKS bitmap size.</li>
<li><a
href="https://github.com/rthalley/dnspython/commit/7e1832f58a9f1583db00461ae97f7a8603c6989f"><code>7e1832f</code></a>
version bump</li>
<li>See full diff in <a
href="https://github.com/rthalley/dnspython/compare/v2.9.1...v2.9.2">compare
view</a></li>
</ul>
</details>
<br />

Updates `authlib` from 1.9.2 to 1.9.5
<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.9.5</h2>
<p>Changelog: <a
href="https://docs.authlib.org/en/stable/upgrades/changelog.html#changelog-1-9-5">https://docs.authlib.org/en/stable/upgrades/changelog.html#changelog-1-9-5</a></p>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/authlib/authlib/compare/v1.9.4...v1.9.5">https://github.com/authlib/authlib/compare/v1.9.4...v1.9.5</a></p>
<h2>v1.9.4</h2>
<p>Changelog: <a
href="https://docs.authlib.org/en/stable/upgrades/changelog.html#changelog-1-9-4">https://docs.authlib.org/en/stable/upgrades/changelog.html#changelog-1-9-4</a></p>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/authlib/authlib/compare/v1.9.3...v1.9.4">https://github.com/authlib/authlib/compare/v1.9.3...v1.9.4</a></p>
<h2>v1.9.3</h2>
<p>Changelog: <a
href="https://docs.authlib.org/en/stable/upgrades/changelog.html#changelog-1-9-3">https://docs.authlib.org/en/stable/upgrades/changelog.html#changelog-1-9-3</a></p>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/authlib/authlib/compare/v1.9.2...v1.9.3">https://github.com/authlib/authlib/compare/v1.9.2...v1.9.3</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/authlib/authlib/commit/42607cbc2135252a641a845e4ed7f94e3a922c47"><code>42607cb</code></a>
chore: release 1.9.5</li>
<li><a
href="https://github.com/authlib/authlib/commit/ce5bb0d0413765a638a623b1332a32feca142dcc"><code>ce5bb0d</code></a>
fix(jose): refuse SSH keys with a tab and SSH certificates as HMAC
secrets</li>
<li><a
href="https://github.com/authlib/authlib/commit/9dffddfaea643835b92caefe05f1548f57bd1433"><code>9dffddf</code></a>
chore: release 1.9.4</li>
<li><a
href="https://github.com/authlib/authlib/commit/67db2aa36a9c5372efc3a31e441d315504dc1915"><code>67db2aa</code></a>
fix(oauth): let IDJAGGrant resolve users by issuer and subject</li>
<li><a
href="https://github.com/authlib/authlib/commit/e90d1b19bf216e1359058b4cdb86ab557fb0a9f3"><code>e90d1b1</code></a>
chore: release 1.9.3</li>
<li><a
href="https://github.com/authlib/authlib/commit/ec4c7a16c5dadbc6c51a868456aba527785f8637"><code>ec4c7a1</code></a>
Merge commit from fork</li>
<li>See full diff in <a
href="https://github.com/authlib/authlib/compare/v1.9.2...v1.9.5">compare
view</a></li>
</ul>
</details>
<br />

Updates `joserfc` from 1.7.7 to 1.8.0
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/authlib/joserfc/releases">joserfc's
releases</a>.</em></p>
<blockquote>
<h2>1.8.0</h2>
<h3>   🐞 Bug Fixes</h3>
<ul>
<li>Use python&gt;=3.11, improve type hints  -  by <a
href="https://github.com/lepture"><code>@​lepture</code></a> <a
href="https://github.com/authlib/joserfc/commit/7c6de8c"><!-- raw HTML
omitted -->(7c6de)<!-- raw HTML omitted --></a></li>
<li>Replace typing as t syntax  -  by <a
href="https://github.com/lepture"><code>@​lepture</code></a> <a
href="https://github.com/authlib/joserfc/commit/7422af8"><!-- raw HTML
omitted -->(7422a)<!-- raw HTML omitted --></a></li>
<li>Treat claims option &quot;value&quot; as the expected claim value
 -  by <a
href="https://github.com/choksi2212"><code>@​choksi2212</code></a> <a
href="https://github.com/authlib/joserfc/commit/232d647"><!-- raw HTML
omitted -->(232d6)<!-- raw HTML omitted --></a></li>
<li><strong>jwe</strong>: Refresh ECDH ephemeral keys per encryption  - 
by <a href="https://github.com/lepture"><code>@​lepture</code></a> <a
href="https://github.com/authlib/joserfc/commit/cc6ec3f"><!-- raw HTML
omitted -->(cc6ec)<!-- raw HTML omitted --></a></li>
<li><strong>jwe</strong>: Reject explicitly empty AAD  -  by <a
href="https://github.com/lepture"><code>@​lepture</code></a> <a
href="https://github.com/authlib/joserfc/commit/96ef53a"><!-- raw HTML
omitted -->(96ef5)<!-- raw HTML omitted --></a></li>
<li><strong>jwk</strong>: Reject certificates and prefixed public keys
as oct  -  by <a
href="https://github.com/lepture"><code>@​lepture</code></a> <a
href="https://github.com/authlib/joserfc/commit/bf66415"><!-- raw HTML
omitted -->(bf664)<!-- raw HTML omitted --></a></li>
<li><strong>jws</strong>: Protect critical JSON headers and b64 mode  - 
by <a href="https://github.com/lepture"><code>@​lepture</code></a> <a
href="https://github.com/authlib/joserfc/commit/04cb5d8"><!-- raw HTML
omitted -->(04cb5)<!-- raw HTML omitted --></a></li>
<li><strong>jwt</strong>: Reject tokens whose exp equals the current
time  -  by <a
href="https://github.com/carlok"><code>@​carlok</code></a> and
<strong>Claude Sonnet 5.5</strong> <a
href="https://github.com/authlib/joserfc/commit/e2e3a29"><!-- raw HTML
omitted -->(e2e3a)<!-- raw HTML omitted --></a></li>
<li><strong>jwt</strong>: Reject NaN and Infinity in exp, nbf and iat
 -  by <a href="https://github.com/carlok"><code>@​carlok</code></a> and
<strong>Claude Sonnet 5.5</strong> <a
href="https://github.com/authlib/joserfc/commit/b8a2394"><!-- raw HTML
omitted -->(b8a23)<!-- raw HTML omitted --></a></li>
<li><strong>jwt</strong>: Reject True/False in exp/nbf/iat  -  by <a
href="https://github.com/lepture"><code>@​lepture</code></a> <a
href="https://github.com/authlib/joserfc/commit/953e509"><!-- raw HTML
omitted -->(953e5)<!-- raw HTML omitted --></a></li>
<li><strong>mypy</strong>: Use NotRequired in TypedDict  -  by <a
href="https://github.com/lepture"><code>@​lepture</code></a> <a
href="https://github.com/authlib/joserfc/commit/351c1b3"><!-- raw HTML
omitted -->(351c1)<!-- raw HTML omitted --></a></li>
</ul>
<h5>    <a
href="https://github.com/authlib/joserfc/compare/1.7.7...1.8.0">View
changes on GitHub</a></h5>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/authlib/joserfc/blob/main/docs/changelog.rst">joserfc's
changelog</a>.</em></p>
<blockquote>
<h2>1.8.0</h2>
<p><strong>Released on November 3, 2026</strong></p>
<ul>
<li><strong>JWT</strong>: Reject non-finite and boolean NumericDate
claims, and reject JWT payloads
that are not JSON objects.</li>
<li><strong>JWK</strong>: Reject X.509 certificates and prefixed
asymmetric public keys as oct keys.</li>
<li><strong>JWS</strong>: Require critical headers and RFC 7797
<code>b64</code> parameters to be protected,
and require a consistent <code>b64</code> value across General JSON
signatures.</li>
<li><strong>JWE</strong>: Generate fresh ECDH ephemeral keys for each
encryption and reject an
explicitly empty AAD member.</li>
<li>Drop Python 3.10 support; Python 3.11 or newer is required.</li>
<li>Preserve key subclasses in factory return types with
<code>typing.Self</code>.</li>
<li>Improve claim validation type narrowing with
<code>typing.TypeGuard</code> and refine
password, native key, and equality annotations.</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/authlib/joserfc/commit/f345ff71cd6c36452a4e11e47b805ad450a95dd8"><code>f345ff7</code></a>
chore: release 1.8.0</li>
<li><a
href="https://github.com/authlib/joserfc/commit/f1a3e436439c43905c96ddfb1abad54c38b40f2c"><code>f1a3e43</code></a>
chore: update deps</li>
<li><a
href="https://github.com/authlib/joserfc/commit/96ef53afe0f1af4904fa9c20d7a0f757518751ae"><code>96ef53a</code></a>
fix(jwe): reject explicitly empty AAD</li>
<li><a
href="https://github.com/authlib/joserfc/commit/cc6ec3fdf5ebe144f7d9f7d7cdfed8c82f51b4d4"><code>cc6ec3f</code></a>
fix(jwe): refresh ECDH ephemeral keys per encryption</li>
<li><a
href="https://github.com/authlib/joserfc/commit/04cb5d8bf7feff8d96fe804fe89cd531e8a3af25"><code>04cb5d8</code></a>
fix(jws): protect critical JSON headers and b64 mode</li>
<li><a
href="https://github.com/authlib/joserfc/commit/bf664155d1d057779c0db93ac2e677dba75c86e5"><code>bf66415</code></a>
fix(jwk): reject certificates and prefixed public keys as oct</li>
<li><a
href="https://github.com/authlib/joserfc/commit/953e509ebea1ef98636d2dcd2eec720ac64721c8"><code>953e509</code></a>
fix(jwt): reject True/False in exp/nbf/iat</li>
<li><a
href="https://github.com/authlib/joserfc/commit/6ec88f2bbbc89a46c7e62821f964df1792364040"><code>6ec88f2</code></a>
Merge pull request <a
href="https://redirect.github.com/authlib/joserfc/issues/109">#109</a>
from carlok/fix/exp-boundary</li>
<li><a
href="https://github.com/authlib/joserfc/commit/b8a23948eca85ebbc713a1f804bcd1508defa060"><code>b8a2394</code></a>
fix(jwt): reject NaN and Infinity in exp, nbf and iat</li>
<li><a
href="https://github.com/authlib/joserfc/commit/e2e3a29e58ae67127193286317bc23513720b5bd"><code>e2e3a29</code></a>
fix(jwt): reject tokens whose exp equals the current time</li>
<li>Additional commits viewable in <a
href="https://github.com/authlib/joserfc/compare/1.7.7...1.8.0">compare
view</a></li>
</ul>
</details>
<br />

Updates `seleniumbase` from 4.56.5 to 4.57.1
<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.57.1 - Linux <code>aarch64</code> driver support (and more)</h2>
<h2>Linux <code>aarch64</code> driver support (and more)</h2>
<ul>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/c737901b527516e569ba4ce66c919b5f69e2865f">Add
support for &quot;aarch64&quot; drivers on Linux</a>
--&gt; This resolves <a
href="https://redirect.github.com/seleniumbase/SeleniumBase/issues/4546">seleniumbase/SeleniumBase#4546</a></li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/8504f66f0095188cc53f580cd890367ea1b09e42">Update
the default geckodriver version</a></li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/ebebbc92b288cd77b84131b2cb38a34d065fd483">Refresh
Python dependencies</a></li>
</ul>
<h2>What's Changed</h2>
<ul>
<li>Linux <code>aarch64</code> driver support (and more) by <a
href="https://github.com/mdmintz"><code>@​mdmintz</code></a> in <a
href="https://redirect.github.com/seleniumbase/SeleniumBase/pull/4547">seleniumbase/SeleniumBase#4547</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/seleniumbase/SeleniumBase/compare/v4.57.0...v4.57.1">https://github.com/seleniumbase/SeleniumBase/compare/v4.57.0...v4.57.1</a></p>
<h2>4.57.0 - CDP and MCP updates (and dependencies)</h2>
<h2>CDP and MCP updates (and dependencies)</h2>
<ul>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/aa7cdcd84470d2ae7c6670ff2da34696ce15d0da">Add
timeout arg to CDP Mode's get_attribute() method</a>
--&gt; This resolves <a
href="https://redirect.github.com/seleniumbase/SeleniumBase/issues/4542">seleniumbase/SeleniumBase#4542</a></li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/737093e6130102351efb3cdb0b963b6f2e323540">Auto-detect
when evaluate() needs to await a Promise</a>
--&gt; This resolves <a
href="https://redirect.github.com/seleniumbase/SeleniumBase/issues/4543">seleniumbase/SeleniumBase#4543</a></li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/5a2b40fc85fcb46ae36a2bc2a305879568ec8ba5">Update
the default Chromium config</a>
--&gt; More stealth</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/d4072a4b3848a166983cd0fc5762d3af3e31a9df">Refresh
Python dependencies</a>
--&gt; <code>pip</code>
--&gt; <code>websockets</code>
--&gt; <code>filelock</code>
--&gt; <code>trio</code>
--&gt; <code>selenium</code>
--&gt; <code>uv</code> (optional dependency)</li>
</ul>
<h2>What's Changed</h2>
<ul>
<li>CDP and MCP updates (and dependencies) by <a
href="https://github.com/mdmintz"><code>@​mdmintz</code></a> in <a
href="https://redirect.github.com/seleniumbase/SeleniumBase/pull/4545">seleniumbase/SeleniumBase#4545</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/seleniumbase/SeleniumBase/compare/v4.56.5...v4.57.0">https://github.com/seleniumbase/SeleniumBase/compare/v4.56.5...v4.57.0</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/d98668b152f79a09c3f348af5173e67cccd8232d"><code>d98668b</code></a>
Merge pull request <a
href="https://redirect.github.com/seleniumbase/SeleniumBase/issues/4547">#4547</a>
from seleniumbase/linux-aarch64-driver-support</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/2c46b3bd8cb957c4577f79a279ed24d33e4d2906"><code>2c46b3b</code></a>
Version 4.57.1</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/c30c9b4b6a53eda06e445fa2b8021012251d89d0"><code>c30c9b4</code></a>
Update MCP versioning</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/ff6b226c597db9b8af4e6e8a686cf324f6552b63"><code>ff6b226</code></a>
Update the ReadMe</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/ebebbc92b288cd77b84131b2cb38a34d065fd483"><code>ebebbc9</code></a>
Refresh Python dependencies</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/d71ca0ca298e621f3898b032a3e827d569c8193d"><code>d71ca0c</code></a>
Update the docs</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/8504f66f0095188cc53f580cd890367ea1b09e42"><code>8504f66</code></a>
Update the default geckodriver version</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/c737901b527516e569ba4ce66c919b5f69e2865f"><code>c737901</code></a>
Add support for &quot;aarch64&quot; drivers on Linux</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/5b87e78f577c1c57f033a3f98543e04f59fef4d6"><code>5b87e78</code></a>
Update examples and the ReadMe</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/b0e0a33aaf0e166dd2791bafcad29a688d197998"><code>b0e0a33</code></a>
Update the ReadMe</li>
<li>Additional commits viewable in <a
href="https://github.com/seleniumbase/SeleniumBase/compare/v4.56.5...v4.57.1">compare
view</a></li>
</ul>
</details>
<br />

Updates `prek` from 0.5.9 to 0.5.10
<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.10</h2>
<h2>Release Notes</h2>
<p>Released on 2026-11-04.</p>
<h3>Enhancements</h3>
<ul>
<li>Add a builtin <code>replace-pattern</code> hook (<a
href="https://redirect.github.com/j178/prek/pull/2981">#2981</a>)</li>
</ul>
<h3>Bug fixes</h3>
<ul>
<li>Avoid downloading Node prereleases by default (<a
href="https://redirect.github.com/j178/prek/pull/2995">#2995</a>)</li>
<li>Keep file checks out of rayon's recursive frames (<a
href="https://redirect.github.com/j178/prek/pull/2999">#2999</a>)</li>
<li>Skip Node releases without a compatible archive (<a
href="https://redirect.github.com/j178/prek/pull/2996">#2996</a>)</li>
</ul>
<h3>Documentation</h3>
<ul>
<li>Add equivalent prek.toml examples throughout the documentation (<a
href="https://redirect.github.com/j178/prek/pull/2979">#2979</a>)</li>
</ul>
<h3>Contributors</h3>
<ul>
<li><a
href="https://github.com/connorbrinton"><code>@​connorbrinton</code></a></li>
<li><a href="https://github.com/j178"><code>@​j178</code></a></li>
</ul>
<h2>Install prek 0.5.10</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.10/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.10/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.10</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.10/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.10/prek-aarch64-apple-darwin.tar.gz.sha256">checksum</a></td>
</tr>
<tr>
<td><a
href="https://github.com/j178/prek/releases/download/v0.5.10/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.10/prek-x86_64-apple-darwin.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/j178/prek/blob/main/CHANGELOG.md">prek's
changelog</a>.</em></p>
<blockquote>
<h2>0.5.10</h2>
<p>Released on 2026-11-04.</p>
<h3>Enhancements</h3>
<ul>
<li>Add a builtin <code>replace-pattern</code> hook (<a
href="https://redirect.github.com/j178/prek/pull/2981">#2981</a>)</li>
</ul>
<h3>Bug fixes</h3>
<ul>
<li>Avoid downloading Node prereleases by default (<a
href="https://redirect.github.com/j178/prek/pull/2995">#2995</a>)</li>
<li>Keep file checks out of rayon's recursive frames (<a
href="https://redirect.github.com/j178/prek/pull/2999">#2999</a>)</li>
<li>Skip Node releases without a compatible archive (<a
href="https://redirect.github.com/j178/prek/pull/2996">#2996</a>)</li>
</ul>
<h3>Documentation</h3>
<ul>
<li>Add equivalent prek.toml examples throughout the documentation (<a
href="https://redirect.github.com/j178/prek/pull/2979">#2979</a>)</li>
</ul>
<h3>Contributors</h3>
<ul>
<li><a
href="https://github.com/connorbrinton"><code>@​connorbrinton</code></a></li>
<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/516f5bdce9eab921021edb1c22305ad569605d50"><code>516f5bd</code></a>
Bump version to 0.5.10 (<a
href="https://redirect.github.com/j178/prek/issues/3001">#3001</a>)</li>
<li><a
href="https://github.com/j178/prek/commit/6201a32db79e732b1beff563715751e7077f4510"><code>6201a32</code></a>
Upgrade serde-saphyr to 2.1.0 (<a
href="https://redirect.github.com/j178/prek/issues/2877">#2877</a>)</li>
<li><a
href="https://github.com/j178/prek/commit/d08703e86427a7c1e593e526f4cbd18a92f4b085"><code>d08703e</code></a>
Infer the release version from Cargo metadata (<a
href="https://redirect.github.com/j178/prek/issues/3000">#3000</a>)</li>
<li><a
href="https://github.com/j178/prek/commit/f03096afd4fd8f00847a6618e8e31e6397b336f1"><code>f03096a</code></a>
Keep file checks out of rayon's recursive frames (<a
href="https://redirect.github.com/j178/prek/issues/2999">#2999</a>)</li>
<li><a
href="https://github.com/j178/prek/commit/c88a1ccc522e24546d348b55aa83332022a6fae9"><code>c88a1cc</code></a>
Skip Node releases without a compatible archive (<a
href="https://redirect.github.com/j178/prek/issues/2996">#2996</a>)</li>
<li><a
href="https://github.com/j178/prek/commit/10e7648e55e1c34e7b10118d4e5aa2acc1933c8d"><code>10e7648</code></a>
Avoid downloading Node prereleases by default (<a
href="https://redirect.github.com/j178/prek/issues/2995">#2995</a>)</li>
<li><a
href="https://github.com/j178/prek/commit/f1bfe9d6ea38503c9880d097c1c937d2141fc187"><code>f1bfe9d</code></a>
Update prek hooks (<a
href="https://redirect.github.com/j178/prek/issues/2993">#2993</a>)</li>
<li><a
href="https://github.com/j178/prek/commit/71e02db63b6d42e21f83744c3580bf8685b6fc9b"><code>71e02db</code></a>
Make release publishing resumable (<a
href="https://redirect.github.com/j178/prek/issues/2992">#2992</a>)</li>
<li><a
href="https://github.com/j178/prek/commit/84c3c67ad0c076848ddfbac4823bf164a696f7ce"><code>84c3c67</code></a>
Update CI tools (<a
href="https://redirect.github.com/j178/prek/issues/2983">#2983</a>)</li>
<li><a
href="https://github.com/j178/prek/commit/e4a4b4d7d247e89b26da118329ac11bb3da58ef5"><code>e4a4b4d</code></a>
Group CI tools and compatible Rust dependencies (<a
href="https://redirect.github.com/j178/prek/issues/2991">#2991</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/j178/prek/compare/v0.5.9...v0.5.10">compare
view</a></li>
</ul>
</details>
<br />

Updates `ruff` from 0.17.3 to 0.17.4
<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.17.4</h2>
<h2>Release Notes</h2>
<p>Released on 2026-11-05.</p>
<h3>Preview features</h3>
<ul>
<li>[<code>flake8-logging-format</code>] Avoid behavior changes and
syntax errors in fixes (<code>G004</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/27419">#27419</a>)</li>
<li>[<code>security</code>] Implement
<code>tarfile-ignore-zeros-true</code> (<a
href="https://redirect.github.com/astral-sh/ruff/pull/28680">#28680</a>)</li>
</ul>
<h3>Bug fixes</h3>
<ul>
<li>Accept a bare raise before a semicolon (<a
href="https://redirect.github.com/astral-sh/ruff/pull/29761">#29761</a>)</li>
<li>Allow match identifiers after a semicolon (<a
href="https://redirect.github.com/astral-sh/ruff/pull/29760">#29760</a>)</li>
<li>Fix panic on BOM-prefixed files in diagnostic renderer (<a
href="https://redirect.github.com/astral-sh/ruff/pull/30017">#30017</a>)</li>
<li>[<code>flake8-simplify</code>] Report collapsible <code>elif</code>
clauses after a multi-statement <code>if</code> branch
(<code>SIM102</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/29814">#29814</a>)</li>
</ul>
<h3>Rule changes</h3>
<ul>
<li>Show more nonprinting characters in diagnostic fix diffs (<a
href="https://redirect.github.com/astral-sh/ruff/pull/30062">#30062</a>)</li>
</ul>
<h3>Performance</h3>
<ul>
<li>Coalesce source slices when formatting attributes (<a
href="https://redirect.github.com/astral-sh/ruff/pull/29841">#29841</a>)</li>
<li>Collect return statements with a statement-only visitor (<a
href="https://redirect.github.com/astral-sh/ruff/pull/29911">#29911</a>)</li>
<li>[<code>pyflakes</code>] Defer value comparisons for repeated
dictionary keys (<code>F601</code>, <code>F602</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/29739">#29739</a>)</li>
<li>Format node fields directly when comment handling is unnecessary (<a
href="https://redirect.github.com/astral-sh/ruff/pull/29853">#29853</a>)</li>
<li>Reduce formatter source-slice overhead (<a
href="https://redirect.github.com/astral-sh/ruff/pull/29855">#29855</a>)</li>
<li>Reuse a callee's qualified name across lint rules (<a
href="https://redirect.github.com/astral-sh/ruff/pull/29916">#29916</a>)</li>
<li>Skip invalid string character checks when the source contains none
(<a
href="https://redirect.github.com/astral-sh/ruff/pull/29883">#29883</a>)</li>
<li>Use a Bloom filter for formatter comment lookups (<a
href="https://redirect.github.com/astral-sh/ruff/pull/29852">#29852</a>)</li>
<li>Use a concrete buffer for ordinary writes (<a
href="https://redirect.github.com/astral-sh/ruff/pull/29819">#29819</a>)</li>
</ul>
<h3>CLI</h3>
<ul>
<li>Give <code>NO_COLOR</code> precedence in Ruff and ty diagnostics (<a
href="https://redirect.github.com/astral-sh/ruff/pull/29898">#29898</a>)</li>
<li>Honor <code>CLICOLOR=0</code> in Ruff and ty diagnostics (<a
href="https://redirect.github.com/astral-sh/ruff/pull/29910">#29910</a>)</li>
</ul>
<h3>Documentation</h3>
<ul>
<li>Correct the documented <code>version</code> and <code>src</code>
defaults for <code>ruff-action</code> (<a
href="https://redirect.github.com/astral-sh/ruff/pull/29880">#29880</a>)</li>
<li>[<code>flake8-bugbear</code>] Make <code>B014</code> example error
out-of-the-box (<a
href="https://redirect.github.com/astral-sh/ruff/pull/29941">#29941</a>)</li>
<li>[<code>flake8-no-pep420</code>] Document preview behavior
(<code>INP001</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/29764">#29764</a>)</li>
<li>[<code>isort</code>] Expand <code>I001</code> documentation (<a
href="https://redirect.github.com/astral-sh/ruff/pull/29843">#29843</a>)</li>
</ul>
<h3>Other changes</h3>
<ul>
<li>[syntax-errors] Extend <code>__debug__</code> write check to
attribute targets (<a
href="https://redirect.github.com/astral-sh/ruff/pull/27908">#27908</a>)</li>
</ul>
<h3>Contributors</h3>
<!-- 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.17.4</h2>
<p>Released on 2026-11-05.</p>
<h3>Preview features</h3>
<ul>
<li>[<code>flake8-logging-format</code>] Avoid behavior changes and
syntax errors in fixes (<code>G004</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/27419">#27419</a>)</li>
<li>[<code>security</code>] Implement
<code>tarfile-ignore-zeros-true</code> (<a
href="https://redirect.github.com/astral-sh/ruff/pull/28680">#28680</a>)</li>
</ul>
<h3>Bug fixes</h3>
<ul>
<li>Accept a bare raise before a semicolon (<a
href="https://redirect.github.com/astral-sh/ruff/pull/29761">#29761</a>)</li>
<li>Allow match identifiers after a semicolon (<a
href="https://redirect.github.com/astral-sh/ruff/pull/29760">#29760</a>)</li>
<li>Fix panic on BOM-prefixed files in diagnostic renderer (<a
href="https://redirect.github.com/astral-sh/ruff/pull/30017">#30017</a>)</li>
<li>[<code>flake8-simplify</code>] Report collapsible <code>elif</code>
clauses after a multi-statement <code>if</code> branch
(<code>SIM102</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/29814">#29814</a>)</li>
</ul>
<h3>Rule changes</h3>
<ul>
<li>Show more nonprinting characters in diagnostic fix diffs (<a
href="https://redirect.github.com/astral-sh/ruff/pull/30062">#30062</a>)</li>
</ul>
<h3>Performance</h3>
<ul>
<li>Coalesce source slices when formatting attributes (<a
href="https://redirect.github.com/astral-sh/ruff/pull/29841">#29841</a>)</li>
<li>Collect return statements with a statement-only visitor (<a
href="https://redirect.github.com/astral-sh/ruff/pull/29911">#29911</a>)</li>
<li>[<code>pyflakes</code>] Defer value comparisons for repeated
dictionary keys (<code>F601</code>, <code>F602</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/29739">#29739</a>)</li>
<li>Format node fields directly when comment handling is unnecessary (<a
href="https://redirect.github.com/astral-sh/ruff/pull/29853">#29853</a>)</li>
<li>Reduce formatter source-slice overhead (<a
href="https://redirect.github.com/astral-sh/ruff/pull/29855">#29855</a>)</li>
<li>Reuse a callee's qualified name across lint rules (<a
href="https://redirect.github.com/astral-sh/ruff/pull/29916">#29916</a>)</li>
<li>Skip invalid string character checks when the source contains none
(<a
href="https://redirect.github.com/astral-sh/ruff/pull/29883">#29883</a>)</li>
<li>Use a Bloom filter for formatter comment lookups (<a
href="https://redirect.github.com/astral-sh/ruff/pull/29852">#29852</a>)</li>
<li>Use a concrete buffer for ordinary writes (<a
href="https://redirect.github.com/astral-sh/ruff/pull/29819">#29819</a>)</li>
</ul>
<h3>CLI</h3>
<ul>
<li>Give <code>NO_COLOR</code> precedence in Ruff and ty diagnostics (<a
href="https://redirect.github.com/astral-sh/ruff/pull/29898">#29898</a>)</li>
<li>Honor <code>CLICOLOR=0</code> in Ruff and ty diagnostics (<a
href="https://redirect.github.com/astral-sh/ruff/pull/29910">#29910</a>)</li>
</ul>
<h3>Documentation</h3>
<ul>
<li>Correct the documented <code>version</code> and <code>src</code>
defaults for <code>ruff-action</code> (<a
href="https://redirect.github.com/astral-sh/ruff/pull/29880">#29880</a>)</li>
<li>[<code>flake8-bugbear</code>] Make <code>B014</code> example error
out-of-the-box (<a
href="https://redirect.github.com/astral-sh/ruff/pull/29941">#29941</a>)</li>
<li>[<code>flake8-no-pep420</code>] Document preview behavior
(<code>INP001</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/29764">#29764</a>)</li>
<li>[<code>isort</code>] Expand <code>I001</code> documentation (<a
href="https://redirect.github.com/astral-sh/ruff/pull/29843">#29843</a>)</li>
</ul>
<h3>Other changes</h3>
<ul>
<li>[syntax-errors] Extend <code>__debug__</code> write check to
attribute targets (<a
href="https://redirect.github.com/astral-sh/ruff/pull/27908">#27908</a>)</li>
</ul>
<h3>Contributors</h3>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/astral-sh/ruff/commit/05cdc36a45a29e078c2af9358969d0f576408bef"><code>05cdc36</code></a>
Bump version to 0.17.4 (<a
href="https://redirect.github.com/astral-sh/ruff/issues/30073">#30073</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/c6b17c2b0bd1a0ffe6cd562c6dfaeece2a65f742"><code>c6b17c2</code></a>
[ty] Freshen method typevars before owner specialization (<a
href="https://redirect.github.com/astral-sh/ruff/issues/29543">#29543</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/4b0b407bfccff1d6aa46e2f26112f334bfab3140"><code>4b0b407</code></a>
[ty] Preserve unions inferred during fixpoint iteration (<a
href="https://redirect.github.com/astral-sh/ruff/issues/29927">#29927</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/1dd3e9ec7eb8da6b39318ef1084bc070a53ae272"><code>1dd3e9e</code></a>
Show more nonprinting characters in diagnostic fix diffs (<a
href="https://redirect.github.com/astral-sh/ruff/issues/30062">#30062</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/32be8e7258782da94b18752249971be4708d485c"><code>32be8e7</code></a>
[ty] Skip fixpoint iteration for simple generic calls (<a
href="https://redirect.github.com/astral-sh/ruff/issues/30054">#30054</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/926ccc883c5cf3d54b1dea610603acd2aae93c19"><code>926ccc8</code></a>
[ty] Handle output errors when generating shell completions (<a
href="https://redirect.github.com/astral-sh/ruff/issues/29997">#29997</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/c86652abceafe81457858c75c9233eadf1d39912"><code>c86652a</code></a>
[ty] Skip active Conda and Pixi environments during discovery if the
environm...</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/c9150f008303c86ad49675f73b75b923f0013bdf"><code>c9150f0</code></a>
Add project walltime benchmarks for Ruff (<a
href="https://redirect.github.com/astral-sh/ruff/issues/30061">#30061</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/64989ec2a42c65dabb471e20976f4f014c966f27"><code>64989ec</code></a>
[ty] Report invalid module globs once per override pattern (<a
href="https://redirect.github.com/astral-sh/ruff/issues/30031">#30031</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/367a5639d2bbd4a641e5fb4a1aaa6a41beab9711"><code>367a563</code></a>
[ty] Fix conditional type and tuple annotations (<a
href="https://redirect.github.com/astral-sh/ruff/issues/29987">#29987</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/astral-sh/ruff/compare/0.17.3...0.17.4">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>

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

<!-- coderabbit-ai-generated:v1 -->

## Summary by CodeRabbit

* **Chores**
* Updated internal project configuration. No user-facing changes are
included in this update.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-08 23:02:41 -07:00
splitsec2 e828bdbe9c feat(slskd): answer anti-leech chat challenges from uploaders, off by default (#1591)
Some Soulseek uploaders run a bot that blocks your downloads until you
type a word to them in a private message ("please type 'download' in
this chat"). The files sit queued until someone answers, and nobody
looking at Shelfmark would know why.

While a slskd download is polling, this checks slskd's unacknowledged
private messages about once a minute, replies with the word the message
asks for and marks the conversation read. It only matches three known
phrasings, the word has to fit `[A-Za-z0-9_-]{1,24}`, and each user gets
at most one reply per 12 hours (recorded after a successful send). Every
reply is logged at INFO.

The challenge patterns and the one-reply-per-12-hours idea come from
@Nezreka's [SoulSync](https://github.com/Nezreka/SoulSync)
(`core/chat_autoprove.py`, commit 445473b, MIT). I reworked it for
slskd's client and this download handler. The credit is in the module
docstring and the commit message too.

This sends messages to other people on your behalf without asking, so
the setting `SLSKD_CHAT_AUTOPROVE` is off by default here. Say if you'd
rather it not exist at all, or want it somewhere other than the slskd
settings.

It has run on my instance since October 22 without seeing a real
challenge, so the reply path is covered by tests but not by a live one.
The same code works in nezreka/soulsync with the same slskd client so I
am confident in it. Ruff, basedpyright and the slskd tests pass, and
`docs/environment-variables.md` and the readme line for slskd are
updated.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
<!-- coderabbit-ai-generated:v1 -->

## Summary by CodeRabbit

* **New Features**
* Added an optional setting to automatically answer supported Soulseek
private-message challenges while a download is running. When enabled,
Shelfmark checks unread messages, replies to each person at most once
every 12 hours, logs replies, and marks answered conversations as read.
The setting is off by default.
* **Documentation**
* Updated the environment-variable and Soulseek setup documentation with
details about the setting and its behavior, including a reminder to
leave it off if you read slskd messages yourself.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-11-08 22:48:25 -07:00
splitsec2 a01e700c81 fix(auto-download): only fulfil requests the owner could download directly (#1676)
Automatic downloads fulfil pending Hardcover requests as an admin, and
they picked up every pending request with provider `hardcover`. A
request a user makes from the search UI carries that provider too, so
with automatic downloads on, a request from a user whose policy needs
approval was approved by the pass instead of waiting for an admin. I
added that behaviour in #1516 and #1535 and missed this case.

The pass now runs the same check the download route makes. A request is
searched only on the release sources where its owner's resolved policy
(global settings plus their own overrides) is Download. Requests owned
by an admin, installs without login, and users with requests turned off
are not limited, since all of them can already download directly. A
request left with no source stays pending and untouched: no search, no
backoff state, no note. The 7-day and quick retries only reopen a failed
download on the same terms, so a request the pass can't fulfil isn't
moved back to pending for nothing.

The pass summary gains an `awaiting_approval` count. The
`AUTO_DOWNLOAD_ENABLED` description, the env var reference and the
Hardcover sync docs say which requests it approves.

Tests cover approval needed, direct download allowed, admin owned,
blocked, a per-source rule, a per-user override, requests turned off, no
login, and the retry reopen.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
<!-- coderabbit-ai-generated:v1 -->

## Summary by CodeRabbit

* **Bug Fixes**
* Automatic downloads now respect each request owner’s policy and
permitted sources. Requests that require approval or are blocked by
policy remain pending rather than being searched.
* Failed downloads are reopened for retry only when the owner’s policy
allows direct fulfillment.
* Admin-owned requests and requests in unauthenticated mode retain their
existing automatic fulfillment behavior.
* **Documentation**
* Clarified how request ownership, download policies, approval
requirements, and retries affect automatic downloads.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-11-08 22:39:07 -07:00
splitsec2 8c06862ec7 feat(requests): let a book be requested alongside downloads (#1579)
In Download mode there is no way to ask for a book that has nothing to
download yet. The release modal has an "Add to requests" chip for
admins, which sends a book-level request, and when the default mode is
Download the server answers it with "Download policy requires a concrete
release". That is #995, where @expostarv2 used to bookmark unreleased
books this way. #1019 is the other side of it: @punkincognito wants a
less technical user to be able to download from one source and, when
that fails, ask for the book without choosing a release.

A book-level request in Download mode now becomes a normal pending book
request, stored with the mode `request_book`, when the actor is an admin
or when a new setting allows it: **Allow book requests alongside
downloads** (`REQUESTS_ALLOW_BOOK_REQUEST_WITH_DOWNLOAD`, off by
default, per-user overridable). A request that names a release still
goes straight to the download queue as before, and with the setting off
a non-admin gets the same error as today, so nothing changes for an
existing install unless it turns the setting on.

The policy endpoint reports the setting, and the release modal shows
"Add to requests" to a non-admin when requests are on and the setting
allows it (admins keep it as before, and it stays off while fulfilling a
request). The small `canRequestBookFromReleaseModal` helper holds that
rule so it can be tested.

I chose a setting over making this the default for everyone because it
changes what Download mode means, which is yours to decide. The other
way to read #1019 is a per-source rule, "Request Book" for Prowlarr and
Download for the direct sources. That is not here: the rules matrix
excludes Request Book today, the release modal shows such a source as
unavailable, and it needs more UI work than I can check without trying
it in a browser. This PR covers the half that works with the existing
flow, and the rule version can follow if you want it.

Tests: four route tests (a user with the setting on, an admin with it
off, a release request that must still queue a download, and the policy
endpoint reporting the setting), and a frontend test for the helper. The
route tests that depend on the change fail on current main. Full backend
and frontend suites pass, with ruff, oxlint and tsc clean. I have not
tried it in a browser, so this is a draft until someone has looked at
the modal.


Closes #995. #1019 stays open for the per-source rule.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
<!-- coderabbit-ai-generated:v1 -->

## Summary by CodeRabbit

* **New Features**
* Added an off-by-default, user-overridable setting that lets users with
download access request a book without selecting a release. These
requests remain pending for admin action, and admins can always make
book-level requests.
* Eligible users can make book-level requests under the download policy
when the setting is enabled. This option is unavailable in browse-fulfil
mode.
* Requests that include release details continue to queue downloads
rather than create book-level requests.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-11-08 17:56:10 -07:00
splitsec2 c38f4c6522 feat(activity): Clear Completed also clears finished requests with no download (#1650)
A request that auto-download closes because the book is already in the
library never gets a download, so Clear Completed skipped it and those
requests piled up under Complete. The button also disappeared once the
last finished download was cleared, leaving no way to clear them.

ActivitySidebar.tsx: hasTerminalDownloadItems is dropped; the button
shows whenever getClearCompletedTargets returns anything, and on the
Requests tab as well as Downloads and All.

clearCompleted.ts: a request card with no download of its own is a
target when its request is fulfilled with no active delivery_state
(queued, resolving, locating, downloading), or cancelled. Pending and
rejected requests are left alone. The server already treats any
fulfilled request as terminal in _request_terminal_status, so the client
rule is the stricter of the two.

docs/users-and-requests.md: a line under Clearing the activity list
saying what the button clears.

Tests: two new cases in clearCompleted.test.ts, one for a closed request
and a cancelled one with no download, one that pending, rejected and
still-downloading requests are not touched.

Builds on #1438.



<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
<!-- coderabbit-ai-generated:v1 -->

## Summary by CodeRabbit

* **New Features**
* **Clear Completed** can appear on the Requests tab, as well as
Downloads and All, when there are items to clear.
* Clearing completed items removes finished downloads and their
requests, fulfilled requests that are no longer in progress, and
cancelled requests without downloads. Pending and rejected requests
remain.
* **Documentation**
* Updated the help documentation to explain what **Clear Completed**
removes.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-11-08 14:52:19 -07:00
splitsec2 371ac75954 feat(activity): say in plain words what a failed download means and what happens next (#1617)
A failed or cancelled card showed the log line, things like `Peer
transfer failed: Errored` or a 500 with a URL in it, so a red card meant
going to the logs to find out whether anything was coming next. They now
say what happened and what happens next.

For a download that belongs to a request, the card says another copy
will be tried in about N minutes when the quick retry from #1535 is on,
and points at Retry or reopening the request when it isn't. A manual
download is never promised an automatic retry. The Soulseek cases each
get their own sentence: the peer dropped the transfer, the peer could
not be reached, slskd refused to queue it, and the peer never answered.
The "tried twice" wording leans on the retry from #1590.

The saved message is left alone because auto-download reads it to tell a
failed release from an interrupted one. Only what the activity list
shows is reworded, on a copy, and anything the new code doesn't
recognise comes back as it was.

It matches on how the message starts, so it is only as current as the
wording the rest of the code uses. A message that gets reworded
elsewhere falls back to the original line instead of breaking, which
seemed fine for something that only changes the display, but it is the
part I would look at first.

It has been running on my instance since mid-October. Ten new tests
cover each message and the next step, and the activity route tests still
pass. Ruff and basedpyright are clean on the changed files.
2026-11-08 14:51:52 -07:00
dependabot[bot] 318b7483fe build(deps): bump the gh-actions group with 4 updates (#1671)
Bumps the gh-actions group with 4 updates:
[astral-sh/setup-uv](https://github.com/astral-sh/setup-uv),
[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 `astral-sh/setup-uv` from 10.3.2 to 10.3.3
<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.3.3 🌈 Use Astral mirror for version manifests with GitHub
fallback</h2>
<h2>Changes</h2>
<p>This release contains something that should have been included a very
long time ago. Sorry for sleeping on that.</p>
<p>This action now retrieves the release manifest that contains the
available versions, their download links and checksums from the Astral
mirror, rather than trying GitHub first. This should present much more
stable.
(Known checksums are still checked, so the security impact does not
change)</p>
<p>Also a big thank you to <a
href="https://github.com/soujasK"><code>@​soujasK</code></a> for a set
of bugfixes 👏</p>
<h2>🐛 Bug fixes</h2>
<ul>
<li>Use Astral mirror for version manifests with GitHub fallback <a
href="https://github.com/eifinger"><code>@​eifinger</code></a> (<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/1119">#1119</a>)</li>
<li>fix: allow whitespace in uv requirement specifiers <a
href="https://github.com/soujasK"><code>@​soujasK</code></a> (<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/1112">#1112</a>)</li>
<li>fix: resolve ~= specifiers with PEP 440 semantics <a
href="https://github.com/soujasK"><code>@​soujasK</code></a> (<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/1111">#1111</a>)</li>
<li>fix: only report a saved cache when saving succeeded <a
href="https://github.com/soujasK"><code>@​soujasK</code></a> (<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/1110">#1110</a>)</li>
</ul>
<h2>🧰 Maintenance</h2>
<ul>
<li>chore: update known checksums for 0.13.6 @<a
href="https://github.com/apps/github-actions">github-actions[bot]</a>
(<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/1117">#1117</a>)</li>
</ul>
<h2>📚 Documentation</h2>
<ul>
<li>docs: use JSDoc for compatible release specifier comment <a
href="https://github.com/eifinger"><code>@​eifinger</code></a> (<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/1113">#1113</a>)</li>
<li>docs: update version references to v10.3.2 @<a
href="https://github.com/apps/github-actions">github-actions[bot]</a>
(<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/1104">#1104</a>)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/astral-sh/setup-uv/commit/68559e99cc807c1eb7e9238fd43a15bba460eec2"><code>68559e9</code></a>
Use Astral mirror for version manifests with GitHub fallback (<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/1119">#1119</a>)</li>
<li><a
href="https://github.com/astral-sh/setup-uv/commit/0192b6951893e558e24cfbd971d641a8cd336a95"><code>0192b69</code></a>
chore: update known checksums for 0.13.6 (<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/1117">#1117</a>)</li>
<li><a
href="https://github.com/astral-sh/setup-uv/commit/2f29784424ba0f5755e2d0bbc18227082a5daff1"><code>2f29784</code></a>
docs: use JSDoc for compatible release specifier comment (<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/1113">#1113</a>)</li>
<li><a
href="https://github.com/astral-sh/setup-uv/commit/9c7332190cf7291329c770bedb2ba1b3e441b37c"><code>9c73321</code></a>
fix: allow whitespace in uv requirement specifiers (<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/1112">#1112</a>)</li>
<li><a
href="https://github.com/astral-sh/setup-uv/commit/80fbe0140ccf08d50eff5eeb98dff9021c415a21"><code>80fbe01</code></a>
fix: resolve ~= specifiers with PEP 440 semantics (<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/1111">#1111</a>)</li>
<li><a
href="https://github.com/astral-sh/setup-uv/commit/36b7b57007634b5d947fd73fcd85c7b44863efec"><code>36b7b57</code></a>
fix: only report a saved cache when saving succeeded (<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/1110">#1110</a>)</li>
<li><a
href="https://github.com/astral-sh/setup-uv/commit/56618cab7a58e3efc9b282dd3de75bbf1075db9b"><code>56618ca</code></a>
docs: update version references to v10.3.2 (<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/1104">#1104</a>)</li>
<li>See full diff in <a
href="https://github.com/astral-sh/setup-uv/compare/07a047e608db84b3d8c06cd9a7c7dcc9c355c18e...68559e99cc807c1eb7e9238fd43a15bba460eec2">compare
view</a></li>
</ul>
</details>
<br />

Updates `github/codeql-action/init` from 4.39.1 to 4.39.2
<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.39.2</h2>
<ul>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.27.4">2.27.4</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/4284">#4284</a></li>
<li><em>Upcoming breaking change</em>: CodeQL version 2.22.3 and earlier
were discontinued on 16 October 2026 alongside GitHub Enterprise Server
3.18, and will be unsupported by the next minor release of the CodeQL
Action. Added a deprecation warning for customers using these versions
of CodeQL. <a
href="https://redirect.github.com/github/codeql-action/pull/4266">#4266</a></li>
<li>Starting with macOS 27 and Xcode 27, traced analyses are
unsupported. See <a
href="https://github.blog/changelog/2026-10-19-codeql-changes-for-macos-27-xcode-27-and-swift-6-4/">this
blog post for more information</a>. The CodeQL Action will now try to
identify if a workflow is about to perform a traced analysis on version
27 or above of macOS or Xcode. If so, the CodeQL Action will emit a
corresponding warning that will also be visible on the <a
href="https://docs.github.com/en/code-security/how-tos/find-and-fix-code-vulnerabilities/manage-your-configuration/use-the-tools-status-page-for-code-scanning">Tool
Status Page</a>. This check will be rolled out following this release of
the CodeQL Action. <a
href="https://redirect.github.com/github/codeql-action/pull/4221">#4221</a></li>
<li>The undocumented TRAP cache cleanup feature that could be enabled
using the <code>CODEQL_ACTION_CLEANUP_TRAP_CACHES</code> environment
variable has been removed, as announced in the release notes for CodeQL
Action 4.35.2. The <code>analyze</code> Action now warns if this
environment variable is set. If you used TRAP cache cleanup to limit
Actions cache usage, we recommend disabling TRAP caching by passing the
<code>trap-caching: false</code> input to the <code>init</code> Action.
<a
href="https://redirect.github.com/github/codeql-action/pull/4243">#4243</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.39.3 - 04 Nov 2026</h2>
<ul>
<li>Fixed diff-informed alert filtering incorrectly removing alerts in
changed files whose paths contain spaces, special characters, or
non-ASCII characters. <a
href="https://redirect.github.com/github/codeql-action/pull/4283">#4283</a></li>
</ul>
<h2>4.39.2 - 03 Nov 2026</h2>
<ul>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.27.4">2.27.4</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/4284">#4284</a></li>
<li><em>Upcoming breaking change</em>: CodeQL version 2.22.3 and earlier
were discontinued on 16 October 2026 alongside GitHub Enterprise Server
3.18, and will be unsupported by the next minor release of the CodeQL
Action. Added a deprecation warning for customers using these versions
of CodeQL. <a
href="https://redirect.github.com/github/codeql-action/pull/4266">#4266</a></li>
<li>Starting with macOS 27 and Xcode 27, traced analyses are
unsupported. See <a
href="https://github.blog/changelog/2026-10-19-codeql-changes-for-macos-27-xcode-27-and-swift-6-4/">this
blog post for more information</a>. The CodeQL Action will now try to
identify if a workflow is about to perform a traced analysis on version
27 or above of macOS or Xcode. If so, the CodeQL Action will emit a
corresponding warning that will also be visible on the <a
href="https://docs.github.com/en/code-security/how-tos/find-and-fix-code-vulnerabilities/manage-your-configuration/use-the-tools-status-page-for-code-scanning">Tool
Status Page</a>. This check will be rolled out following this release of
the CodeQL Action. <a
href="https://redirect.github.com/github/codeql-action/pull/4221">#4221</a></li>
<li>The undocumented TRAP cache cleanup feature that could be enabled
using the <code>CODEQL_ACTION_CLEANUP_TRAP_CACHES</code> environment
variable has been removed, as announced in the release notes for CodeQL
Action 4.35.2. The <code>analyze</code> Action now warns if this
environment variable is set. If you used TRAP cache cleanup to limit
Actions cache usage, we recommend disabling TRAP caching by passing the
<code>trap-caching: false</code> input to the <code>init</code> Action.
<a
href="https://redirect.github.com/github/codeql-action/pull/4243">#4243</a></li>
</ul>
<h2>4.39.1 - 21 Oct 2026</h2>
<ul>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.27.3">2.27.3</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/4256">#4256</a></li>
</ul>
<h2>4.39.0 - 15 Oct 2026</h2>
<ul>
<li><em>Breaking change</em>: Bump the minimum required CodeQL bundle
version to 2.20.7. <a
href="https://redirect.github.com/github/codeql-action/pull/4205">#4205</a></li>
<li>The <code>codeql-action/analyze</code> step now validates that a
correct argument for the <code>checkout_path</code> input has been
provided. The <code>checkout_path</code> input must be set to the path
where the repository that is being analyzed is checked out at on the
runner, if that path is something other than the workspace root. If the
path does not point to a repository checkout or is different from the
path that was used by <code>codeql-action/init</code>, then the CodeQL
Action now produces a warning. <a
href="https://redirect.github.com/github/codeql-action/pull/4148">#4148</a></li>
<li>Resolved an issue with third-party caching implementations that
prevented caches from being restored correctly. <a
href="https://redirect.github.com/github/codeql-action/pull/4206">#4206</a></li>
<li>The <code>codeql-action/start-proxy</code> step that is used in Code
Scanning Default Setup now finds CodeQL releases using the same logic as
<code>codeql-action/init</code>. This primarily benefits customers who
are using GitHub instances other than <code>github.com</code> where the
<code>codeql-action/start-proxy</code> step may previously have been
unable to locate recent releases and ended up using outdated versions of
the private registry authentication proxy as a result. <a
href="https://redirect.github.com/github/codeql-action/pull/4198">#4198</a></li>
</ul>
<h2>4.38.3 - 08 Oct 2026</h2>
<ul>
<li><em>Upcoming breaking change</em>: CodeQL version 2.21.2 and earlier
were discontinued on 24 September 2026 alongside GitHub Enterprise
Server 3.17, and will be unsupported by the next minor release of the
CodeQL Action. Added a deprecation warning for customers using these
versions of CodeQL. <a
href="https://redirect.github.com/github/codeql-action/pull/4188">#4188</a></li>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.27.2">2.27.2</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/4203">#4203</a></li>
<li>Fixed a bug where the decision of whether to use a per-language
bundle did not account for custom configurations that reference queries
outside of compiled CodeQL packs. This issue was caught during internal
testing and did not affect any customer repositories. We will resume the
roll out of per-language bundles in the coming weeks. <a
href="https://redirect.github.com/github/codeql-action/pull/4184">#4184</a></li>
</ul>
<h2>4.38.2 - 24 Sept 2026</h2>
<ul>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.27.1">2.27.1</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/4160">#4160</a></li>
</ul>
<h2>4.38.1 - 18 Sept 2026</h2>
<ul>
<li>The CodeQL Action now has experimental support for CodeQL releases
for which per-language bundles are available. Per-language bundles
support analysis for a single language and are therefore smaller than
the combined bundles that allow analysis for all supported languages. As
a result, per-language bundles take up less space on disk and are faster
to download. We expect to roll this change out to everyone in the coming
weeks. <a
href="https://redirect.github.com/github/codeql-action/pull/4146">#4146</a></li>
</ul>
<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>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/github/codeql-action/commit/1fc58d04a4d4374f921bdac573c3811c240f9c26"><code>1fc58d0</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4286">#4286</a>
from github/update-v4.39.2-c05042115</li>
<li><a
href="https://github.com/github/codeql-action/commit/d9984814e484244a9abd81ad96a4646ca1c4122a"><code>d998481</code></a>
Update changelog for v4.39.2</li>
<li><a
href="https://github.com/github/codeql-action/commit/c050421152f5bd3d7e4d8ece8dbd46d93ca0f926"><code>c050421</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4284">#4284</a>
from github/update-bundle/codeql-bundle-v2.27.4</li>
<li><a
href="https://github.com/github/codeql-action/commit/750fe8feea49cd7d09db1a11def08c3bd812d810"><code>750fe8f</code></a>
Merge branch 'main' into update-bundle/codeql-bundle-v2.27.4</li>
<li><a
href="https://github.com/github/codeql-action/commit/adfcd5ffb8f15a420edbc24cce8056e2626d76bd"><code>adfcd5f</code></a>
Add changelog note</li>
<li><a
href="https://github.com/github/codeql-action/commit/2ef3b622017573270975effb6820567f855245aa"><code>2ef3b62</code></a>
Update default bundle to codeql-bundle-v2.27.4</li>
<li><a
href="https://github.com/github/codeql-action/commit/b35c6c6aaefc0489db8249a1adea322ed8011094"><code>b35c6c6</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4251">#4251</a>
from github/mbg/refactor-util/errors</li>
<li><a
href="https://github.com/github/codeql-action/commit/ac9f25761184d85ea2864c1e40de96afc64337bb"><code>ac9f257</code></a>
Merge remote-tracking branch 'origin/main' into
mbg/refactor-util/errors</li>
<li><a
href="https://github.com/github/codeql-action/commit/52cf1852265d99e9bea071b03366fc777a602909"><code>52cf185</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4278">#4278</a>
from github/mbg/macos27/xcode-detection</li>
<li><a
href="https://github.com/github/codeql-action/commit/95dd6bfbc42487e3e3695e9c9cb52f3a4b3fcd4f"><code>95dd6bf</code></a>
Fix new test not providing typical input</li>
<li>Additional commits viewable in <a
href="https://github.com/github/codeql-action/compare/9b3254085494b55a0f7c4c2b3d2d52cc712ce370...1fc58d04a4d4374f921bdac573c3811c240f9c26">compare
view</a></li>
</ul>
</details>
<br />

Updates `github/codeql-action/autobuild` from 4.39.1 to 4.39.2
<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.39.2</h2>
<ul>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.27.4">2.27.4</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/4284">#4284</a></li>
<li><em>Upcoming breaking change</em>: CodeQL version 2.22.3 and earlier
were discontinued on 16 October 2026 alongside GitHub Enterprise Server
3.18, and will be unsupported by the next minor release of the CodeQL
Action. Added a deprecation warning for customers using these versions
of CodeQL. <a
href="https://redirect.github.com/github/codeql-action/pull/4266">#4266</a></li>
<li>Starting with macOS 27 and Xcode 27, traced analyses are
unsupported. See <a
href="https://github.blog/changelog/2026-10-19-codeql-changes-for-macos-27-xcode-27-and-swift-6-4/">this
blog post for more information</a>. The CodeQL Action will now try to
identify if a workflow is about to perform a traced analysis on version
27 or above of macOS or Xcode. If so, the CodeQL Action will emit a
corresponding warning that will also be visible on the <a
href="https://docs.github.com/en/code-security/how-tos/find-and-fix-code-vulnerabilities/manage-your-configuration/use-the-tools-status-page-for-code-scanning">Tool
Status Page</a>. This check will be rolled out following this release of
the CodeQL Action. <a
href="https://redirect.github.com/github/codeql-action/pull/4221">#4221</a></li>
<li>The undocumented TRAP cache cleanup feature that could be enabled
using the <code>CODEQL_ACTION_CLEANUP_TRAP_CACHES</code> environment
variable has been removed, as announced in the release notes for CodeQL
Action 4.35.2. The <code>analyze</code> Action now warns if this
environment variable is set. If you used TRAP cache cleanup to limit
Actions cache usage, we recommend disabling TRAP caching by passing the
<code>trap-caching: false</code> input to the <code>init</code> Action.
<a
href="https://redirect.github.com/github/codeql-action/pull/4243">#4243</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.39.3 - 04 Nov 2026</h2>
<ul>
<li>Fixed diff-informed alert filtering incorrectly removing alerts in
changed files whose paths contain spaces, special characters, or
non-ASCII characters. <a
href="https://redirect.github.com/github/codeql-action/pull/4283">#4283</a></li>
</ul>
<h2>4.39.2 - 03 Nov 2026</h2>
<ul>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.27.4">2.27.4</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/4284">#4284</a></li>
<li><em>Upcoming breaking change</em>: CodeQL version 2.22.3 and earlier
were discontinued on 16 October 2026 alongside GitHub Enterprise Server
3.18, and will be unsupported by the next minor release of the CodeQL
Action. Added a deprecation warning for customers using these versions
of CodeQL. <a
href="https://redirect.github.com/github/codeql-action/pull/4266">#4266</a></li>
<li>Starting with macOS 27 and Xcode 27, traced analyses are
unsupported. See <a
href="https://github.blog/changelog/2026-10-19-codeql-changes-for-macos-27-xcode-27-and-swift-6-4/">this
blog post for more information</a>. The CodeQL Action will now try to
identify if a workflow is about to perform a traced analysis on version
27 or above of macOS or Xcode. If so, the CodeQL Action will emit a
corresponding warning that will also be visible on the <a
href="https://docs.github.com/en/code-security/how-tos/find-and-fix-code-vulnerabilities/manage-your-configuration/use-the-tools-status-page-for-code-scanning">Tool
Status Page</a>. This check will be rolled out following this release of
the CodeQL Action. <a
href="https://redirect.github.com/github/codeql-action/pull/4221">#4221</a></li>
<li>The undocumented TRAP cache cleanup feature that could be enabled
using the <code>CODEQL_ACTION_CLEANUP_TRAP_CACHES</code> environment
variable has been removed, as announced in the release notes for CodeQL
Action 4.35.2. The <code>analyze</code> Action now warns if this
environment variable is set. If you used TRAP cache cleanup to limit
Actions cache usage, we recommend disabling TRAP caching by passing the
<code>trap-caching: false</code> input to the <code>init</code> Action.
<a
href="https://redirect.github.com/github/codeql-action/pull/4243">#4243</a></li>
</ul>
<h2>4.39.1 - 21 Oct 2026</h2>
<ul>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.27.3">2.27.3</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/4256">#4256</a></li>
</ul>
<h2>4.39.0 - 15 Oct 2026</h2>
<ul>
<li><em>Breaking change</em>: Bump the minimum required CodeQL bundle
version to 2.20.7. <a
href="https://redirect.github.com/github/codeql-action/pull/4205">#4205</a></li>
<li>The <code>codeql-action/analyze</code> step now validates that a
correct argument for the <code>checkout_path</code> input has been
provided. The <code>checkout_path</code> input must be set to the path
where the repository that is being analyzed is checked out at on the
runner, if that path is something other than the workspace root. If the
path does not point to a repository checkout or is different from the
path that was used by <code>codeql-action/init</code>, then the CodeQL
Action now produces a warning. <a
href="https://redirect.github.com/github/codeql-action/pull/4148">#4148</a></li>
<li>Resolved an issue with third-party caching implementations that
prevented caches from being restored correctly. <a
href="https://redirect.github.com/github/codeql-action/pull/4206">#4206</a></li>
<li>The <code>codeql-action/start-proxy</code> step that is used in Code
Scanning Default Setup now finds CodeQL releases using the same logic as
<code>codeql-action/init</code>. This primarily benefits customers who
are using GitHub instances other than <code>github.com</code> where the
<code>codeql-action/start-proxy</code> step may previously have been
unable to locate recent releases and ended up using outdated versions of
the private registry authentication proxy as a result. <a
href="https://redirect.github.com/github/codeql-action/pull/4198">#4198</a></li>
</ul>
<h2>4.38.3 - 08 Oct 2026</h2>
<ul>
<li><em>Upcoming breaking change</em>: CodeQL version 2.21.2 and earlier
were discontinued on 24 September 2026 alongside GitHub Enterprise
Server 3.17, and will be unsupported by the next minor release of the
CodeQL Action. Added a deprecation warning for customers using these
versions of CodeQL. <a
href="https://redirect.github.com/github/codeql-action/pull/4188">#4188</a></li>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.27.2">2.27.2</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/4203">#4203</a></li>
<li>Fixed a bug where the decision of whether to use a per-language
bundle did not account for custom configurations that reference queries
outside of compiled CodeQL packs. This issue was caught during internal
testing and did not affect any customer repositories. We will resume the
roll out of per-language bundles in the coming weeks. <a
href="https://redirect.github.com/github/codeql-action/pull/4184">#4184</a></li>
</ul>
<h2>4.38.2 - 24 Sept 2026</h2>
<ul>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.27.1">2.27.1</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/4160">#4160</a></li>
</ul>
<h2>4.38.1 - 18 Sept 2026</h2>
<ul>
<li>The CodeQL Action now has experimental support for CodeQL releases
for which per-language bundles are available. Per-language bundles
support analysis for a single language and are therefore smaller than
the combined bundles that allow analysis for all supported languages. As
a result, per-language bundles take up less space on disk and are faster
to download. We expect to roll this change out to everyone in the coming
weeks. <a
href="https://redirect.github.com/github/codeql-action/pull/4146">#4146</a></li>
</ul>
<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>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/github/codeql-action/commit/1fc58d04a4d4374f921bdac573c3811c240f9c26"><code>1fc58d0</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4286">#4286</a>
from github/update-v4.39.2-c05042115</li>
<li><a
href="https://github.com/github/codeql-action/commit/d9984814e484244a9abd81ad96a4646ca1c4122a"><code>d998481</code></a>
Update changelog for v4.39.2</li>
<li><a
href="https://github.com/github/codeql-action/commit/c050421152f5bd3d7e4d8ece8dbd46d93ca0f926"><code>c050421</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4284">#4284</a>
from github/update-bundle/codeql-bundle-v2.27.4</li>
<li><a
href="https://github.com/github/codeql-action/commit/750fe8feea49cd7d09db1a11def08c3bd812d810"><code>750fe8f</code></a>
Merge branch 'main' into update-bundle/codeql-bundle-v2.27.4</li>
<li><a
href="https://github.com/github/codeql-action/commit/adfcd5ffb8f15a420edbc24cce8056e2626d76bd"><code>adfcd5f</code></a>
Add changelog note</li>
<li><a
href="https://github.com/github/codeql-action/commit/2ef3b622017573270975effb6820567f855245aa"><code>2ef3b62</code></a>
Update default bundle to codeql-bundle-v2.27.4</li>
<li><a
href="https://github.com/github/codeql-action/commit/b35c6c6aaefc0489db8249a1adea322ed8011094"><code>b35c6c6</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4251">#4251</a>
from github/mbg/refactor-util/errors</li>
<li><a
href="https://github.com/github/codeql-action/commit/ac9f25761184d85ea2864c1e40de96afc64337bb"><code>ac9f257</code></a>
Merge remote-tracking branch 'origin/main' into
mbg/refactor-util/errors</li>
<li><a
href="https://github.com/github/codeql-action/commit/52cf1852265d99e9bea071b03366fc777a602909"><code>52cf185</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4278">#4278</a>
from github/mbg/macos27/xcode-detection</li>
<li><a
href="https://github.com/github/codeql-action/commit/95dd6bfbc42487e3e3695e9c9cb52f3a4b3fcd4f"><code>95dd6bf</code></a>
Fix new test not providing typical input</li>
<li>Additional commits viewable in <a
href="https://github.com/github/codeql-action/compare/9b3254085494b55a0f7c4c2b3d2d52cc712ce370...1fc58d04a4d4374f921bdac573c3811c240f9c26">compare
view</a></li>
</ul>
</details>
<br />

Updates `github/codeql-action/analyze` from 4.39.1 to 4.39.2
<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.39.2</h2>
<ul>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.27.4">2.27.4</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/4284">#4284</a></li>
<li><em>Upcoming breaking change</em>: CodeQL version 2.22.3 and earlier
were discontinued on 16 October 2026 alongside GitHub Enterprise Server
3.18, and will be unsupported by the next minor release of the CodeQL
Action. Added a deprecation warning for customers using these versions
of CodeQL. <a
href="https://redirect.github.com/github/codeql-action/pull/4266">#4266</a></li>
<li>Starting with macOS 27 and Xcode 27, traced analyses are
unsupported. See <a
href="https://github.blog/changelog/2026-10-19-codeql-changes-for-macos-27-xcode-27-and-swift-6-4/">this
blog post for more information</a>. The CodeQL Action will now try to
identify if a workflow is about to perform a traced analysis on version
27 or above of macOS or Xcode. If so, the CodeQL Action will emit a
corresponding warning that will also be visible on the <a
href="https://docs.github.com/en/code-security/how-tos/find-and-fix-code-vulnerabilities/manage-your-configuration/use-the-tools-status-page-for-code-scanning">Tool
Status Page</a>. This check will be rolled out following this release of
the CodeQL Action. <a
href="https://redirect.github.com/github/codeql-action/pull/4221">#4221</a></li>
<li>The undocumented TRAP cache cleanup feature that could be enabled
using the <code>CODEQL_ACTION_CLEANUP_TRAP_CACHES</code> environment
variable has been removed, as announced in the release notes for CodeQL
Action 4.35.2. The <code>analyze</code> Action now warns if this
environment variable is set. If you used TRAP cache cleanup to limit
Actions cache usage, we recommend disabling TRAP caching by passing the
<code>trap-caching: false</code> input to the <code>init</code> Action.
<a
href="https://redirect.github.com/github/codeql-action/pull/4243">#4243</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.39.3 - 04 Nov 2026</h2>
<ul>
<li>Fixed diff-informed alert filtering incorrectly removing alerts in
changed files whose paths contain spaces, special characters, or
non-ASCII characters. <a
href="https://redirect.github.com/github/codeql-action/pull/4283">#4283</a></li>
</ul>
<h2>4.39.2 - 03 Nov 2026</h2>
<ul>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.27.4">2.27.4</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/4284">#4284</a></li>
<li><em>Upcoming breaking change</em>: CodeQL version 2.22.3 and earlier
were discontinued on 16 October 2026 alongside GitHub Enterprise Server
3.18, and will be unsupported by the next minor release of the CodeQL
Action. Added a deprecation warning for customers using these versions
of CodeQL. <a
href="https://redirect.github.com/github/codeql-action/pull/4266">#4266</a></li>
<li>Starting with macOS 27 and Xcode 27, traced analyses are
unsupported. See <a
href="https://github.blog/changelog/2026-10-19-codeql-changes-for-macos-27-xcode-27-and-swift-6-4/">this
blog post for more information</a>. The CodeQL Action will now try to
identify if a workflow is about to perform a traced analysis on version
27 or above of macOS or Xcode. If so, the CodeQL Action will emit a
corresponding warning that will also be visible on the <a
href="https://docs.github.com/en/code-security/how-tos/find-and-fix-code-vulnerabilities/manage-your-configuration/use-the-tools-status-page-for-code-scanning">Tool
Status Page</a>. This check will be rolled out following this release of
the CodeQL Action. <a
href="https://redirect.github.com/github/codeql-action/pull/4221">#4221</a></li>
<li>The undocumented TRAP cache cleanup feature that could be enabled
using the <code>CODEQL_ACTION_CLEANUP_TRAP_CACHES</code> environment
variable has been removed, as announced in the release notes for CodeQL
Action 4.35.2. The <code>analyze</code> Action now warns if this
environment variable is set. If you used TRAP cache cleanup to limit
Actions cache usage, we recommend disabling TRAP caching by passing the
<code>trap-caching: false</code> input to the <code>init</code> Action.
<a
href="https://redirect.github.com/github/codeql-action/pull/4243">#4243</a></li>
</ul>
<h2>4.39.1 - 21 Oct 2026</h2>
<ul>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.27.3">2.27.3</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/4256">#4256</a></li>
</ul>
<h2>4.39.0 - 15 Oct 2026</h2>
<ul>
<li><em>Breaking change</em>: Bump the minimum required CodeQL bundle
version to 2.20.7. <a
href="https://redirect.github.com/github/codeql-action/pull/4205">#4205</a></li>
<li>The <code>codeql-action/analyze</code> step now validates that a
correct argument for the <code>checkout_path</code> input has been
provided. The <code>checkout_path</code> input must be set to the path
where the repository that is being analyzed is checked out at on the
runner, if that path is something other than the workspace root. If the
path does not point to a repository checkout or is different from the
path that was used by <code>codeql-action/init</code>, then the CodeQL
Action now produces a warning. <a
href="https://redirect.github.com/github/codeql-action/pull/4148">#4148</a></li>
<li>Resolved an issue with third-party caching implementations that
prevented caches from being restored correctly. <a
href="https://redirect.github.com/github/codeql-action/pull/4206">#4206</a></li>
<li>The <code>codeql-action/start-proxy</code> step that is used in Code
Scanning Default Setup now finds CodeQL releases using the same logic as
<code>codeql-action/init</code>. This primarily benefits customers who
are using GitHub instances other than <code>github.com</code> where the
<code>codeql-action/start-proxy</code> step may previously have been
unable to locate recent releases and ended up using outdated versions of
the private registry authentication proxy as a result. <a
href="https://redirect.github.com/github/codeql-action/pull/4198">#4198</a></li>
</ul>
<h2>4.38.3 - 08 Oct 2026</h2>
<ul>
<li><em>Upcoming breaking change</em>: CodeQL version 2.21.2 and earlier
were discontinued on 24 September 2026 alongside GitHub Enterprise
Server 3.17, and will be unsupported by the next minor release of the
CodeQL Action. Added a deprecation warning for customers using these
versions of CodeQL. <a
href="https://redirect.github.com/github/codeql-action/pull/4188">#4188</a></li>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.27.2">2.27.2</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/4203">#4203</a></li>
<li>Fixed a bug where the decision of whether to use a per-language
bundle did not account for custom configurations that reference queries
outside of compiled CodeQL packs. This issue was caught during internal
testing and did not affect any customer repositories. We will resume the
roll out of per-language bundles in the coming weeks. <a
href="https://redirect.github.com/github/codeql-action/pull/4184">#4184</a></li>
</ul>
<h2>4.38.2 - 24 Sept 2026</h2>
<ul>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.27.1">2.27.1</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/4160">#4160</a></li>
</ul>
<h2>4.38.1 - 18 Sept 2026</h2>
<ul>
<li>The CodeQL Action now has experimental support for CodeQL releases
for which per-language bundles are available. Per-language bundles
support analysis for a single language and are therefore smaller than
the combined bundles that allow analysis for all supported languages. As
a result, per-language bundles take up less space on disk and are faster
to download. We expect to roll this change out to everyone in the coming
weeks. <a
href="https://redirect.github.com/github/codeql-action/pull/4146">#4146</a></li>
</ul>
<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>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/github/codeql-action/commit/1fc58d04a4d4374f921bdac573c3811c240f9c26"><code>1fc58d0</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4286">#4286</a>
from github/update-v4.39.2-c05042115</li>
<li><a
href="https://github.com/github/codeql-action/commit/d9984814e484244a9abd81ad96a4646ca1c4122a"><code>d998481</code></a>
Update changelog for v4.39.2</li>
<li><a
href="https://github.com/github/codeql-action/commit/c050421152f5bd3d7e4d8ece8dbd46d93ca0f926"><code>c050421</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4284">#4284</a>
from github/update-bundle/codeql-bundle-v2.27.4</li>
<li><a
href="https://github.com/github/codeql-action/commit/750fe8feea49cd7d09db1a11def08c3bd812d810"><code>750fe8f</code></a>
Merge branch 'main' into update-bundle/codeql-bundle-v2.27.4</li>
<li><a
href="https://github.com/github/codeql-action/commit/adfcd5ffb8f15a420edbc24cce8056e2626d76bd"><code>adfcd5f</code></a>
Add changelog note</li>
<li><a
href="https://github.com/github/codeql-action/commit/2ef3b622017573270975effb6820567f855245aa"><code>2ef3b62</code></a>
Update default bundle to codeql-bundle-v2.27.4</li>
<li><a
href="https://github.com/github/codeql-action/commit/b35c6c6aaefc0489db8249a1adea322ed8011094"><code>b35c6c6</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4251">#4251</a>
from github/mbg/refactor-util/errors</li>
<li><a
href="https://github.com/github/codeql-action/commit/ac9f25761184d85ea2864c1e40de96afc64337bb"><code>ac9f257</code></a>
Merge remote-tracking branch 'origin/main' into
mbg/refactor-util/errors</li>
<li><a
href="https://github.com/github/codeql-action/commit/52cf1852265d99e9bea071b03366fc777a602909"><code>52cf185</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4278">#4278</a>
from github/mbg/macos27/xcode-detection</li>
<li><a
href="https://github.com/github/codeql-action/commit/95dd6bfbc42487e3e3695e9c9cb52f3a4b3fcd4f"><code>95dd6bf</code></a>
Fix new test not providing typical input</li>
<li>Additional commits viewable in <a
href="https://github.com/github/codeql-action/compare/9b3254085494b55a0f7c4c2b3d2d52cc712ce370...1fc58d04a4d4374f921bdac573c3811c240f9c26">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>

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

<!-- coderabbit-ai-generated:v1 -->

## Summary by CodeRabbit

* **Chores**
* Updated the tools used by automated quality checks, type checks,
tests, end-to-end checks, and security analysis. The workflows’
configurations and behavior remain unchanged, so there are no new
user-facing features or changes to how the product works.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-07 15:51:47 -07:00
dependabot[bot] 68165e1730 build(deps): bump publicsuffixlist from 1.1.2.20261029 to 1.1.2.20261101 (#1666)
Bumps [publicsuffixlist](https://github.com/ko-zu/psl) from
1.1.2.20261029 to 1.1.2.20261101.
<details>
<summary>Commits</summary>
<ul>
<li>See full diff in <a
href="https://github.com/ko-zu/psl/commits">compare view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=publicsuffixlist&package-manager=uv&previous-version=1.1.2.20261029&new-version=1.1.2.20261101)](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>

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

<!-- coderabbit-ai-generated:v1 -->

## Summary by CodeRabbit

* **Chores**
  * Updated the minimum required version of a dependency.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-07 15:49:48 -07:00
dependabot[bot] b1a1f46096 build(deps): bump astral-sh/uv from 0.13.6 to 0.13.7 in the docker-base-image-digests group (#1670)
> [!WARNING]
> Cooldown could not be applied because no publication date was
available from the registry.
>

Bumps the docker-base-image-digests group with 1 update:
[astral-sh/uv](https://github.com/astral-sh/uv).

Updates `astral-sh/uv` from 0.13.6 to 0.13.7
<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.13.7</h2>
<h2>Release Notes</h2>
<p>Released on 2026-11-03.</p>
<h3>Preview features</h3>
<ul>
<li>Add <code>--output-version</code> to <code>uv workspace
metadata</code> for requesting a specific schema version (<a
href="https://redirect.github.com/astral-sh/uv/pull/22958">#22958</a>)</li>
<li>Report aliased OSV records as a single vulnerability in <code>uv
audit</code> (<a
href="https://redirect.github.com/astral-sh/uv/pull/22990">#22990</a>)</li>
</ul>
<h3>Bug fixes</h3>
<ul>
<li>Treat workspace member paths added by <code>uv init</code> or
<code>uv add</code> as literals instead of glob patterns (<a
href="https://redirect.github.com/astral-sh/uv/pull/23008">#23008</a>)</li>
<li>Prevent uv's Windows trampolines from closing handles they did not
inherit (<a
href="https://redirect.github.com/astral-sh/uv/pull/22977">#22977</a>)</li>
<li>Allow <code>uvx</code> to run tools with CPython builds whose
versions contain local identifiers (<a
href="https://redirect.github.com/astral-sh/uv/pull/23013">#23013</a>)</li>
<li>Generate valid module names when <code>uv init</code> creates
packaged projects whose names start with a digit (<a
href="https://redirect.github.com/astral-sh/uv/pull/23022">#23022</a>)</li>
<li>Preserve comments around existing inline dependency groups when
<code>uv add</code> updates a group (<a
href="https://redirect.github.com/astral-sh/uv/pull/23017">#23017</a>)</li>
<li>Allow <code>uv remove</code> with an empty inline
<code>tool.uv.sources</code> table (<a
href="https://redirect.github.com/astral-sh/uv/pull/23012">#23012</a>)</li>
</ul>
<h3>Documentation</h3>
<ul>
<li>Correct the <code>cache-keys</code> inline table example (<a
href="https://redirect.github.com/astral-sh/uv/pull/22984">#22984</a>)</li>
</ul>
<h2>Install uv 0.13.7</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.13.7/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.13.7/uv-installer.ps1
| iex&quot;
</code></pre>
<h2>Download uv 0.13.7</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.13.7/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.13.7/uv-aarch64-apple-darwin.tar.gz.sha256">checksum</a></td>
</tr>
<tr>
<td><a
href="https://releases.astral.sh/github/uv/releases/download/0.13.7/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.13.7/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.13.7/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.13.7/uv-aarch64-pc-windows-msvc.zip.sha256">checksum</a></td>
</tr>
<tr>
<td><a
href="https://releases.astral.sh/github/uv/releases/download/0.13.7/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.13.7/uv-i686-pc-windows-msvc.zip.sha256">checksum</a></td>
</tr>
<tr>
<td><a
href="https://releases.astral.sh/github/uv/releases/download/0.13.7/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.13.7/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.13.7/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.13.7/uv-aarch64-unknown-linux-gnu.tar.gz.sha256">checksum</a></td>
</tr>
<tr>
<td><a
href="https://releases.astral.sh/github/uv/releases/download/0.13.7/uv-i686-unknown-linux-gnu.tar.gz">uv-i686-unknown-linux-gnu.tar.gz</a></td>
<td>x86 Linux</td>
<td><a
href="https://releases.astral.sh/github/uv/releases/download/0.13.7/uv-i686-unknown-linux-gnu.tar.gz.sha256">checksum</a></td>
</tr>
<tr>
<td><a
href="https://releases.astral.sh/github/uv/releases/download/0.13.7/uv-powerpc64le-unknown-linux-gnu.tar.gz">uv-powerpc64le-unknown-linux-gnu.tar.gz</a></td>
<td>PPC64LE Linux</td>
<td><a
href="https://releases.astral.sh/github/uv/releases/download/0.13.7/uv-powerpc64le-unknown-linux-gnu.tar.gz.sha256">checksum</a></td>
</tr>
<tr>
<td><a
href="https://releases.astral.sh/github/uv/releases/download/0.13.7/uv-riscv64gc-unknown-linux-gnu.tar.gz">uv-riscv64gc-unknown-linux-gnu.tar.gz</a></td>
<td>RISCV Linux</td>
<td><a
href="https://releases.astral.sh/github/uv/releases/download/0.13.7/uv-riscv64gc-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.13.7</h2>
<p>Released on 2026-11-03.</p>
<h3>Preview features</h3>
<ul>
<li>Add <code>--output-version</code> to <code>uv workspace
metadata</code> for requesting a specific schema version (<a
href="https://redirect.github.com/astral-sh/uv/pull/22958">#22958</a>)</li>
<li>Report aliased OSV records as a single vulnerability in <code>uv
audit</code> (<a
href="https://redirect.github.com/astral-sh/uv/pull/22990">#22990</a>)</li>
</ul>
<h3>Bug fixes</h3>
<ul>
<li>Treat workspace member paths added by <code>uv init</code> or
<code>uv add</code> as literals instead of glob patterns (<a
href="https://redirect.github.com/astral-sh/uv/pull/23008">#23008</a>)</li>
<li>Prevent uv's Windows trampolines from closing handles they did not
inherit (<a
href="https://redirect.github.com/astral-sh/uv/pull/22977">#22977</a>)</li>
<li>Allow <code>uvx</code> to run tools with CPython builds whose
versions contain local identifiers (<a
href="https://redirect.github.com/astral-sh/uv/pull/23013">#23013</a>)</li>
<li>Generate valid module names when <code>uv init</code> creates
packaged projects whose names start with a digit (<a
href="https://redirect.github.com/astral-sh/uv/pull/23022">#23022</a>)</li>
<li>Preserve comments around existing inline dependency groups when
<code>uv add</code> updates a group (<a
href="https://redirect.github.com/astral-sh/uv/pull/23017">#23017</a>)</li>
<li>Allow <code>uv remove</code> with an empty inline
<code>tool.uv.sources</code> table (<a
href="https://redirect.github.com/astral-sh/uv/pull/23012">#23012</a>)</li>
</ul>
<h3>Documentation</h3>
<ul>
<li>Correct the <code>cache-keys</code> inline table example (<a
href="https://redirect.github.com/astral-sh/uv/pull/22984">#22984</a>)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/astral-sh/uv/commit/316c827e058c16b71d5c507a20729c2995a7bcce"><code>316c827</code></a>
Bump version to 0.13.7 (<a
href="https://redirect.github.com/astral-sh/uv/issues/23068">#23068</a>)</li>
<li><a
href="https://github.com/astral-sh/uv/commit/917b4c31531e6933c20596b65b49e75c83ff7d93"><code>917b4c3</code></a>
Update Rust crate tokio-rustls to v0.26.6 (<a
href="https://redirect.github.com/astral-sh/uv/issues/23048">#23048</a>)</li>
<li><a
href="https://github.com/astral-sh/uv/commit/baec6ce829bea457e5452906144115940e1ea896"><code>baec6ce</code></a>
Update conda-incubator/setup-miniconda action to v4.1.0 (<a
href="https://redirect.github.com/astral-sh/uv/issues/23032">#23032</a>)</li>
<li><a
href="https://github.com/astral-sh/uv/commit/da8bbd3365161177fc30909aa0b84d45e8811f09"><code>da8bbd3</code></a>
Update fedora Docker tag to v46 (<a
href="https://redirect.github.com/astral-sh/uv/issues/23033">#23033</a>)</li>
<li><a
href="https://github.com/astral-sh/uv/commit/3db723cf104c685b9f2f31a26834b7d417db652f"><code>3db723c</code></a>
Update Rust crate cargo-util to v0.2.32 (<a
href="https://redirect.github.com/astral-sh/uv/issues/23045">#23045</a>)</li>
<li><a
href="https://github.com/astral-sh/uv/commit/28c0c65cd01ba9430787eef2f933740095609e09"><code>28c0c65</code></a>
Update Rust crate uuid to v1.27.0 (<a
href="https://redirect.github.com/astral-sh/uv/issues/23052">#23052</a>)</li>
<li><a
href="https://github.com/astral-sh/uv/commit/77168783ec37c93a786fce4bbaabe58d1255c22f"><code>7716878</code></a>
Update Rust crate insta to v1.49.0 (<a
href="https://redirect.github.com/astral-sh/uv/issues/23051">#23051</a>)</li>
<li><a
href="https://github.com/astral-sh/uv/commit/3be10e3890b942cb7509e656245b46e7ad63d650"><code>3be10e3</code></a>
Update dependency <code>@​openai/codex</code> to v0.160.0 (<a
href="https://redirect.github.com/astral-sh/uv/issues/23050">#23050</a>)</li>
<li><a
href="https://github.com/astral-sh/uv/commit/8eb756a2e7a4a8380d49a3edd6224da4c5af4cea"><code>8eb756a</code></a>
Update CodSpeedHQ/action action to v5.4.0 (<a
href="https://redirect.github.com/astral-sh/uv/issues/23049">#23049</a>)</li>
<li><a
href="https://github.com/astral-sh/uv/commit/632df30b8e8c8ed1dbba309cc79c2608a63da155"><code>632df30</code></a>
Update Rust crate spdx to v0.13.6 (<a
href="https://redirect.github.com/astral-sh/uv/issues/23047">#23047</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/astral-sh/uv/compare/0.13.6...0.13.7">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=astral-sh/uv&package-manager=docker&previous-version=0.13.6&new-version=0.13.7)](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>

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

<!-- coderabbit-ai-generated:v1 -->

## Summary by CodeRabbit

* **Chores**
  * Updated the build environment’s `uv` version.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-07 15:49:21 -07:00
dependabot[bot] 149f32b955 build(deps): bump flask-socketio from 5.7.1 to 5.7.2 in the python-deps group (#1669)
Bumps the python-deps group with 1 update:
[flask-socketio](https://code.miguelgrinberg.com/miguelgrinberg/flask-socketio).

Updates `flask-socketio` from 5.7.1 to 5.7.2


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=flask-socketio&package-manager=uv&previous-version=5.7.1&new-version=5.7.2)](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-11-07 15:48:06 -07:00
Matt Van HornandMatt Van Horn 0bb54ac8f8 fix(clients): use port 443 for HTTPS Transmission URLs that omit a port (#1673)
## Why

Saving a Transmission address as HTTPS and leaving the port blank makes
the connection test fail. The client opens port 9091 and reports an SSL
wrong-version error, so a daemon published through a reverse proxy on
the usual HTTPS port never answers.

parse_transmission_url filled in 9091 whenever the URL omitted a port,
and that default ignored the scheme. An https address therefore started
TLS against 9091, where the peer is not speaking TLS.

## Summary

An omitted port now follows the scheme: 443 for https, and 9091 for
http, which still covers the empty-URL default. A port written in the
URL is left as given. The parser tests cover
https://transmission.myserver.org and expect https, that host, port 443,
and the path /transmission/rpc.

## Testing

The targeted tests for the shelfmark parse_transmission_url change pass
locally. The tests passed with the shelfmark parse_transmission_url
change applied; a run with it reverted gave no clear result, so no test
is shown to catch this bug.

Closes #1667


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
<!-- coderabbit-ai-generated:v1 -->

## Summary by CodeRabbit

* **Bug Fixes**
* HTTPS Transmission URLs without an explicitly specified port now use
port 443. HTTP URLs continue to default to port 9091, and the default
RPC path is unchanged.
* **Documentation**
* Clarified the default ports for each URL scheme and noted that HTTPS
instances on port 9091 require `:9091` in the URL.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
2026-11-07 14:29:27 -05:00
dependabot[bot] 985a54765d build(deps): bump oauthlib from 4.0.0 to 4.0.1 (#1668)
Bumps [oauthlib](https://github.com/oauthlib/oauthlib) from 4.0.0 to
4.0.1.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/oauthlib/oauthlib/releases">oauthlib's
releases</a>.</em></p>
<blockquote>
<h2>4.0.1</h2>
<h2>Security</h2>
<ul>
<li><strong>CVE-2026-49272</strong> / GHSA-3jp2-3mrg-2vxc: The device
code flow token endpoint did not validate the <code>device_code</code>
parameter. A request with a known <code>client_id</code> and a bogus
<code>device_code</code> could obtain a bearer token.
<code>DeviceCodeGrant.validate_token_request</code> now calls
<code>DeviceCodeGrant.validate_device_code()</code>, which rejects
missing or unknown device codes (<code>invalid_request</code> /
<code>invalid_grant</code>) and delegates the code's authorization
status to the new <code>RequestValidator.validate_device_code()</code>
hook, per RFC 8628 Section 3.5. (cherry-picked from <a
href="https://redirect.github.com/oauthlib/oauthlib/issues/954">#954</a>)</li>
</ul>
<h2>Upgrade note for device flow integrators</h2>
<p>Custom <code>RequestValidator</code> implementations using the device
flow must now implement <code>validate_device_code(client_id, code,
request)</code>. Return one of the
<code>DeviceCodeGrant.DEVICE_CODE_*</code> statuses for known codes, or
<code>None</code> for an unknown code. When returning
<code>AUTHORIZED</code>, the validator must also populate
<code>request.user</code> and <code>request.scopes</code> from the
stored authorization.</p>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/oauthlib/oauthlib/blob/v4.0.1/CHANGELOG.rst">oauthlib's
changelog</a>.</em></p>
<blockquote>
<h2>4.0.1 (2026-11-04):</h2>
<p>OAuth2.0 Provider (Security):</p>
<ul>
<li>
<p><strong>Security</strong>: CVE-2026-49272 / GHSA-3jp2-3mrg-2vxc: The
device code flow
token endpoint did not validate the <code>device_code</code> parameter.
A request
with a known <code>client_id</code> and a bogus <code>device_code</code>
could obtain a
bearer token. <code>DeviceCodeGrant.validate_token_request</code> now
calls a new
<code>DeviceCodeGrant.validate_device_code()</code> which rejects
missing or
unknown device codes (<code>400 invalid_request</code> / <code>400
invalid_grant</code>)
and delegates the code's authorization status to a new required
<code>RequestValidator.validate_device_code()</code> hook, per RFC 8628
Section 3.5. (cherry-picked from <a
href="https://redirect.github.com/oauthlib/oauthlib/issues/954">#954</a>,
merged as 1c91d64)</p>
<p><strong>Upgrade note for device flow integrators</strong>: custom
<code>RequestValidator</code> implementations using the device flow must
now
implement <code>validate_device_code(client_id, code, request)</code>.
Return one
of the <code>DeviceCodeGrant.DEVICE_CODE_*</code> statuses
(<code>AUTHORIZED</code>,
<code>PENDING</code>, <code>SLOW_DOWN</code> or <code>EXPIRED</code>)
for known codes, or <code>None</code>
for an unknown code. When returning <code>AUTHORIZED</code>, the
validator must
also populate <code>request.user</code> and <code>request.scopes</code>
from the stored
authorization.</p>
</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/oauthlib/oauthlib/commit/6e3aa05af92978755939a18cb4dd655ced156c1e"><code>6e3aa05</code></a>
Release 4.0.1: fix CVE-2026-49272 (device_code validation in device
flow)</li>
<li><a
href="https://github.com/oauthlib/oauthlib/commit/77a5f1c8ce00da2b0b8c5f6e9cc129920085f114"><code>77a5f1c</code></a>
Merge pull request <a
href="https://redirect.github.com/oauthlib/oauthlib/issues/954">#954</a>
from hekhuisk/improve-device-workflow</li>
<li>See full diff in <a
href="https://github.com/oauthlib/oauthlib/compare/v4.0.0...v4.0.1">compare
view</a></li>
</ul>
</details>
<br />


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

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

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

---

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

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore 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)
You can disable automated security fix PRs for this repo from the
[Security Alerts
page](https://github.com/calibrain/shelfmark/network/alerts).

</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-06 11:00:23 -05:00
dependabot[bot] afb9bf1670 build(deps): bump the python-deps group with 3 updates (#1665)
Bumps the python-deps group with 3 updates:
[flask-socketio](https://code.miguelgrinberg.com/miguelgrinberg/flask-socketio),
[python-socketio](https://github.com/sponsors/miguelgrinberg) and
[authlib](https://github.com/authlib/authlib).

Updates `flask-socketio` from 5.7.0 to 5.7.1

Updates `python-socketio` from 5.17.1 to 5.18.1
<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 />

Updates `authlib` from 1.9.1 to 1.9.2
<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.9.2</h2>
<p>Changelog: <a
href="https://docs.authlib.org/en/stable/upgrades/changelog.html#changelog-1-9-2">https://docs.authlib.org/en/stable/upgrades/changelog.html#changelog-1-9-2</a></p>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/authlib/authlib/compare/v1.9.1...v1.9.2">https://github.com/authlib/authlib/compare/v1.9.1...v1.9.2</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/authlib/authlib/commit/d74dc90ea0de493858f987ddba47e1fccfb0c2f3"><code>d74dc90</code></a>
chore: release 1.9.2</li>
<li><a
href="https://github.com/authlib/authlib/commit/44c96176dc903d21efea5fcafe9dc18cb3eb0517"><code>44c9617</code></a>
fix(oauth): limit ID-JAG access tokens to the scope claim</li>
<li>See full diff in <a
href="https://github.com/authlib/authlib/compare/v1.9.1...v1.9.2">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>

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

<!-- coderabbit-ai-generated:v1 -->

## Summary by CodeRabbit

* **Release Notes**
  * No end-user-facing changes are included in this update.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-05 11:29:55 -06:00
splitsec2 4c3ce3355a fix(ui): give release source tabs their own row on phones (#1663)
Fixes #1662, thanks @mietu111 for the report and the screenshot.

The source tabs share a row with up to five icons, which take 240px on a
phone. On a 393px screen that leaves 113px for the tabs, so Prowlarr
fits and Direct Download is down to a sliver. On a 360px screen not even
Prowlarr fits. The scrollbar is hidden, so there is also nothing to tell
you there is more to scroll to.

With more than one source, the tabs now get their own row below the sm
breakpoint and the icons sit on a row beneath them. Picking a tab also
scrolls it into view, for when there are too many sources to fit. This
does make the sticky header 36px taller on phones. One source and
anything sm or wider stay on a single row, with the same tab positions
and row height as before.

I checked this with screenshots of the dialog using mocked search
results, at 360 and 393px with two sources, 360px with five, 393px with
one, and at 640 and 1280px with two. The filter menu and manual query
panel still open correctly from the new row at 393px. There are no
layout tests for this dialog, so I did not add one.

Before, with Direct Download selected. The tab has 113px of space and
its label is cut off.
<img width="786" height="1704" alt="download"
src="https://github.com/user-attachments/assets/7fc46034-4faf-438b-aed3-7d9adbd41ac9"
/>
After, with Direct Download selected. Both tabs fit and the icons sit on
the row beneath.
<img width="786" height="1704" alt="download"
src="https://github.com/user-attachments/assets/e9e961e8-5f04-4b24-9483-89482891252a"
/>


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

<!-- coderabbit-ai-generated:v1 -->

## Summary by CodeRabbit

* **Improvements**
  * The active source tab is centered in the tab strip when selected.
* On narrow screens, multiple source tabs wrap onto their own row, and
the result count is hidden.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-11-04 16:07:35 -06:00
splitsec2 7c90afca9e docs(core): docstrings for the functions the route moves left without one (#1660)
Follow-up to the route moves out of main.py. CodeRabbit's docstring
coverage check flagged #1637 to #1639 at 63%, so I went back over the
modules the moves created and filled in the 19 functions and nested
helpers that had none, in queue_routes, release_routes,
download_recording, system_routes, metadata_routes and auth_middleware.
Comments only, nothing else changes.

The route modules that were split out earlier (request_routes,
activity_routes and a few others) have more without docstrings, but I
left those alone since that code was not part of the moves.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

<!-- coderabbit-ai-generated:v1 -->

## Summary by CodeRabbit

* **Documentation**
* Expanded and clarified descriptions of error responses, download and
queue statuses, notification handling, queue ownership and visibility,
release-policy checks, and administrative access.
  * No user-facing behavior changes are included in this update.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-11-04 00:44:49 -06:00
splitsec2 e8ec6cc715 fix(requests): reopening after a failed download remembers the release that failed (#1655)
When a download fails, the terminal hook reopens its request through
reopen_failed_request straight away, with no admin involved. That path
cleared the release without noting that it had failed, so the next
search could pick the same release again. Only the admin reopen and the
status sync remembered it.

Both paths now add the release to failed_releases before the reopen
clears it, the same list the unattended retry already skips, and a
release that is already listed is not added twice. A download that
failed because its client could not be reached (the same four markers
auto_download uses) is not held against the release, since the same one
is fine once the client is back.

This is the upstream shape of something I have been running on my own
instance since mid October. ruff, basedpyright and the requests_service
tests (87) pass, with five new tests for the two paths, the dedupe and
the unreachable client.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
<!-- coderabbit-ai-generated:v1 -->

## Summary by CodeRabbit

* **Bug Fixes**
* Failed requests are reopened after non-retryable delivery failures,
with the failed release recorded when appropriate.
* Connection failures caused by an unreachable client no longer mark the
release as failed.
* Reopening a request does not record the same release more than once or
alter book details when no new release can be recorded.
* Queue status updates apply the same release-tracking behavior, while
requests that are not failed remain unchanged.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-11-04 00:41:03 -06:00
splitsec2 eb9ee093de fix(auth): don't fail the lockout cleanup when a login clears the entry first (#1658)
cleanup_old_lockouts collects the expired usernames, logs each one and
then deletes it. Under gevent the log write can yield, and a successful
login for the same user in that gap clears the entry first, so the del
raises KeyError in whatever request ran the cleanup. Both removals now
use pop(username, None), and the new test reproduces the KeyError on
main.

While in there I dropped the if db_user check in the builtin login path.
It is always true after the not db_user return just above it, and the
failed response after it could never be reached. The diff looks bigger
than it is because the block moves out one indent.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

<!-- coderabbit-ai-generated:v1 -->

## Summary by CodeRabbit

* **Bug Fixes**
* Login failures now follow a consistent response path when an account
is unavailable or its password data is invalid.
* Expired login lockouts can be cleaned up safely even if they are
cleared at the same time.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-11-04 00:39:11 -06:00
splitsec2 b246c6617b fix(app_setup): create the secret key file private instead of tightening it after the write (#1659)
load_or_create_secret_key writes the Flask secret with write_bytes and
then chmods it to 0600. Until the chmod the key sits in a file with
whatever the umask allows, and if the chmod fails it stays that way and
only a warning is logged. The file is now opened with mode 0600, an
existing one is fchmod'ed before anything is written, and then the key
goes in. The new test makes chmod fail and checks the file still ends up
private, which fails on main.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

<!-- coderabbit-ai-generated:v1 -->

## Summary by CodeRabbit

* **Bug Fixes**
* Secret-key files now receive restrictive permissions when created or
replaced, helping prevent unauthorized access to the stored key.
* Key creation maintains these permissions even when a later
permission-setting operation fails.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-11-04 00:38:27 -06:00
dependabot[bot] 95e01913b4 build(deps): bump the python-deps group with 2 updates (#1652)
Bumps the python-deps group with 2 updates:
[python-socketio](https://github.com/sponsors/miguelgrinberg) and
[prek](https://github.com/j178/prek).

Updates `python-socketio` from 5.17.0 to 5.17.1
<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 />

Updates `prek` from 0.5.7 to 0.5.9
<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.9</h2>
<h2>Release Notes</h2>
<p>Released on 2026-10-30.</p>
<h3>Enhancements</h3>
<ul>
<li>Add <code>normalize-indentation</code> builtin hook (<a
href="https://redirect.github.com/j178/prek/pull/2967">#2967</a>)</li>
<li>Allow overriding the uv install version (<a
href="https://redirect.github.com/j178/prek/pull/2923">#2923</a>)</li>
<li>Embed dependency metadata in release binaries (<a
href="https://redirect.github.com/j178/prek/pull/2976">#2976</a>)</li>
<li>Select musl Bun builds when glibc is unavailable (<a
href="https://redirect.github.com/j178/prek/pull/2972">#2972</a>)</li>
</ul>
<h3>Performance</h3>
<ul>
<li>Improve workspace discovery performance (<a
href="https://redirect.github.com/j178/prek/pull/2936">#2936</a>)</li>
</ul>
<h3>Bug fixes</h3>
<ul>
<li>Preserve clean-filtered files during stashing (<a
href="https://redirect.github.com/j178/prek/pull/2915">#2915</a>)</li>
</ul>
<h3>Contributors</h3>
<ul>
<li><a href="https://github.com/j178"><code>@​j178</code></a></li>
<li><a
href="https://github.com/tchernobog"><code>@​tchernobog</code></a></li>
</ul>
<h2>Install prek 0.5.9</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.9/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.9/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.9</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.9/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.9/prek-aarch64-apple-darwin.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/j178/prek/blob/main/CHANGELOG.md">prek's
changelog</a>.</em></p>
<blockquote>
<h2>0.5.9</h2>
<p>Released on 2026-10-30.</p>
<h3>Enhancements</h3>
<ul>
<li>Add <code>normalize-indentation</code> builtin hook (<a
href="https://redirect.github.com/j178/prek/pull/2967">#2967</a>)</li>
<li>Allow overriding the uv install version (<a
href="https://redirect.github.com/j178/prek/pull/2923">#2923</a>)</li>
<li>Embed dependency metadata in release binaries (<a
href="https://redirect.github.com/j178/prek/pull/2976">#2976</a>)</li>
<li>Select musl Bun builds when glibc is unavailable (<a
href="https://redirect.github.com/j178/prek/pull/2972">#2972</a>)</li>
</ul>
<h3>Performance</h3>
<ul>
<li>Improve workspace discovery performance (<a
href="https://redirect.github.com/j178/prek/pull/2936">#2936</a>)</li>
</ul>
<h3>Bug fixes</h3>
<ul>
<li>Preserve clean-filtered files during stashing (<a
href="https://redirect.github.com/j178/prek/pull/2915">#2915</a>)</li>
</ul>
<h3>Contributors</h3>
<ul>
<li><a href="https://github.com/j178"><code>@​j178</code></a></li>
<li><a
href="https://github.com/tchernobog"><code>@​tchernobog</code></a></li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/j178/prek/commit/48c8a3ec465a8e98bb8b5b88c8bfacaca84cf84b"><code>48c8a3e</code></a>
Bump version to 0.5.9</li>
<li><a
href="https://github.com/j178/prek/commit/9f23bb8204f8a9be707966c5803e5c16d65b0f80"><code>9f23bb8</code></a>
Bump version to 0.5.8 (<a
href="https://redirect.github.com/j178/prek/issues/2978">#2978</a>)</li>
<li><a
href="https://github.com/j178/prek/commit/b209d5399481cac5385c2895df8d4df56d534790"><code>b209d53</code></a>
Bump dependencies (<a
href="https://redirect.github.com/j178/prek/issues/2977">#2977</a>)</li>
<li><a
href="https://github.com/j178/prek/commit/e935fd611556a708c30429c2762dd97af2b08ca8"><code>e935fd6</code></a>
Embed dependency metadata in release binaries (<a
href="https://redirect.github.com/j178/prek/issues/2976">#2976</a>)</li>
<li><a
href="https://github.com/j178/prek/commit/7ca1efa86853c0b2124cef23dd7921cfba61a3ee"><code>7ca1efa</code></a>
Select musl Bun builds when glibc is unavailable (<a
href="https://redirect.github.com/j178/prek/issues/2972">#2972</a>)</li>
<li><a
href="https://github.com/j178/prek/commit/69b23d8c04c9c42ca0d93bf39140b0b9403fa62d"><code>69b23d8</code></a>
Detect available glibc and musl runtimes (<a
href="https://redirect.github.com/j178/prek/issues/2975">#2975</a>)</li>
<li><a
href="https://github.com/j178/prek/commit/08098ac39c258bf582c3458eba9378dbe0f1d8e4"><code>08098ac</code></a>
Add <code>normalize-indentation</code> builtin hook (<a
href="https://redirect.github.com/j178/prek/issues/2967">#2967</a>)</li>
<li><a
href="https://github.com/j178/prek/commit/aa757d3871eccf0acc6113c4ee296f3120b0889f"><code>aa757d3</code></a>
Revert &quot;Add expand-tabs builtin hook (<a
href="https://redirect.github.com/j178/prek/issues/2918">#2918</a>)&quot;
(<a
href="https://redirect.github.com/j178/prek/issues/2968">#2968</a>)</li>
<li><a
href="https://github.com/j178/prek/commit/310a66f0b2e7c4174bfd71281a02132356b9ce2e"><code>310a66f</code></a>
Lock mise development tool versions (<a
href="https://redirect.github.com/j178/prek/issues/2965">#2965</a>)</li>
<li><a
href="https://github.com/j178/prek/commit/36f4502f808c69ddd3729440db82e045cf2fb600"><code>36f4502</code></a>
Report precise cargo-bloat sizes and symbol changes (<a
href="https://redirect.github.com/j178/prek/issues/2963">#2963</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/j178/prek/compare/v0.5.7...v0.5.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-11-03 17:11:35 -06:00
splitsec2 eb93a32f4b fix(ui): show Debug and Restart in the menu to admins only (#1653)
The Debug and Restart items in the menu show for every signed-in user
whenever Debug Mode is on, but `/api/debug` and `/api/restart` both need
an admin, so a regular user who clicks one gets a 403 page. The menu now
hides them unless the user is an admin.

It uses the auth check's `is_admin`, which is true when auth is off, so
a no-auth install keeps both items. The Debug chip in the footer is a
label and stays.

Frontend typecheck passes. I also ran a scratch instance with Debug Mode
on: the admin account sees Debug and Restart, a regular user sees only
Report a Bug and Settings, and with auth off both items are still there.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

<!-- coderabbit-ai-generated:v1 -->

## Summary by CodeRabbit

* **Bug Fixes**
* Debug information in the header is now shown only when debugging is
configured and the signed-in user is an administrator.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-11-02 23:32:06 -07:00
CaliBrain 2cb1fb4ac6 fix(tooltip): ignore sub-pixel overflow in the viewport clamp (#1651)
Closes #1646. In Firefox, hovering the Stop download button on an
in-progress download blanked the page with React error #185.

The Tooltip's layout effect moves a tooltip back inside the viewport and
then runs again on the new position, expecting to measure no overflow.
The Stop download tooltip runs about 10px past the right edge of the
unpinned Activity sidebar. After the move, Firefox measures its right
edge 0.0000153px past the limit (1594.0000153 against 1594), because it
works out a transformed box in single precision. The effect moves it by
that much, which is below what layout can represent, so the box stays
put
and the next pass measures the same overflow. After 50 passes React
throws #185 and unmounts the app. Chromium lands at 1017.996 against
1018 and stops after one pass.

The clamp now lives in getTooltipViewportNudge and treats overflow under
half a pixel as none. Tooltip calls it with the same inputs as before.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

<!-- coderabbit-ai-generated:v1 -->

## Summary by CodeRabbit

* **Bug Fixes**
* Tooltips are repositioned to stay within the viewport, including when
they are wider or taller than the available space.
* Minor overflow of less than half a pixel no longer triggers
unnecessary repositioning.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-11-03 00:00:21 -05:00
dependabot[bot]andsplitsec2 fe7073fdf6 build(deps): bump the python-deps group with 2 updates (#1644)
Bumps the python-deps group with 2 updates:
[dnspython](https://github.com/rthalley/dnspython) and
[seleniumbase](https://github.com/seleniumbase/SeleniumBase).

Updates `dnspython` from 2.9.0 to 2.9.1
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/rthalley/dnspython/releases">dnspython's
releases</a>.</em></p>
<blockquote>
<h2>dnspython 2.9.1</h2>
<p>See <a
href="https://dnspython.readthedocs.io/en/stable/whatsnew.html">What's
New</a> for details.</p>
<p>The minimum supported version of Python is 3.11.</p>
<p>My thanks to the many people who have contributed to this release.
Also thanks to my co-maintainers: Nicki Křížek, Petr Špaček, and Brian
Wellington.</p>
<p>This release was published to PyPI using Trusted Publishing.</p>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/rthalley/dnspython/blob/main/doc/whatsnew.rst">dnspython's
changelog</a>.</em></p>
<blockquote>
<h2>2.9.1 (in development)</h2>
<ul>
<li>
<p>LOC text parsing now converts the altitude, size, horizontal
precision, and
vertical precision fields to centimeters exactly, and requires them to
be
decimal numbers of meters with at most two fractional digits, as RFC
1876
section 3 specifies. They were converted with <code>float()</code>, so
<code>9.29m</code>
became 928 cm rather than 929 cm and the text and wire forms of the
record
disagreed, forms such as <code>1e3m</code>, <code>1_000m</code>,
<code>+10m</code>, <code>nanm</code>, and
<code>infm</code> were accepted, extra fractional digits were silently
dropped, and an
altitude outside the representable range was accepted and then failed in
<code>to_wire()</code> with a non-DNS exception.
<code>dns.exception.SyntaxError</code> is
now raised for all of these.</p>
</li>
<li>
<p>The resolver now assigns a new random query id to every query.</p>
</li>
<li>
<p>For transports that require a 0 query id, set it in a way that does
not break
TSIG.</p>
</li>
<li>
<p><code>dns.message.QueryMessage.resolve_chaining()</code> now caps the
minimum TTL of
a negative response with no SOA record at 300 seconds
(<code>dns.message.NEGATIVE_TTL_WITHOUT_SOA</code>). Previously the TTL
was left at
its initial value of 2^32 - 1, so a single NXDOMAIN or NODATA response
with
no SOA, e.g. a spoofed one, stayed in the resolver cache for the life of
the process.</p>
</li>
<li>
<p>DNSSEC validation now rejects an RRSIG whose type covered or class
does not
match the RRset, as RFC 4035 section 5.3.1 requires. This was not
checked,
so a revoked DNSKEY, which may only validate the DNSKEY RRset, still
validated any RRset signed with its private key if the RRSIG claimed to
cover DNSKEY.</p>
</li>
<li>
<p><code>dns.tokenizer.Tokenizer</code> now ends a line at a carriage
return, or at a
carriage return followed by a line feed, unless the carriage return is
in a
quoted string. The carriage return of CRLF line ends used to stay in the
last token of each line and in the text of a comment, so e.g.
<code>dns.zone.from_text()</code> rejected a zone with CRLF line
ends.</p>
</li>
<li>
<p>DNSSEC validation no longer uses a DNSKEY with the REVOKE flag set to
validate anything other than the DNSKEY RRset. RFC 5011 section 2.1 says
a
revoked key must not be used for any other purpose, but it was a
candidate
key like any other, so as long as the revoked key was still published,
data
signed with its private key, e.g. by whoever compromised it,
validated.</p>
</li>
<li>
<p><code>dns.ttl.from_text()</code> now rejects a BIND-style TTL that
ends in a 0
without a unit, e.g. <code>1h0</code>, as it already did for other
numbers, e.g.
<code>1h5</code>, and as BIND does. The check for a trailing number
tested its value
instead of whether there was one, so <code>1h0</code> was read as one
hour.</p>
</li>
<li>
<p><code>dns.dnssec.sign_zone()</code> now adds the apex NSEC record
when the origin is</p>
</li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/rthalley/dnspython/commit/dddd69c1ca32a94d18d2662a2dc6085c8750dd6d"><code>dddd69c</code></a>
black lint</li>
<li><a
href="https://github.com/rthalley/dnspython/commit/185cc38a0ab2d2ca1837b53d91b915e0f020b895"><code>185cc38</code></a>
release prep</li>
<li><a
href="https://github.com/rthalley/dnspython/commit/cf22838ab0c64f3c6a4d157278ae7e9fb707de8d"><code>cf22838</code></a>
Randomize the id of every message the resolver sends. (<a
href="https://redirect.github.com/rthalley/dnspython/issues/1395">#1395</a>)</li>
<li><a
href="https://github.com/rthalley/dnspython/commit/e0fce7f5a9962325d64873c31103fb33dae55112"><code>e0fce7f</code></a>
use a short negative TTL for a negative answer with no SOA in
resolve_chainin...</li>
<li><a
href="https://github.com/rthalley/dnspython/commit/ad0803c88d90fbba86ee20de747fcc676ce769ec"><code>ad0803c</code></a>
check the RRSIG type covered and class against the RRset (<a
href="https://redirect.github.com/rthalley/dnspython/issues/1388">#1388</a>)</li>
<li><a
href="https://github.com/rthalley/dnspython/commit/8c38a048424b7f44b46b0e4b4318c08de00e0457"><code>8c38a04</code></a>
Strictly apply RFC 3597 compression rules when decompressing. (<a
href="https://redirect.github.com/rthalley/dnspython/issues/1383">#1383</a>)</li>
<li><a
href="https://github.com/rthalley/dnspython/commit/ab5ade8af1b0cd3ecd1e38410df07f38da67841e"><code>ab5ade8</code></a>
end a line at a carriage return in the tokenizer (<a
href="https://redirect.github.com/rthalley/dnspython/issues/1387">#1387</a>)</li>
<li><a
href="https://github.com/rthalley/dnspython/commit/d9d259d2bfd04ece71e039bae1f9521d2cd6d337"><code>d9d259d</code></a>
only use a revoked DNSKEY to validate the DNSKEY RRset (<a
href="https://redirect.github.com/rthalley/dnspython/issues/1385">#1385</a>)</li>
<li><a
href="https://github.com/rthalley/dnspython/commit/6d436b82b56afa988d510bc9e4bf17449ba42917"><code>6d436b8</code></a>
reject BIND-style TTLs that end in a 0 without a unit (<a
href="https://redirect.github.com/rthalley/dnspython/issues/1384">#1384</a>)</li>
<li><a
href="https://github.com/rthalley/dnspython/commit/d8fcf2dc3294e96ac08488a621a7d66a819e52a1"><code>d8fcf2d</code></a>
bound $GENERATE width and parse text labels in linear time (<a
href="https://redirect.github.com/rthalley/dnspython/issues/1382">#1382</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/rthalley/dnspython/compare/v2.9.0...v2.9.1">compare
view</a></li>
</ul>
</details>
<br />

Updates `seleniumbase` from 4.56.4 to 4.56.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.56.5 - A few bug fixes and more</h2>
<h2>A few bug fixes and more</h2>
<ul>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/740990ea23d4e8fe9a79aa5d53199b332f3ac9f8">Suppress
exceptions caused by cdp.dom.enable()</a>
--&gt; This resolves <a
href="https://redirect.github.com/seleniumbase/SeleniumBase/issues/4537">seleniumbase/SeleniumBase#4537</a></li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/f39fa56c8e038054890945382134bb24b0152126">Use
verify=True by default when calling requests.head()</a>
--&gt; This resolves <a
href="https://redirect.github.com/seleniumbase/SeleniumBase/issues/4539">seleniumbase/SeleniumBase#4539</a></li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/7fb67bf450a074fde72d0332f5502bad45e8414f">Simplify
&quot;headless&quot; mode in the docs</a>
--&gt; This resolves <a
href="https://redirect.github.com/seleniumbase/SeleniumBase/issues/4536">seleniumbase/SeleniumBase#4536</a></li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/164c36da8bb90673563a7f0aaa3fe320e63fcaae">Update
examples</a></li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/9786d7bf8b5039769b61b9fd0bbc945f9a160241">Update
the mkdocs prepare script</a></li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/835cb808c0094e098b817e2dc1141282a672df40">Refresh
Python dependencies</a></li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/821d87f4372cb9059bc80349c77b976eb1fd4c4e">Update
the method_summary</a></li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/18a808c9f9c9efcaa66904e118ef0f25c5151f74">Update
the docs</a></li>
</ul>
<h2>What's Changed</h2>
<ul>
<li>A few bug fixes and more by <a
href="https://github.com/mdmintz"><code>@​mdmintz</code></a> in <a
href="https://redirect.github.com/seleniumbase/SeleniumBase/pull/4540">seleniumbase/SeleniumBase#4540</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/seleniumbase/SeleniumBase/compare/v4.56.4...v4.56.5">https://github.com/seleniumbase/SeleniumBase/compare/v4.56.4...v4.56.5</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/91d15446cdb6574b8eb1a812e1e77367e3525307"><code>91d1544</code></a>
Merge pull request <a
href="https://redirect.github.com/seleniumbase/SeleniumBase/issues/4540">#4540</a>
from seleniumbase/a-few-bug-fixes-and-more</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/fa2b7989527371569cf9689e58bf443c0c9342f4"><code>fa2b798</code></a>
Version 4.56.5</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/36125f29a48e7cc863b856f477602ef107491f78"><code>36125f2</code></a>
Update MCP server versioning</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/18a808c9f9c9efcaa66904e118ef0f25c5151f74"><code>18a808c</code></a>
Update the docs</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/821d87f4372cb9059bc80349c77b976eb1fd4c4e"><code>821d87f</code></a>
Update the method_summary</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/835cb808c0094e098b817e2dc1141282a672df40"><code>835cb80</code></a>
Refresh Python dependencies</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/9786d7bf8b5039769b61b9fd0bbc945f9a160241"><code>9786d7b</code></a>
Update the mkdocs prepare script</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/164c36da8bb90673563a7f0aaa3fe320e63fcaae"><code>164c36d</code></a>
Update examples</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/7fb67bf450a074fde72d0332f5502bad45e8414f"><code>7fb67bf</code></a>
Simplify &quot;headless&quot; mode in the docs</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/f39fa56c8e038054890945382134bb24b0152126"><code>f39fa56</code></a>
Use <code>verify=True</code> by default when calling
<code>requests.head()</code></li>
<li>Additional commits viewable in <a
href="https://github.com/seleniumbase/SeleniumBase/compare/v4.56.4...v4.56.5">compare
view</a></li>
</ul>
</details>
<br />


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

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

---

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

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


</details>

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

<!-- coderabbit-ai-generated:v1 -->

## Summary by CodeRabbit

* **Chores**
* Updated browser automation support to a newer patch release. No
user-facing behavior changes are included in this update.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: splitsec2 <35583321+splitsec2@users.noreply.github.com>
2026-11-02 19:50:42 -07:00
dependabot[bot]andsplitsec2 3af8df177a build(deps): bump publicsuffixlist from 1.1.2.20261023 to 1.1.2.20261029 (#1645)
Bumps [publicsuffixlist](https://github.com/ko-zu/psl) from
1.1.2.20261023 to 1.1.2.20261029.
<details>
<summary>Commits</summary>
<ul>
<li>See full diff in <a
href="https://github.com/ko-zu/psl/commits">compare view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=publicsuffixlist&package-manager=uv&previous-version=1.1.2.20261023&new-version=1.1.2.20261029)](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>

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

<!-- coderabbit-ai-generated:v1 -->

## Summary by CodeRabbit

* **Chores**
* Updated project configuration. This maintenance update does not add or
remove visible features, controls, or workflows. Existing behavior
remains unchanged, and no new user actions are required. No other
user-facing changes are included in this release. These notes cover the
changes with a direct impact on the experience; no additional visible
updates are reported.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: splitsec2 <35583321+splitsec2@users.noreply.github.com>
2026-11-02 19:39:54 -07:00
splitsec2 99d842b381 build(docker): install Chromium from trixie-security without the snapshot pin (#1648)
Following the discussion on #1635, this drops the Chromium snapshot pin
and the apt preference, and installs whatever trixie-security currently
ships. Thanks @calibrain for suggesting we try it.

With no pin there is nothing left to go stale, so the scheduled check
that issue asked for isn't needed. Closes #1635.

I built the image from this branch and ran it as a scratch container. It
has Chromium 156.0.8078.79, and the bypass cleared DDoS-Guard on Anna's
Archive and returned search results. That challenge cleared on the
auto-wait step, so the click path wasn't exercised, same as the open
question in #1635.

One thing to know: the build cache is keyed on the Dockerfile text, so a
newer Debian Chromium only lands when that layer rebuilds, for example
on a base image bump.
2026-11-02 19:35:07 -07:00
splitsec2 fa42f357d2 fix(types): type download_url's return by spool_to_disk (#1649)
The Python Typechecks job fails on main since #1632, which is why #1644
shows red too. `download_url` returns a `BytesIO`, or a
`SpooledTemporaryFile` when `spool_to_disk` is set, and both were typed
as `BinaryIO`. That has no `getbuffer()`, which four direct-download
callers use.

Two overloads on `spool_to_disk` give each caller the type it actually
gets, so nothing changes at runtime. Thanks @29rdx for the spooling
work, this only tightens the types around it.

basedpyright (source and tests), ruff and vulture are clean, and the
tests for the download, direct download and libgen code pass.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
<!-- coderabbit-ai-generated:v1 -->

## Summary by CodeRabbit

* **Developer Experience**
* Updated download API type information to distinguish between in-memory
buffers and disk-spooled buffers. The return type now reflects the
selected spooling option, making the API’s possible results clearer to
developers and improving type checking when working with downloaded
content.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-11-02 19:33:59 -07:00
splitsec2 9d8e7eaad2 fix: keep exception text out of the queue, release, metadata and debug responses (#1643)
Now that the route moves have landed, this takes care of the code
scanning alerts they brought along. Sixteen places in `queue_routes.py`,
`release_routes.py`, `metadata_routes.py` and `system_routes.py` handed
`str(e)` of an unexpected OSError, RuntimeError, TypeError or sqlite3
error back as the 500 body. It can carry file paths, URLs with tokens or
driver text. Same idea as #1558 and #1562.

Each now returns one fixed sentence ("Could not cancel the download",
"Could not read the queue order", "Metadata search failed" and so on)
and the full error and trace stay in the log. The routes covered are the
cover proxy, cancel, retry, priority, reorder, queue order and active
downloads; release download, search, source list and source record; the
metadata providers, config, search and book routes; and the debug
endpoint.

I left alone the messages that are written for the person using the app:
what a metadata provider raises as a ValueError or RuntimeError
("Hardcover API key is invalid"), `SourceUnavailableError`, and the
reason `queue_release` gives. CodeQL still flags those, as I read it
those are the ones that should be dismissed as intended, but that is
your call. The same pattern is in the settings and onboarding routes,
which aren't flagged, so I didn't touch them.

There are 15 new tests, one per fixed route except the debug endpoint,
which only exists in debug mode. All 15 fail without the change. Two
queue tests that asserted the old text now assert the fixed message. The
full suite, ruff and basedpyright are clean.

@calibrain I pushed through the refactors keeping them the same
functionality, this addresses "issues" flagged in them, I think some of
these were dismissed before, but I gathered them all up here in a PR
that you should have a say on as it is changing long standing code.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

<!-- coderabbit-ai-generated:v1 -->

## Summary by CodeRabbit

* **Bug Fixes**
* Error responses now show generic messages instead of operational
exception details across metadata, queue, release, and debug endpoints.
HTTP status codes and error logging remain unchanged.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-11-02 09:21:08 -07:00
Rupraj Datta ed0777a2e0 Spool large debrid downloads to disk (#1632)
Fixes #1630.

Debrid downloads currently buffer entire files in RAM. The four clients
now opt into a spooled temporary file: downloads up to 8 MiB stay in
memory, and larger downloads roll to disk. The clients copy from the
file in chunks and close it after use. Other download_url callers retain
the existing BytesIO behavior.

Temporary files are closed on unsuccessful attempts and cancellation;
successful resumed downloads remain open for their caller. Added offline
coverage for disk rollover, timeout cleanup, and resumed content.
Updated the Debrid-Link test doubles for the new opt-in argument.

Validation: 113 tests passed across test_http_download_url,
test_debridlink_client, and test_torbox_client; Ruff check and format
check passed for changed files; git diff --check passed. No live debrid
services were exercised. AI assistance was used in implementing this
change.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

<!-- coderabbit-ai-generated:v1 -->

## Summary by CodeRabbit

* **Improvements**
* Large downloads are handled more efficiently, reducing memory use
while preserving download and resume behavior.
* **Bug Fixes**
* Temporary download data is cleaned up when a download is cancelled or
fails, helping prevent unnecessary resource usage.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-11-01 22:54:46 -05:00
splitsec2 622f6ac677 refactor: move the download recording into core/download_recording.py (#1642)
Last of the planned moves is the download recording (row 9 in Discussion
#1536). The queue hooks that write a download to the history when it is
queued, finalize it on a terminal status, notify, push the activity
update and reopen a failed request move from `main.py` to
`core/download_recording.py`, registered with
`register_download_recording(...)`.

None of it was tested directly, so the first commit adds 38 tests: the
queued record for direct and request linked downloads, the terminal
snapshot for complete, error and cancelled, ignoring non terminal
statuses, who gets notified, reopening a failed request, and what
happens when a service is missing or a call fails. They pass on current
`main`. The second commit is the move.

`_resolve_status_scope` and the request update emitter stay in `main.py`
because other code uses them, so the emitter is passed in, and the user
database and history service are looked up per call as before. The hooks
are registered at the same spot. Some existing test lines changed with
the move and no assertion did: the `notify_admin`, `notify_user` and
`reopen_failed_request` patches point at the new module, the direct
calls to `_record_download_queued` go through the queue's hook, and two
tests import `QueueStatus` and `viewer_download_id` themselves now that
`main.py` no longer imports them.

The URL map is identical, all 77 rules, and the full suite, ruff and
basedpyright are clean.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
<!-- coderabbit-ai-generated:v1 -->

## Summary by CodeRabbit

* **Improvements**
* Download history and activity updates reflect queued downloads and
their completion statuses; activity updates are emitted after successful
finalization.
* Admins and eligible download owners receive notifications when
downloads reach a terminal status.
* Requests linked to downloads that fail and cannot be retried are
reopened with a failure reason.
* Activity updates use an opaque download identifier rather than
exposing the underlying task identifier.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-11-01 19:09:01 -07:00
splitsec2 fa28bc2185 refactor: move the release routes into core/release_routes.py (#1640)
The release routes are the largest piece left in `main.py`.
`/api/releases`, `/api/releases/download`, `/api/release-sources` and
the source record route move to `core/release_routes.py`, registered
with `register_release_routes(app, login_required, ...)`. They take the
content type inference, the request policy checks that gate listing and
download, and the release serializers with them. The inspect route is
registered from the same function since it shares the policy gate.
`main.py` is about 700 lines shorter.

The first commit adds 34 tests for what the policy, budget, direct
provider and title tests did not cover: content type inference on
download, the policy refusal on download, the error statuses of the
listing route, the source list and the record route. They pass on
current `main`. The second commit is the move.

The policy checks need the auth mode, the user database and the request
policy settings, so they come in as callables that `main.py` resolves
lazily, which keeps the existing tests that patch those on
`shelfmark.main` working. One test line changed, the patch of
`get_source_content_type_capabilities`, which now points at the new
module. Log lines from these routes now name
`shelfmark.core.release_routes`.

The URL map is identical, all 77 rules. The full suite, ruff and
basedpyright are clean, and I ran the app from the branch with the other
two route PRs merged in and hit the source list, the listing route's
validation, the record route and the download route, which answer as
before. The exception text a few of them return in a 500 is unchanged,
that is for the follow-up on exception text.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
<!-- coderabbit-ai-generated:v1 -->

## Summary by CodeRabbit

* **Release Discovery**
* Release searches can use metadata providers, manual searches, or
direct source browsing, with results and source details returned
together.
* Searches respect request policies and report unavailable sources or
partial-search errors.

* **Downloads**
* Download requests validate submitted details, infer a content type
when possible, and report whether the request was queued successfully.

* **Source Browsing**
* Available sources and individual source records can be retrieved,
subject to applicable access policies.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-11-01 19:02:47 -07:00
splitsec2 2254e2bf93 refactor: move the proxy-auth and API-key middleware into core/auth_middleware.py (#1641)
Row 8 of the plan in Discussion #1536 is the auth middleware. The API
key `before_request` hook, the proxy auth hook, the hook that strips the
session cookie from API key requests and `_proxy_default_is_admin` move
from `main.py` to `core/auth_middleware.py`, registered with
`register_auth_middleware(app, resolve_auth_mode=..., get_user_db=...)`.

This is the code that decides who a request is, so the first commit adds
41 tests before anything moves. They cover the hook order, the admin
group mapping (comma and pipe delimiters, a custom group header, the
WSGI fallback), proxy header handling in each auth mode, provisioning
once per session, the API key scope (exempt paths, a read only key only
on GET `/api/stats`, a wrong key) and the cookie stripping. They pass on
current `main`. The second commit is the move.

The call is made right after `register_security_headers`, so the
`before_request` and `after_request` order is the same as before. The
existing tests called `proxy_auth_middleware()` and
`api_key_auth_middleware()` on `shelfmark.main` directly, and those
names are gone, so 18 call lines in three test files now find the
registered hook by name through a small helper. No patch targets changed
since the auth mode and user database are still read from `main` at
request time.

The URL map and hook order are identical, and the full suite, ruff and
basedpyright are clean.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

<!-- coderabbit-ai-generated:v1 -->

## Summary by CodeRabbit

* **Authentication**
* API-key authentication supports admin and read-only access, with
read-only keys limited to statistics requests.
* Reverse-proxy authentication recognizes configured identity and group
information, and applies admin access according to the configured role
settings.
* Requests with invalid credentials, missing proxy identities, or
authentication errors receive appropriate error responses.
  * API-key requests no longer retain session cookies.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-11-01 18:50:36 -07:00
splitsec2 384adc4c75 refactor: move the queue routes into core/queue_routes.py (#1637)
Next from the route plan in Discussion #1536 are the queue routes:
status, cancel, retry, priority, reorder, queue order, active downloads,
the local file download and the cover proxy. They move from `main.py` to
`core/queue_routes.py`, registered with `register_queue_routes(app,
login_required, ...)`, and `main.py` is about 600 lines shorter.

The first commit adds 33 tests for what the guardrail tests did not
cover, mostly the local download in each shape, the admin paths, the
validation errors and the failure responses. They pass on current
`main`. The second commit is the move.

`resolve_status_scope` and the request update emitter stay in `main.py`
because the release routes and the request code still use them, so they
come in as parameters, with the user database and the history service as
callables evaluated per request. Two test lines changed: the patches of
`send_file` and of the delivery state sync now point at `queue_routes`,
since that is where they get looked up. Log lines from these routes now
name `shelfmark.core.queue_routes`.

The URL map is identical, all 77 rules. The full suite, ruff and
basedpyright are clean, and I ran the app from the branch and hit
status, queue order, active downloads, the local download and the cover
route, which answer as before. The exception text a few of them return
in a 500 is unchanged, that is for the follow-up on exception text.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
<!-- coderabbit-ai-generated:v1 -->

## Summary by CodeRabbit

* **Improvements**
* Queue status, downloads, and queue controls respect account ownership,
with administrative access retained.
* More specific errors clarify when queue items or files are missing or
unavailable.
* Queue actions support cancellation, retrying, prioritization, and
reordering.
  * Cover requests can use cached images when caching is enabled.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-11-01 18:28:10 -07:00
splitsec2 f06585aa01 refactor: move the secret key, session cookie, security headers and log filter into core/app_setup.py (#1639)
`main.py` also builds the app itself, and this moves the parts of that
which need no route into `core/app_setup.py`: the persisted secret key
and the session cookie settings, the security headers, and the filter
that keeps status polling and the WebSocket upgrade noise out of the
log. It is the app setup item in the plan from Discussion #1536.

The first commit adds 14 tests for them, since none existed: the key
reused, created with mode 600, replaced when too short, and still
returned when the config directory cannot be written, the cookie
settings, the headers on a real response and that a header a route set
itself is kept, and each case the log filter drops or keeps. They pass
on current `main`. The second commit is the move.

The security headers hook is registered from the same spot in `main.py`
as before, so the `after_request` order is unchanged. The secret key
function takes the config directory as a parameter instead of reading
`CONFIG_DIR`, which is the one change in the move, and the key tests
call it with a temp directory.

The URL map and the hook order are identical, and the full suite, ruff
and basedpyright are clean. I also ran the app from the branch: the
secret file is created with mode 600 and the four security headers come
back on a normal response.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
<!-- coderabbit-ai-generated:v1 -->

## Summary by CodeRabbit

* **Bug Fixes**
* Sessions now remain consistent across app restarts, and session
cookies use more protective browser settings, including HTTP-only access
and a seven-day lifetime.
* Responses include baseline security headers while preserving any
headers already set by the app.
* Routine status-polling and connection-upgrade messages are filtered
from application logs, reducing unrelated log noise.
* If saving a session key fails, the app can still start with a
generated key.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-11-01 18:21:49 -07:00
splitsec2 9ca1981d00 refactor: move the Socket.IO handlers into core/socket_handlers.py (#1638)
This one is small. The three Socket.IO handlers, connect, disconnect and
request_status, move from `main.py` to `core/socket_handlers.py`,
registered with `register_socket_handlers(socketio, ...)`. It is the
Socket.IO item in the plan from Discussion #1536.

They had no tests, so the first commit adds 9 that go through
Flask-SocketIO's test client: what a client gets on connect with no
auth, as an admin, as a user and as a visitor with no session, a failure
reading the status, the manual status request, and the room bookkeeping
on disconnect. They pass on current `main`. The second commit is the
move.

`resolve_status_scope` stays in `main.py` since the HTTP routes use it
too, so it is passed in. One test line changed, the patch of
`redact_status`, which now points at the new module. Log lines from the
handlers now name `shelfmark.core.socket_handlers`.

The URL map and the registered socket events are identical before and
after, and the full suite, ruff and basedpyright are clean. I also
started the app from the branch and the Socket.IO handshake answers.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

<!-- coderabbit-ai-generated:v1 -->

## Summary by CodeRabbit

* **Bug Fixes**
* Status requests now return a consistent empty update when access is
denied and an error message when status retrieval fails.
* Status visibility follows the requester’s access level, with non-admin
results redacted.
* **Refactor**
* Connection, disconnection, and status-request handling now follows
consistent room and connection-tracking behavior.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-11-01 18:13:11 -07:00
splitsec2 c6685feba5 fix(docker): drop ffmpeg from the default image; install on demand in Debug mode (#1631)
ffmpeg is in the default image for one thing, the Debug mode screen
recording (`_start_ffmpeg_recording`). Nothing else calls it,
SeleniumBase included, and it brings 93 packages with it. On real builds
that's 406 Trivy findings (149 High) and 78 MB of the gzipped amd64
image. This takes it out of the image and installs it when Debug mode
needs it.

It came out of the base image discussion on #1616 and helps on any base,
though on Wolfi the install line would need `apk`.

## What changed

- **Dockerfile:** ffmpeg is gone from the full image's install.
- **Guard:** `_start_ffmpeg_recording` checks `shutil.which("ffmpeg")`
first. If it's missing it logs a warning and returns, and the bypass
carries on. Without this, Debug on with no ffmpeg fails every bypass
with `FileNotFoundError`. There's a new test for it.
- **Entrypoint:** when Debug is on, the internal bypasser is in use and
ffmpeg isn't already there, it installs ffmpeg in the background so the
app doesn't wait for it. The download and the install each run under a
120 s `timeout` with `DEBIAN_FRONTEND=noninteractive`. Downloading first
means a hung mirror can only be killed before dpkg starts, and the next
start runs `dpkg --configure -a` in case a restart cut dpkg off. It logs
when it starts, succeeds, times out or fails.
- **Docs:** a short paragraph in `docs/troubleshooting.md` covering the
install and the non-root limit.

## Debug has two sources

Debug can come from the `DEBUG` env var or from the Debug Mode setting
in the UI, which is saved to `/config/plugins/advanced.json`. The
existing block in `entrypoint.sh` only reads the env var, so an install
keyed on `$DEBUG` would miss anyone who turned it on in the UI.

The new check asks the app instead (`shelfmark.config.env.DEBUG`), which
reads both. The UI setting is `requires_restart`, so the restart it
already asks for is what brings it through the entrypoint.

## Non-root mode

A container started as `1000:1000` can't run `apt-get`, so the
entrypoint logs that it can't install ffmpeg, the guard warns on each
bypass, and there's no recording. Everything else works. The workaround
for a debugging session is to start as root with `PUID`/`PGID`.

## Startup cost

None that I could measure. With Debug on, `/api/health` answers in the
same time as main (3-4 s with Debug only in config, 9-10 s from env),
and ffmpeg was ready 6-8 s later on the runners.

The trade-off is that the first bypass after startup may be unrecorded
while the install runs. It logs the warning and passes. I saw both
outcomes on a bypass started right after health.

From reading the code: the install lives in the container, so recreating
the container installs it again. It also runs after `tor.sh` and
`wireguard.sh`, so apt goes through whichever egress is active.

## Testing

Real Docker builds on GitHub Actions on my fork, amd64 and arm64, with
the same result on both for every case below.

| Case | Result |
|---|---|
| Debug off | No install, `/api/health` in 3-4 s, same as main |
| Debug only in config | Health in 3-4 s, ffmpeg ready 6-8 s later, real
mp4 after a bypass |
| Debug from env | Health in 9-10 s (same as main), real mp4 after a
bypass |
| Started as root, app dropped to uid 1000 | Smoke and bypass pass |
| Started with `--user 1000`, Debug on | Logs both messages, bypass
passes, no mp4 |
| apt blocked (closed proxy) | Logs `Could not install ffmpeg`, bypass
passes, no mp4 |
| apt's proxy on a dropped address, so no TCP reply at all | Health in
9-10 s, a bypass during the install passes with the warning and no mp4.
apt gives up by itself in under a minute. Nothing left running, `dpkg
--audit` clean |
| DNS server on a dropped address | Health in 9-10 s, `Timed out
installing ffmpeg after 120s`. Nothing left running, `dpkg --audit`
clean |
| `--network none`, Debug on | No hang, health 200 from inside the
container |
| Container restarted while dpkg was running | Next start finishes the
install, `dpkg --audit` clean, real mp4 |
| Fresh named volume, Debug only in config | Recording made, uid 1000
can write |
| Existing uid-1000 volume, Debug only in config | Same, nothing
root-owned afterwards |
| `find /config /var/log/shelfmark -user root` | 0 files, same as main |
| `find /app -user root` | 245, same count as main |
| `tests/e2e/platform/run-e2e.sh` with `dns-blocked` and `baseline` |
Pass |

The bypass checks ran against example.com. A real Cloudflare challenge
was served by a demo page in the same runs, and no build solved it,
including main, so that part doesn't say anything about this change.

Locally: 5494 Python tests pass, and `ruff`, `basedpyright`, `vulture`
and `shellcheck` are clean.

The full results are in [the base image
report](https://github.com/splitsec2/shelfmark/blob/7afcecfb76727724a71561cbc078f091569b58dc/docs/research/2026-11-01-base-image-report.md).
2026-11-01 18:09:11 -07:00
splitsec2 34c9e6dbd7 refactor: move the auth routes into core/auth_routes.py (#1625)
The auth routes are the next group from the plan in Discussion #1536:
`/api/auth/login`, `/logout` and `/check`, plus the login lockout
helpers, move from `main.py` to `core/auth_routes.py`, registered with
`register_auth_routes(app, ...)`. `main.py` is about 370 lines shorter.
This is the login path, so I kept it strictly to a move.

Two commits. The first adds 59 tests that pin the current behaviour
before anything moves: login in each mode (builtin, none, CWA and the
OIDC password fallback), wrong passwords, the lockout (the warning when
five attempts are left, the 429 and its text on the tenth failure,
per-user isolation, expiry), the local-auth switches, logout in each
mode and `/check`. They pass on current `main`, and I ran the same file
against `main` and against this branch. The second commit is the move,
and no existing test changed.

The lockout state is one dict, now living in `auth_routes`, and
`main.py` imports the same object so its other users (the
password-attempt limiter for the self-user routes, and the tests) see
it. Everything else that comes from `main` (the user database, the auth
mode, the client IP, the CWA database path, the local-auth settings and
the password check) is passed in as a callable evaluated per request, so
patches on `main` still apply.

Two things to know. `main.py` re-exports the lockout names and keeps
three lines assigning the view functions (`api_login =
app.view_functions["api_login"]` and the other two), because the e2e
tests call `main_module.api_login()` directly and I did not change tests
in this PR. If you would rather drop both, I can edit those e2e tests
instead. The other is that the lockout dict is no longer reset by a
reload of `main`, which only matters in tests, and they clear it
themselves.

The URL map is identical, all 77 rules, and the full suite, ruff and
basedpyright are clean. I also ran the app with builtin auth on a
scratch instance and went through it over HTTP: a wrong password gets a
401, login works and `/check` shows the session, logout clears it, the
tenth failed attempt gets the 429, and the right password is refused
while the account is locked.
2026-11-01 15:53:53 -07:00
splitsec2 d1e959758d refactor: move the system routes into core/system_routes.py (#1624)
The system routes are the next group from the plan in Discussion #1536:
`/api/config`, `/api/health`, `/api/debug`, `/api/restart` and the
routes that serve the frontend (`/`, the SPA catch-all, `/assets/...`,
the logo, the favicons and `theme-init.js`) move from `main.py` to
`core/system_routes.py`, registered with `register_system_routes(app,
login_required, ...)`. `main.py` is about 250 lines shorter.

Same two-commit layout as before. The first commit adds 14 tests for the
frontend-serving routes and the `/api/config` error paths, which had
none, and they pass on current `main`. The second commit is the move,
and no existing test changed.

The helpers only these routes use moved with them (`admin_required`,
built on the `login_required` that is passed in, plus `_base_href` and
`_serve_index_html`). What `main.py` still owns, the base path, the
frontend folder, the auth mode and the session user id, is passed in as
callables evaluated per request, so the tests that patch them on `main`
still work. The debug routes are only registered when debug is on, as
before, with the stop-GUI and run-script hooks handed in.

The thing to look at is the SPA catch-all, which is now registered
earlier in `main.py` than it used to be (the old comment said it must be
last). Werkzeug picks the most specific rule whatever the registration
order, and I checked it: all 76 rule and method pairs in the URL map
still match their own endpoint, on `main` and on this branch, and an
unknown `/api/...` path still gets the JSON 404.

The Flask URL map is identical before and after, all 77 rules, and with
debug on (79). The full suite, ruff and basedpyright are clean. I also
ran the app from this branch: health, config, the logo, the favicon and
the theme script answer as before, a client-side path like
`/library/books` gets the index page, an unknown `/api/` path gets the
JSON 404, and the page loads in a browser. I did not exercise
`/api/restart` or a real debug script run, since nothing tests them.
2026-11-01 14:12:11 -07:00
splitsec2 d41fa98749 refactor: move the metadata routes into core/metadata_routes.py (#1623)
The metadata routes are the next group from the plan in Discussion
#1536: the eight `/api/metadata/...` handlers (providers, config,
search, field-options, book and the three targets routes) move from
`main.py` to `core/metadata_routes.py`, registered with
`register_metadata_routes(app, ...)`. `main.py` is about 490 lines
shorter.

Same two-commit layout as the settings move (#1595). The first commit
adds 39 tests that pass on current `main` before anything moves. They
cover the 401 on all eight routes, the shape of the providers list and
the config, the search errors, the book lookup (200, 404, 400 and 503),
and how target errors map to 400, 502 and 500. The second commit is the
move, and no test changed in it.

Three helpers moved with the routes because nothing else uses them.
`_attach_library_ownership` stays in `main.py` since the releases routes
need it too, and is passed in along with the login decorator and the
auth mode, the way `activity_routes` takes them. The auth mode is a
callable evaluated per request, so the one test that patches it on
`main` still works.

The Flask URL map (every rule, its methods and its endpoint name) is
identical before and after, all 77 rules. The full suite, ruff and
basedpyright are clean. I also ran the app from this branch and hit the
routes over HTTP: providers and config answer 200, a book that doesn't
exist gets a 404, and search answers 503 with the "no metadata provider
configured" message on an instance with none enabled. I did not look at
it in a browser or run the e2e tests.

Log lines for these routes now come from
`shelfmark.core.metadata_routes`, and the error tuples are local copies
so the module doesn't import `main`. One thing to know when reading the
diff: the register call sits just after `_attach_library_ownership` and
not where the first route used to be, because that function has to be
defined before it can be passed in.
2026-11-01 14:11:33 -07:00
splitsec2 0a7e114681 docs(dev): new routes go in a register_<group>_routes module (#1622)
Nothing in the developer docs says where a new route goes, so the next
contributor adds it to `main.py` again. This writes down the convention
from Discussion #1536: a new route group gets its own
`register_<group>_routes` module under `shelfmark/core/`, and `main.py`
calls it once.

It is one paragraph on a new page, `docs/dev/routes.md`, with a line in
the developer index. It also says that a few older routes are still in
`main.py` and are being moved out one group at a time. The route moves
in that discussion will follow what this says, so this goes first.
2026-10-31 03:52:01 -06:00
splitsec2 9bd6c51388 fix(hardcover): read the featured series from a search result (#1589)
Closes #1585. @joeRob2468 traced this one to the parser, and the answer
turned out to be sitting in the search response already.

A Hardcover search hit carries the book's featured series as
`featured_series`, with the same shape as the `featured_book_series` of
a full record: the position and a nested series with its id, name and
book count. `_parse_search_result` never read it, so a book requested
from a search card was stored with `series_name: null` and the
`{Series}` folder and the custom script payload came out empty. It now
reads it, through a small helper that `_parse_book` uses too, so there
is no extra Hardcover call per request (the issue's other option was
fetching the full record, which costs one against the rate limit).

I checked it against a live search for "Mistborn The Final Empire". The
top hit parses to series "The Mistborn Saga: The Original Trilogy",
position 1, id 10001, and its search title is still "Mistborn".

That last part is on purpose. I left `_compute_search_title` alone,
since passing it the series would change what gets searched for everyone
and the series name here doesn't equal the "Mistborn" prefix anyway.

Two things that behave differently. A search result now has
`series_name` set, so anything in the UI that shows it for a full record
(the series link, the bigger cover in the release modal) will show it on
search results too. I ran the branch and looked at the release modal for
the top Mistborn result: it shows "#1 of 3 in The Mistborn Saga: The
Original Trilogy" with the View series link. The modal fetches the full
record when it opens, so I can't say how much of that is new on this
path. Requests already stored keep the empty series they were saved
with.

Tests: four in a new file (a hit with a series, hits with none or an
incomplete one, a hit nested in a `document`, and the search title
staying the same), the first and third fail on current main. The
metadata and core suites, ruff and basedpyright are clean.
2026-10-31 01:35:37 -06:00
dependabot[bot] d95f663a92 build(deps): bump the gh-actions group with 5 updates (#1612)
Bumps the gh-actions group with 5 updates:

| Package | From | To |
| --- | --- | --- |
| [docker/login-action](https://github.com/docker/login-action) |
`4.6.0` | `4.7.0` |
| [astral-sh/setup-uv](https://github.com/astral-sh/setup-uv) | `10.3.0`
| `10.3.2` |
| [github/codeql-action/init](https://github.com/github/codeql-action) |
`4.39.0` | `4.39.1` |
|
[github/codeql-action/autobuild](https://github.com/github/codeql-action)
| `4.39.0` | `4.39.1` |
|
[github/codeql-action/analyze](https://github.com/github/codeql-action)
| `4.39.0` | `4.39.1` |

Updates `docker/login-action` from 4.6.0 to 4.7.0
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/docker/login-action/releases">docker/login-action's
releases</a>.</em></p>
<blockquote>
<h2>v4.7.0</h2>
<ul>
<li>Preserve explicit ECR settings in <code>registry-auth</code> by <a
href="https://github.com/crazy-max"><code>@​crazy-max</code></a> in <a
href="https://redirect.github.com/docker/login-action/pull/1089">docker/login-action#1089</a></li>
<li>Use registry hosts for scoped credentials by <a
href="https://github.com/crazy-max"><code>@​crazy-max</code></a> in <a
href="https://redirect.github.com/docker/login-action/pull/1088">docker/login-action#1088</a></li>
<li>Raise Docker Hub OIDC max expiry to 6 hours by <a
href="https://github.com/crazy-max"><code>@​crazy-max</code></a> in <a
href="https://redirect.github.com/docker/login-action/pull/1068">docker/login-action#1068</a></li>
<li>Log Docker Hub OIDC token exchange progress by <a
href="https://github.com/crazy-max"><code>@​crazy-max</code></a> in <a
href="https://redirect.github.com/docker/login-action/pull/1061">docker/login-action#1061</a></li>
<li>Bump <code>@​aws-sdk/client-ecr</code> and
<code>@​aws-sdk/client-ecr-public</code> to 3.1154.0 in <a
href="https://redirect.github.com/docker/login-action/pull/1093">docker/login-action#1093</a></li>
<li>Bump <code>@​docker/actions-toolkit</code> from 0.94.0 to 0.107.0 in
<a
href="https://redirect.github.com/docker/login-action/pull/1100">docker/login-action#1100</a></li>
<li>Bump <code>@​humanfs/node</code> from 0.16.7 to 0.16.8 in <a
href="https://redirect.github.com/docker/login-action/pull/1077">docker/login-action#1077</a></li>
<li>Bump csv-parse from 7.0.1 to 7.0.3 in <a
href="https://redirect.github.com/docker/login-action/pull/1078">docker/login-action#1078</a></li>
<li>Bump handlebars from 4.7.9 to 4.7.10 in <a
href="https://redirect.github.com/docker/login-action/pull/1091">docker/login-action#1091</a></li>
<li>Bump js-yaml from 5.2.2 to 5.5.0 in <a
href="https://redirect.github.com/docker/login-action/pull/1066">docker/login-action#1066</a></li>
<li>Bump postcss from 8.5.22 to 8.5.26 in <a
href="https://redirect.github.com/docker/login-action/pull/1071">docker/login-action#1071</a></li>
<li>Bump postcss-selector-parser from 7.1.1 to 7.1.6 in <a
href="https://redirect.github.com/docker/login-action/pull/1092">docker/login-action#1092</a></li>
<li>Bump source-map-js from 1.2.1 to 1.2.2 in <a
href="https://redirect.github.com/docker/login-action/pull/1090">docker/login-action#1090</a></li>
<li>Bump undici from 6.27.0 to 6.29.1 in <a
href="https://redirect.github.com/docker/login-action/pull/1085">docker/login-action#1085</a></li>
<li>Bump uuid from 14.0.1 to 14.0.3 in <a
href="https://redirect.github.com/docker/login-action/pull/1096">docker/login-action#1096</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/docker/login-action/compare/v4.6.0...v4.7.0">https://github.com/docker/login-action/compare/v4.6.0...v4.7.0</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/docker/login-action/commit/81c6f93d4bc991e510b342a00b419b3bb4efc5c6"><code>81c6f93</code></a>
Merge pull request <a
href="https://redirect.github.com/docker/login-action/issues/1100">#1100</a>
from docker/dependabot/npm_and_yarn/docker/actions-t...</li>
<li><a
href="https://github.com/docker/login-action/commit/1b16c8d1815e1d951d87a3522ff48a76d073735a"><code>1b16c8d</code></a>
Merge pull request <a
href="https://redirect.github.com/docker/login-action/issues/1096">#1096</a>
from docker/dependabot/npm_and_yarn/uuid-14.0.3</li>
<li><a
href="https://github.com/docker/login-action/commit/ac86b5e9e9d2db43b12b2d3afb9140a427d69d65"><code>ac86b5e</code></a>
[dependabot skip] chore: update generated content</li>
<li><a
href="https://github.com/docker/login-action/commit/1476d845f08dd8309a938959a280f2e23c0cba14"><code>1476d84</code></a>
build(deps): bump <code>@​docker/actions-toolkit</code> from 0.94.0 to
0.107.0</li>
<li><a
href="https://github.com/docker/login-action/commit/503cee68d85e94275776fcc6f5315f19207f7567"><code>503cee6</code></a>
Merge pull request <a
href="https://redirect.github.com/docker/login-action/issues/1095">#1095</a>
from docker/dependabot/github_actions/actions/create...</li>
<li><a
href="https://github.com/docker/login-action/commit/f0935357f5c539aeb7ff6b886ba5434ab76e9de3"><code>f093535</code></a>
Merge pull request <a
href="https://redirect.github.com/docker/login-action/issues/1099">#1099</a>
from docker/dependabot/github_actions/codecov/codeco...</li>
<li><a
href="https://github.com/docker/login-action/commit/d5ae7f188776587b12b77b45e29ea21332462da9"><code>d5ae7f1</code></a>
Merge pull request <a
href="https://redirect.github.com/docker/login-action/issues/1098">#1098</a>
from docker/dependabot/github_actions/docker/bake-ac...</li>
<li><a
href="https://github.com/docker/login-action/commit/6f9750797f6e7186f18c069a7e97abc1dcf1006b"><code>6f97507</code></a>
Merge pull request <a
href="https://redirect.github.com/docker/login-action/issues/1097">#1097</a>
from docker/dependabot/github_actions/docker/bake-ac...</li>
<li><a
href="https://github.com/docker/login-action/commit/006d61fa598847d65f6670d1569545fa2af9cf02"><code>006d61f</code></a>
Merge pull request <a
href="https://redirect.github.com/docker/login-action/issues/1094">#1094</a>
from docker/dependabot/github_actions/codeql-actions...</li>
<li><a
href="https://github.com/docker/login-action/commit/93f5f522eea5eacabf35cc89b9d19ec23e742471"><code>93f5f52</code></a>
Merge pull request <a
href="https://redirect.github.com/docker/login-action/issues/1093">#1093</a>
from docker/dependabot/npm_and_yarn/aws-sdk-dependen...</li>
<li>Additional commits viewable in <a
href="https://github.com/docker/login-action/compare/dbcb813823bdd20940b903addbd779551569679f...81c6f93d4bc991e510b342a00b419b3bb4efc5c6">compare
view</a></li>
</ul>
</details>
<br />

Updates `astral-sh/setup-uv` from 10.3.0 to 10.3.2
<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.3.2 🌈 update known checksums for 0.13.5</h2>
<h2>Changes</h2>
<h2>🧰 Maintenance</h2>
<ul>
<li>chore: update known checksums for 0.13.5 @<a
href="https://github.com/apps/github-actions">github-actions[bot]</a>
(<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/1103">#1103</a>)</li>
</ul>
<h2>📚 Documentation</h2>
<ul>
<li>docs: update version references to v10.3.1 @<a
href="https://github.com/apps/github-actions">github-actions[bot]</a>
(<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/1102">#1102</a>)</li>
</ul>
<h2>v10.3.1 🌈 preserve cache error class names in save-cache bundle</h2>
<h2>Changes</h2>
<p>This release includes a fix so that multiple concurrent workflows
trying to upload the same cache no longer warn</p>
<p><code>##[warning]Failed to save: Unable to reserve cache with key
..., another job may be creating this cache.</code></p>
<h2>🐛 Bug fixes</h2>
<ul>
<li>fix: preserve cache error class names in save-cache bundle <a
href="https://github.com/dannyward630"><code>@​dannyward630</code></a>
(<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/1093">#1093</a>)</li>
</ul>
<h2>🧰 Maintenance</h2>
<ul>
<li>chore: update known checksums for 0.13.4 @<a
href="https://github.com/apps/github-actions">github-actions[bot]</a>
(<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/1099">#1099</a>)</li>
<li>chore: update known checksums for 0.13.2/0.13.3 @<a
href="https://github.com/apps/github-actions">github-actions[bot]</a>
(<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/1098">#1098</a>)</li>
<li>chore: update known checksums for 0.13.1 @<a
href="https://github.com/apps/github-actions">github-actions[bot]</a>
(<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/1094">#1094</a>)</li>
<li>Verify npm dependency signatures before builds <a
href="https://github.com/zaniebot"><code>@​zaniebot</code></a> (<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/1027">#1027</a>)</li>
</ul>
<h2>📚 Documentation</h2>
<ul>
<li>docs: cache-dependency-glob matches relative to the working
directory <a
href="https://github.com/flange-ipb"><code>@​flange-ipb</code></a> (<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/1092">#1092</a>)</li>
<li>docs: update version references to v10.3.0 @<a
href="https://github.com/apps/github-actions">github-actions[bot]</a>
(<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/1090">#1090</a>)</li>
</ul>
<h2>⬆️ Dependency updates</h2>
<ul>
<li>chore(deps): 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/1101">#1101</a>)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/astral-sh/setup-uv/commit/07a047e608db84b3d8c06cd9a7c7dcc9c355c18e"><code>07a047e</code></a>
chore: update known checksums for 0.13.5 (<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/1103">#1103</a>)</li>
<li><a
href="https://github.com/astral-sh/setup-uv/commit/e1b85369e72750369c935b004eccf97311bdf5dc"><code>e1b8536</code></a>
docs: update version references to v10.3.1 (<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/1102">#1102</a>)</li>
<li><a
href="https://github.com/astral-sh/setup-uv/commit/18d369b23da25a81e9458561248e1aafff6a70fa"><code>18d369b</code></a>
chore(deps): roll up Dependabot updates (<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/1101">#1101</a>)</li>
<li><a
href="https://github.com/astral-sh/setup-uv/commit/4ad2329c1e377d882fcae221cd1d17e7c0ad788e"><code>4ad2329</code></a>
chore: update known checksums for 0.13.4 (<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/1099">#1099</a>)</li>
<li><a
href="https://github.com/astral-sh/setup-uv/commit/4bc6759c82322ff7efa28de54a45404ca6869912"><code>4bc6759</code></a>
chore: update known checksums for 0.13.2/0.13.3 (<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/1098">#1098</a>)</li>
<li><a
href="https://github.com/astral-sh/setup-uv/commit/c97f06e356bcca1b426f396ef93ec35ce149ce7b"><code>c97f06e</code></a>
fix: preserve cache error class names in save-cache bundle (<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/1093">#1093</a>)</li>
<li><a
href="https://github.com/astral-sh/setup-uv/commit/8ca4be000bfa5f8e6aaa01d8470878c90aa728d3"><code>8ca4be0</code></a>
chore: update known checksums for 0.13.1 (<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/1094">#1094</a>)</li>
<li><a
href="https://github.com/astral-sh/setup-uv/commit/b627d85691ead579b598a36169571b1c7123526f"><code>b627d85</code></a>
docs: cache-dependency-glob matches relative to the working directory
(<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/1092">#1092</a>)</li>
<li><a
href="https://github.com/astral-sh/setup-uv/commit/f8c88970fcd8fff8e97bc62edc5f5c073a0527fe"><code>f8c8897</code></a>
Verify npm dependency signatures before builds (<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/1027">#1027</a>)</li>
<li><a
href="https://github.com/astral-sh/setup-uv/commit/f64d32b1cd6cc11405a74665b3dfb7bb86fea9f0"><code>f64d32b</code></a>
docs: update version references to v10.3.0 (<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/1090">#1090</a>)</li>
<li>See full diff in <a
href="https://github.com/astral-sh/setup-uv/compare/1c37ad07a6a961277cf70c0d37d6f313000f5884...07a047e608db84b3d8c06cd9a7c7dcc9c355c18e">compare
view</a></li>
</ul>
</details>
<br />

Updates `github/codeql-action/init` from 4.39.0 to 4.39.1
<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.39.1</h2>
<ul>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.27.3">2.27.3</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/4256">#4256</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.39.1 - 21 Oct 2026</h2>
<ul>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.27.3">2.27.3</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/4256">#4256</a></li>
</ul>
<h2>4.39.0 - 15 Oct 2026</h2>
<ul>
<li><em>Breaking change</em>: Bump the minimum required CodeQL bundle
version to 2.20.7. <a
href="https://redirect.github.com/github/codeql-action/pull/4205">#4205</a></li>
<li>The <code>codeql-action/analyze</code> step now validates that a
correct argument for the <code>checkout_path</code> input has been
provided. The <code>checkout_path</code> input must be set to the path
where the repository that is being analyzed is checked out at on the
runner, if that path is something other than the workspace root. If the
path does not point to a repository checkout or is different from the
path that was used by <code>codeql-action/init</code>, then the CodeQL
Action now produces a warning. <a
href="https://redirect.github.com/github/codeql-action/pull/4148">#4148</a></li>
<li>Resolved an issue with third-party caching implementations that
prevented caches from being restored correctly. <a
href="https://redirect.github.com/github/codeql-action/pull/4206">#4206</a></li>
<li>The <code>codeql-action/start-proxy</code> step that is used in Code
Scanning Default Setup now finds CodeQL releases using the same logic as
<code>codeql-action/init</code>. This primarily benefits customers who
are using GitHub instances other than <code>github.com</code> where the
<code>codeql-action/start-proxy</code> step may previously have been
unable to locate recent releases and ended up using outdated versions of
the private registry authentication proxy as a result. <a
href="https://redirect.github.com/github/codeql-action/pull/4198">#4198</a></li>
</ul>
<h2>4.38.3 - 08 Oct 2026</h2>
<ul>
<li><em>Upcoming breaking change</em>: CodeQL version 2.21.2 and earlier
were discontinued on 24 September 2026 alongside GitHub Enterprise
Server 3.17, and will be unsupported by the next minor release of the
CodeQL Action. Added a deprecation warning for customers using these
versions of CodeQL. <a
href="https://redirect.github.com/github/codeql-action/pull/4188">#4188</a></li>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.27.2">2.27.2</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/4203">#4203</a></li>
<li>Fixed a bug where the decision of whether to use a per-language
bundle did not account for custom configurations that reference queries
outside of compiled CodeQL packs. This issue was caught during internal
testing and did not affect any customer repositories. We will resume the
roll out of per-language bundles in the coming weeks. <a
href="https://redirect.github.com/github/codeql-action/pull/4184">#4184</a></li>
</ul>
<h2>4.38.2 - 24 Sept 2026</h2>
<ul>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.27.1">2.27.1</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/4160">#4160</a></li>
</ul>
<h2>4.38.1 - 18 Sept 2026</h2>
<ul>
<li>The CodeQL Action now has experimental support for CodeQL releases
for which per-language bundles are available. Per-language bundles
support analysis for a single language and are therefore smaller than
the combined bundles that allow analysis for all supported languages. As
a result, per-language bundles take up less space on disk and are faster
to download. We expect to roll this change out to everyone in the coming
weeks. <a
href="https://redirect.github.com/github/codeql-action/pull/4146">#4146</a></li>
</ul>
<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>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/github/codeql-action/commit/9b3254085494b55a0f7c4c2b3d2d52cc712ce370"><code>9b32540</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4257">#4257</a>
from github/update-v4.39.1-c5a2f4b0f</li>
<li><a
href="https://github.com/github/codeql-action/commit/b5e1ed9574dcfa81c88c318140f3b3e5c199bfb3"><code>b5e1ed9</code></a>
Trigger workflows</li>
<li><a
href="https://github.com/github/codeql-action/commit/20f5ec39109073afe1789b098456b2be0164f4ee"><code>20f5ec3</code></a>
Update changelog for v4.39.1</li>
<li><a
href="https://github.com/github/codeql-action/commit/c5a2f4b0fe28ca06eaaeb86fb05a5f612ae293f9"><code>c5a2f4b</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4245">#4245</a>
from github/henrymercer/graduate-overlay-trap-caching</li>
<li><a
href="https://github.com/github/codeql-action/commit/61a5a80dd98ad1b9679010141a46cace51065761"><code>61a5a80</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4256">#4256</a>
from github/update-bundle/codeql-bundle-v2.27.3</li>
<li><a
href="https://github.com/github/codeql-action/commit/3dd33f8b69588bd9591684b2dca169eb612f5ed5"><code>3dd33f8</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4249">#4249</a>
from github/mbg/upload-improvements-for-4223</li>
<li><a
href="https://github.com/github/codeql-action/commit/b96e3390db93dc46c2c3753a94b87c0d734b0b61"><code>b96e339</code></a>
Change comment for <code>getConfig</code> call in
<code>upload-sarif</code></li>
<li><a
href="https://github.com/github/codeql-action/commit/7fa45180db8d657b23ded3636b5d8583aec6ddfe"><code>7fa4518</code></a>
Use <code>shouldSkipSarifUpload</code> in
<code>analyze-action.ts</code></li>
<li><a
href="https://github.com/github/codeql-action/commit/a69c7cb332d56f616fcadef002884523c71e8d1c"><code>a69c7cb</code></a>
Document that setting the env vars to <code>false</code> takes
precedence</li>
<li><a
href="https://github.com/github/codeql-action/commit/74ddcd9f65683791ab07eccd074b4ba145d89ea6"><code>74ddcd9</code></a>
Fix typo in comment</li>
<li>Additional commits viewable in <a
href="https://github.com/github/codeql-action/compare/6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee...9b3254085494b55a0f7c4c2b3d2d52cc712ce370">compare
view</a></li>
</ul>
</details>
<br />

Updates `github/codeql-action/autobuild` from 4.39.0 to 4.39.1
<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.39.1</h2>
<ul>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.27.3">2.27.3</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/4256">#4256</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.39.1 - 21 Oct 2026</h2>
<ul>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.27.3">2.27.3</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/4256">#4256</a></li>
</ul>
<h2>4.39.0 - 15 Oct 2026</h2>
<ul>
<li><em>Breaking change</em>: Bump the minimum required CodeQL bundle
version to 2.20.7. <a
href="https://redirect.github.com/github/codeql-action/pull/4205">#4205</a></li>
<li>The <code>codeql-action/analyze</code> step now validates that a
correct argument for the <code>checkout_path</code> input has been
provided. The <code>checkout_path</code> input must be set to the path
where the repository that is being analyzed is checked out at on the
runner, if that path is something other than the workspace root. If the
path does not point to a repository checkout or is different from the
path that was used by <code>codeql-action/init</code>, then the CodeQL
Action now produces a warning. <a
href="https://redirect.github.com/github/codeql-action/pull/4148">#4148</a></li>
<li>Resolved an issue with third-party caching implementations that
prevented caches from being restored correctly. <a
href="https://redirect.github.com/github/codeql-action/pull/4206">#4206</a></li>
<li>The <code>codeql-action/start-proxy</code> step that is used in Code
Scanning Default Setup now finds CodeQL releases using the same logic as
<code>codeql-action/init</code>. This primarily benefits customers who
are using GitHub instances other than <code>github.com</code> where the
<code>codeql-action/start-proxy</code> step may previously have been
unable to locate recent releases and ended up using outdated versions of
the private registry authentication proxy as a result. <a
href="https://redirect.github.com/github/codeql-action/pull/4198">#4198</a></li>
</ul>
<h2>4.38.3 - 08 Oct 2026</h2>
<ul>
<li><em>Upcoming breaking change</em>: CodeQL version 2.21.2 and earlier
were discontinued on 24 September 2026 alongside GitHub Enterprise
Server 3.17, and will be unsupported by the next minor release of the
CodeQL Action. Added a deprecation warning for customers using these
versions of CodeQL. <a
href="https://redirect.github.com/github/codeql-action/pull/4188">#4188</a></li>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.27.2">2.27.2</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/4203">#4203</a></li>
<li>Fixed a bug where the decision of whether to use a per-language
bundle did not account for custom configurations that reference queries
outside of compiled CodeQL packs. This issue was caught during internal
testing and did not affect any customer repositories. We will resume the
roll out of per-language bundles in the coming weeks. <a
href="https://redirect.github.com/github/codeql-action/pull/4184">#4184</a></li>
</ul>
<h2>4.38.2 - 24 Sept 2026</h2>
<ul>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.27.1">2.27.1</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/4160">#4160</a></li>
</ul>
<h2>4.38.1 - 18 Sept 2026</h2>
<ul>
<li>The CodeQL Action now has experimental support for CodeQL releases
for which per-language bundles are available. Per-language bundles
support analysis for a single language and are therefore smaller than
the combined bundles that allow analysis for all supported languages. As
a result, per-language bundles take up less space on disk and are faster
to download. We expect to roll this change out to everyone in the coming
weeks. <a
href="https://redirect.github.com/github/codeql-action/pull/4146">#4146</a></li>
</ul>
<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>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/github/codeql-action/commit/9b3254085494b55a0f7c4c2b3d2d52cc712ce370"><code>9b32540</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4257">#4257</a>
from github/update-v4.39.1-c5a2f4b0f</li>
<li><a
href="https://github.com/github/codeql-action/commit/b5e1ed9574dcfa81c88c318140f3b3e5c199bfb3"><code>b5e1ed9</code></a>
Trigger workflows</li>
<li><a
href="https://github.com/github/codeql-action/commit/20f5ec39109073afe1789b098456b2be0164f4ee"><code>20f5ec3</code></a>
Update changelog for v4.39.1</li>
<li><a
href="https://github.com/github/codeql-action/commit/c5a2f4b0fe28ca06eaaeb86fb05a5f612ae293f9"><code>c5a2f4b</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4245">#4245</a>
from github/henrymercer/graduate-overlay-trap-caching</li>
<li><a
href="https://github.com/github/codeql-action/commit/61a5a80dd98ad1b9679010141a46cace51065761"><code>61a5a80</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4256">#4256</a>
from github/update-bundle/codeql-bundle-v2.27.3</li>
<li><a
href="https://github.com/github/codeql-action/commit/3dd33f8b69588bd9591684b2dca169eb612f5ed5"><code>3dd33f8</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4249">#4249</a>
from github/mbg/upload-improvements-for-4223</li>
<li><a
href="https://github.com/github/codeql-action/commit/b96e3390db93dc46c2c3753a94b87c0d734b0b61"><code>b96e339</code></a>
Change comment for <code>getConfig</code> call in
<code>upload-sarif</code></li>
<li><a
href="https://github.com/github/codeql-action/commit/7fa45180db8d657b23ded3636b5d8583aec6ddfe"><code>7fa4518</code></a>
Use <code>shouldSkipSarifUpload</code> in
<code>analyze-action.ts</code></li>
<li><a
href="https://github.com/github/codeql-action/commit/a69c7cb332d56f616fcadef002884523c71e8d1c"><code>a69c7cb</code></a>
Document that setting the env vars to <code>false</code> takes
precedence</li>
<li><a
href="https://github.com/github/codeql-action/commit/74ddcd9f65683791ab07eccd074b4ba145d89ea6"><code>74ddcd9</code></a>
Fix typo in comment</li>
<li>Additional commits viewable in <a
href="https://github.com/github/codeql-action/compare/6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee...9b3254085494b55a0f7c4c2b3d2d52cc712ce370">compare
view</a></li>
</ul>
</details>
<br />

Updates `github/codeql-action/analyze` from 4.39.0 to 4.39.1
<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.39.1</h2>
<ul>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.27.3">2.27.3</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/4256">#4256</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.39.1 - 21 Oct 2026</h2>
<ul>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.27.3">2.27.3</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/4256">#4256</a></li>
</ul>
<h2>4.39.0 - 15 Oct 2026</h2>
<ul>
<li><em>Breaking change</em>: Bump the minimum required CodeQL bundle
version to 2.20.7. <a
href="https://redirect.github.com/github/codeql-action/pull/4205">#4205</a></li>
<li>The <code>codeql-action/analyze</code> step now validates that a
correct argument for the <code>checkout_path</code> input has been
provided. The <code>checkout_path</code> input must be set to the path
where the repository that is being analyzed is checked out at on the
runner, if that path is something other than the workspace root. If the
path does not point to a repository checkout or is different from the
path that was used by <code>codeql-action/init</code>, then the CodeQL
Action now produces a warning. <a
href="https://redirect.github.com/github/codeql-action/pull/4148">#4148</a></li>
<li>Resolved an issue with third-party caching implementations that
prevented caches from being restored correctly. <a
href="https://redirect.github.com/github/codeql-action/pull/4206">#4206</a></li>
<li>The <code>codeql-action/start-proxy</code> step that is used in Code
Scanning Default Setup now finds CodeQL releases using the same logic as
<code>codeql-action/init</code>. This primarily benefits customers who
are using GitHub instances other than <code>github.com</code> where the
<code>codeql-action/start-proxy</code> step may previously have been
unable to locate recent releases and ended up using outdated versions of
the private registry authentication proxy as a result. <a
href="https://redirect.github.com/github/codeql-action/pull/4198">#4198</a></li>
</ul>
<h2>4.38.3 - 08 Oct 2026</h2>
<ul>
<li><em>Upcoming breaking change</em>: CodeQL version 2.21.2 and earlier
were discontinued on 24 September 2026 alongside GitHub Enterprise
Server 3.17, and will be unsupported by the next minor release of the
CodeQL Action. Added a deprecation warning for customers using these
versions of CodeQL. <a
href="https://redirect.github.com/github/codeql-action/pull/4188">#4188</a></li>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.27.2">2.27.2</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/4203">#4203</a></li>
<li>Fixed a bug where the decision of whether to use a per-language
bundle did not account for custom configurations that reference queries
outside of compiled CodeQL packs. This issue was caught during internal
testing and did not affect any customer repositories. We will resume the
roll out of per-language bundles in the coming weeks. <a
href="https://redirect.github.com/github/codeql-action/pull/4184">#4184</a></li>
</ul>
<h2>4.38.2 - 24 Sept 2026</h2>
<ul>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.27.1">2.27.1</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/4160">#4160</a></li>
</ul>
<h2>4.38.1 - 18 Sept 2026</h2>
<ul>
<li>The CodeQL Action now has experimental support for CodeQL releases
for which per-language bundles are available. Per-language bundles
support analysis for a single language and are therefore smaller than
the combined bundles that allow analysis for all supported languages. As
a result, per-language bundles take up less space on disk and are faster
to download. We expect to roll this change out to everyone in the coming
weeks. <a
href="https://redirect.github.com/github/codeql-action/pull/4146">#4146</a></li>
</ul>
<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>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/github/codeql-action/commit/9b3254085494b55a0f7c4c2b3d2d52cc712ce370"><code>9b32540</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4257">#4257</a>
from github/update-v4.39.1-c5a2f4b0f</li>
<li><a
href="https://github.com/github/codeql-action/commit/b5e1ed9574dcfa81c88c318140f3b3e5c199bfb3"><code>b5e1ed9</code></a>
Trigger workflows</li>
<li><a
href="https://github.com/github/codeql-action/commit/20f5ec39109073afe1789b098456b2be0164f4ee"><code>20f5ec3</code></a>
Update changelog for v4.39.1</li>
<li><a
href="https://github.com/github/codeql-action/commit/c5a2f4b0fe28ca06eaaeb86fb05a5f612ae293f9"><code>c5a2f4b</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4245">#4245</a>
from github/henrymercer/graduate-overlay-trap-caching</li>
<li><a
href="https://github.com/github/codeql-action/commit/61a5a80dd98ad1b9679010141a46cace51065761"><code>61a5a80</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4256">#4256</a>
from github/update-bundle/codeql-bundle-v2.27.3</li>
<li><a
href="https://github.com/github/codeql-action/commit/3dd33f8b69588bd9591684b2dca169eb612f5ed5"><code>3dd33f8</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4249">#4249</a>
from github/mbg/upload-improvements-for-4223</li>
<li><a
href="https://github.com/github/codeql-action/commit/b96e3390db93dc46c2c3753a94b87c0d734b0b61"><code>b96e339</code></a>
Change comment for <code>getConfig</code> call in
<code>upload-sarif</code></li>
<li><a
href="https://github.com/github/codeql-action/commit/7fa45180db8d657b23ded3636b5d8583aec6ddfe"><code>7fa4518</code></a>
Use <code>shouldSkipSarifUpload</code> in
<code>analyze-action.ts</code></li>
<li><a
href="https://github.com/github/codeql-action/commit/a69c7cb332d56f616fcadef002884523c71e8d1c"><code>a69c7cb</code></a>
Document that setting the env vars to <code>false</code> takes
precedence</li>
<li><a
href="https://github.com/github/codeql-action/commit/74ddcd9f65683791ab07eccd074b4ba145d89ea6"><code>74ddcd9</code></a>
Fix typo in comment</li>
<li>Additional commits viewable in <a
href="https://github.com/github/codeql-action/compare/6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee...9b3254085494b55a0f7c4c2b3d2d52cc712ce370">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-10-31 01:35:03 -06:00
splitsec2 d04268c639 fix(docker): drop the unused selenium-manager binaries from the image (#1615)
Came out of looking at what is in the image after @wrycu's #1606. The
selenium package ships prebuilt `selenium-manager` binaries for four
platforms (about 22MB uncompressed), and none of them are used here.
They only run when selenium has to find a webdriver, and the bypasser
drives the browser through SeleniumBase's Pure-CDP driver, which doesn't
use one.

This deletes them (and their SBOM file) in the same `RUN` as `uv sync`,
so the layer doesn't carry them. As a side effect image scanners stop
reporting on the Rust crates compiled into those binaries.

To check nothing needs them, I deleted the files in a throwaway
container from the current `dev` image and ran `internal_bypasser.get()`
against example.com. It started the browser, loaded the page and
returned it, same as without the deletion. I haven't run a full image
build of this branch, so CI is the first time that exact `RUN` line gets
executed.

TL;DR dropping an unused feature from an included package lowering size
and eliminating flags for security vulnerabilities that don't get
exercised.

@calibrain this one is optional too, but I don't see a downside, your
call.
2026-10-31 01:21:05 -06:00
splitsec2 3d7d04256f fix(docker): bump Chromium pin to 156.0.8078.73 (#1614)
Debian has a newer Chromium in trixie-security (156.0.8078.73), so this
moves the pin from .47 to .73 and the snapshot to 20261029T020000Z, the
first one that has it for both amd64 and arm64. Same shape as #1601.

To check the bypasser still works on it, I started a throwaway container
from the current `dev` image, upgraded Chromium to .73 the way the
Dockerfile does, and ran `internal_bypasser.get()` against example.com.
It starts, loads the page and returns it, the same as on .47. I also
pointed both versions at the Cloudflare demo challenge page at
scrapingcourse.com. They behave the same there, but neither one solves
it from where I ran it (same challenge detected 4 times, then abort), so
that only shows no difference, not a working solve.

I also ran it against an Anna's Archive search page on both versions.
Both returned the full results page, but the log shows no bypass
attempts, so that mirror didn't serve a challenge from where I ran it
and the solver itself wasn't exercised.

@calibrain your call on if we should update to this, it is minor bump to
what has already been pushed.
2026-10-30 14:22:43 -04:00
splitsec2 cd5a478831 refactor: move the onboarding routes into core/onboarding_routes.py (#1594)
First of the route moves from Discussion #1536, following the plan table
there: the three onboarding routes (`GET` and `POST /api/onboarding`,
`POST /api/onboarding/skip`) leave `main.py` for
`core/onboarding_routes.py`, registered with
`register_onboarding_routes(app, login_required)` the way
`release_inspect_routes` takes it. `main.py` goes down by 72 lines and
the handler bodies are the same code (I diffed them after de-indenting:
the only difference is the local name of the error tuple).

There are two commits so the move can be reviewed on its own. The first
adds 15 tests for these routes, which had none, and they pass on current
`main` before anything moves. They pin the behaviour I most wanted to
keep: a 401 without a session when auth is on, a 403 for a signed-in
non-admin (that check lives in `login_required` and goes by path, which
is why the same decorator has to be passed in), the 400 on an empty
body, the result passed through on success and failure, the 500 with the
error text on an operational error, and open access when no auth is
configured. The second commit is the move, and no test changed in it.

What I checked on the move:

- The Flask URL map (every rule, its methods and its endpoint name) is
identical before and after: 77 rules, the three onboarding ones
included.
- Full suite, ruff and basedpyright are clean.
- I ran the app from this branch and stepped through the setup wizard in
a browser. It loads its steps from the moved route and goes from Search
Mode to Metadata Provider, and `POST /api/onboarding/skip` and the
empty-body 400 answer as before.

Two small things that do change. The log lines for these routes now come
from `shelfmark.core.onboarding_routes` instead of `main`, and the error
tuple is a local copy (`_ONBOARDING_ERRORS`, the same classes as
`_IMPORT_OPERATIONAL_ERRORS`) so the module does not import `main`.
Nothing else is different.
2026-10-30 12:10:04 -06:00
splitsec2 69cbf72698 refactor: move the settings routes into core/settings_routes.py (#1595)
Second route move from the plan in Discussion #1536: the four settings
routes (`GET /api/settings`, `GET` and `PUT /api/settings/<tab>`, and
`POST /api/settings/<tab>/action/<key>`) go from `main.py` to
`core/settings_routes.py`, registered with
`register_settings_routes(app, login_required)`. `main.py` is 143 lines
shorter. These handlers call `settings_registry` directly, so nothing
else has to be passed in. The handler bodies are the same code, and the
only difference I found after de-indenting them is that `ruff format`
wrapped one signature that no longer fits on a line.

Same two-commit layout as the onboarding one (#1594). The first commit
adds 27 tests for these routes and they pass on current `main` before
anything moves. They pin the 404 for an unknown tab, the 400 when a
`PUT` body is not a JSON object (a list, a string, a number, or no body
at all), the "No changes to save" answer for an empty object, the
unsaved form values reaching an action, the result passed through with a
400 on failure, the 500 with the error text on an operational error, a
401 without a session, a 403 for a signed-in non-admin on all four
routes, and open access with no auth. The second commit is the move and
no test changed in it. The admin check for these paths lives in
`login_required`, so the same decorator is passed in.

What I checked:

- The Flask URL map (every rule, its methods and its endpoint name) is
identical before and after, all 77 rules.
- Full suite, ruff and basedpyright are clean.
- I ran the app from this branch and opened Settings in a browser: the
modal lists every tab and the General tab shows its values, which come
from the moved `GET`. Over HTTP, a `PUT` of a new search page title
saved and read back, a list body got the 400 and an unknown tab the 404.

As with #1594, log lines for these routes now come from
`shelfmark.core.settings_routes`, and the error tuple is a local copy
(`_SETTINGS_ERRORS`) so the module does not import `main`. The two PRs
touch neighbouring lines of `main.py` and I checked that they merge
together without a conflict, in either order.
2026-10-30 12:08:00 -06:00
dependabot[bot] b2e3c1faf1 build(deps): bump python from 3.14.8-slim to 3.14.9-slim (#1610)
Bumps python from 3.14.8-slim to 3.14.9-slim.


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=python&package-manager=docker&previous-version=3.14.8-slim&new-version=3.14.9-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-10-30 01:56:56 -04:00
dependabot[bot] 7bf6c8263b build(deps-dev): bump the npm-deps group in /src/frontend with 5 updates (#1611)
Bumps the npm-deps group in /src/frontend with 5 updates:

| Package | From | To |
| --- | --- | --- |
| [knip](https://github.com/webpro-nl/knip/tree/HEAD/packages/knip) |
`6.42.1` | `6.43.0` |
| [oxfmt](https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt) |
`0.74.0` | `0.75.0` |
| [oxlint](https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint) |
`1.89.0` | `1.90.0` |
| [oxlint-tsgolint](https://github.com/oxc-project/tsgolint) |
`7.0.2004` | `7.0.2005` |
| [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) |
`8.3.4` | `8.3.5` |

Updates `knip` from 6.42.1 to 6.43.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.43.0</h2>
<ul>
<li>Document and pin exports that only keep each other alive (<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/2160">#2160</a>)
(39af70abb32523a992a7d6152b8cd95ef317f237)</li>
<li>Don't add config files without an extension as entry files (resolve
<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/2168">#2168</a>)
(46a825ddf15d3073b899ed6ef34689e5da5508f2)</li>
<li>Recognize unscoped ~name workspace packages (resolve <a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/2172">#2172</a>)
(ba527f7386e7741507f2f18d84864058e02ac907)</li>
<li>Follow imports and re-exports in ambient modules (resolve <a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/2174">#2174</a>)
(dbd3a8b8b0429433e5385371c09e805b808a32c2) - thanks <a
href="https://github.com/tlq5l"><code>@​tlq5l</code></a>!</li>
<li>Follow <code>@reference</code> in Tailwind CSS compiler (<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/2177">#2177</a>)
(219f16de95e174cdcfd6a90782bcef13cf79bd64) - thanks <a
href="https://github.com/mkantautas"><code>@​mkantautas</code></a>!</li>
<li>Support <code>{ find, replacement }</code> array form of Vite
<code>alias</code> (<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/2179">#2179</a>)
(5d0779bc4cf8c0469c50d5b2cda1a6bd1f053d81) - thanks <a
href="https://github.com/mkantautas"><code>@​mkantautas</code></a>!</li>
<li>fix(expo): treat local config plugins as entry files (<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/2176">#2176</a>)
(100ef491509a88b00ea9956ba40dbc1b0ac0c205) - thanks <a
href="https://github.com/Cayan"><code>@​Cayan</code></a>!</li>
<li>fix(next): detect Turbopack loaders in shared config factories (<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/2161">#2161</a>)
(9cf928be5022928a38c12fe8001cec7bf467040c) - thanks <a
href="https://github.com/jakeleventhal"><code>@​jakeleventhal</code></a>!</li>
<li>Ignore osascript as global binary (<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/2182">#2182</a>)
(c301e44c7975442d4d25595f084e6b853f07a434) - thanks <a
href="https://github.com/abrahamguo"><code>@​abrahamguo</code></a>!</li>
<li>Skip <code>--fix</code> for exports in multi-declarator variable
statements (<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/2178">#2178</a>)
(326afd9c1e66eef04d986b83ed829b506f506c48) - thanks <a
href="https://github.com/mkantautas"><code>@​mkantautas</code></a>!</li>
<li>Watch the project directory instead of process.cwd() (<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/2183">#2183</a>)
(16d9932d0239f649cf2c1349cc7bc893850cd4f0) - thanks <a
href="https://github.com/abrahamguo"><code>@​abrahamguo</code></a>!</li>
<li>Follow directory paths in tsconfig <code>references</code> (<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/2186">#2186</a>)
(d741c46e3f2a9caaf6bf4e81e9d4c8cfca48623e) - thanks <a
href="https://github.com/mkantautas"><code>@​mkantautas</code></a>!</li>
<li>Handle <code>@plugin</code> options blocks in Tailwind CSS compiler
(<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/2187">#2187</a>)
(9fae773eb75bcba03e62f44ce3e9aa0b889bf4ac) - thanks <a
href="https://github.com/mkantautas"><code>@​mkantautas</code></a>!</li>
<li>Remove anonymous default-exported functions and classes in
<code>--fix</code> (<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/2188">#2188</a>)
(6748259fbb2ea9a5940ea3861626f77677610a86) - thanks <a
href="https://github.com/mkantautas"><code>@​mkantautas</code></a>!</li>
<li>Recognize bun fmt as a built-in command (<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/2189">#2189</a>)
(e971b0924bcb7ff8dcab01a180d3016cc08b675d) - thanks <a
href="https://github.com/TomasHubelbauer"><code>@​TomasHubelbauer</code></a>!</li>
<li>Add plugin for <code>@​bufbuild/buf</code> (<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/2142">#2142</a>)
(90216c261c2273967125669beb611bf763d762c7) - thanks <a
href="https://github.com/ryanquinn3"><code>@​ryanquinn3</code></a>!</li>
<li>Pass <code>isSsrBuild</code> to Vite config functions (<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/2191">#2191</a>)
(d2aa101c55e5d7e8d26548808ce008b214622099) - thanks <a
href="https://github.com/mkantautas"><code>@​mkantautas</code></a>!</li>
<li>Follow <code>src</code> of <code>&lt;script&gt;</code> and
<code>&lt;style&gt;</code> blocks in Vue SFCs (<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/2193">#2193</a>)
(3d148393d5129a096a182fab36a873b9f710f388) - thanks <a
href="https://github.com/mkantautas"><code>@​mkantautas</code></a>!</li>
<li>Remove import attributes with emptied re-exports in
<code>--fix</code> (<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/2192">#2192</a>)
(231026cf9c21cc7890f21d048f4af6ea7233d7b8) - thanks <a
href="https://github.com/mkantautas"><code>@​mkantautas</code></a>!</li>
<li>Update npmx.dev snapshot
(b2cc439006ec98ff5bbc1c99b99bc1ac5203b6b2)</li>
<li>Read string <code>parserOptions.parser</code> in ESLint flat configs
(<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/2198">#2198</a>)
(302f908aa135669d6fe96869ec6452b7570856a4) - thanks <a
href="https://github.com/mkantautas"><code>@​mkantautas</code></a>!</li>
<li>Resolve directory matches in Vitest <code>test.projects</code> to
their config (<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/2195">#2195</a>)
(4d386138011c27e01e6e910be6d58266e07c9cf1) - thanks <a
href="https://github.com/fixfon"><code>@​fixfon</code></a>!</li>
<li>Ignore custom elements named <code>script-*</code> when extracting
script blocks (<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/2199">#2199</a>)
(23e6479b233e1882ddaf29736390309d0f3f040f) - thanks <a
href="https://github.com/mkantautas"><code>@​mkantautas</code></a>!</li>
<li>feat(mcp): add MCP plugin (<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/2180">#2180</a>)
(fc92470a9f687e2f1da60b84612ebf3e59a8bbb9) - thanks <a
href="https://github.com/jakeleventhal"><code>@​jakeleventhal</code></a>!</li>
<li>Handle non-Error plugin config failures
(8f21ea2182fac5a96edd7d6d4280a0fa1051e434)</li>
<li>Update articles list (6569cdcde1a79a6196a35a934b6c0d9d0e23faa4)</li>
<li>Skip <code>--fix</code> export ranges inside a larger removal
(204a8091baf91cb8cfb4e7a9aa70d461cc926baa)</li>
<li>Detect <code>.travis.yml</code> with a stat instead of a glob
(3601c6e32afef3d1d76a3944e540225da5190b9b)</li>
<li>Mark YAML catalog entries fixed only when their line is removed (<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/2201">#2201</a>)
(813fa30e55ef3a8ed749d9d6d5a02a00810cfe72) - thanks <a
href="https://github.com/mkantautas"><code>@​mkantautas</code></a>!</li>
<li>Add Electron Forge plugin (<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/2158">#2158</a>)
(12e4355a43ea63c417d3b9ebfbbac1c8510b139e) - thanks <a
href="https://github.com/mehdimirzafaruk"><code>@​mehdimirzafaruk</code></a>!</li>
<li>Resolve function and promise entries in Vitest
<code>test.projects</code> (<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/2197">#2197</a>)
(ec293d1a33d30a8b0521725d1148e6e72c6e4220) - thanks <a
href="https://github.com/mkantautas"><code>@​mkantautas</code></a>!</li>
<li>Follow npm lifecycle scripts and run-script aliases (<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/2202">#2202</a>)
(89dfdc881e6865aef326c2b9dc700cf5c46b73b4) - thanks <a
href="https://github.com/sushanta-neupane"><code>@​sushanta-neupane</code></a>!</li>
<li>Follow pnpm <code>t</code>, <code>tst</code> and
<code>run-script</code> aliases
(ce91e230e0823433dfcc1ee4562335c34d2b73ef)</li>
<li>Treat <code>vitest-environment-nuxt</code> as optional (it comes
with <code>@nuxt/test-utils</code>)
(db9aa7443c5b3839df5c0fe320d78ea42f0f3446)</li>
<li>Ignore tool output before the first report section in ecosystem
snapshots (9434bf678e60d16832f13e892d7c1ebc2b9e6ed1)</li>
<li>Update dependencies (21c3b75fc4ae94208311abb630ff129fcd9fe820)</li>
<li>Bump <code>engines.node</code> to follow Astro's
(9d7a5e07212b093f9d422cab813fad5d01273fc6)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/webpro-nl/knip/commit/07121567e60b73d1e640f395028c525a2b1d9778"><code>0712156</code></a>
Release knip@6.43.0</li>
<li><a
href="https://github.com/webpro-nl/knip/commit/21c3b75fc4ae94208311abb630ff129fcd9fe820"><code>21c3b75</code></a>
Update dependencies</li>
<li><a
href="https://github.com/webpro-nl/knip/commit/db9aa7443c5b3839df5c0fe320d78ea42f0f3446"><code>db9aa74</code></a>
Treat <code>vitest-environment-nuxt</code> as optional (it comes with
<code>@nuxt/test-utils</code>)</li>
<li><a
href="https://github.com/webpro-nl/knip/commit/ce91e230e0823433dfcc1ee4562335c34d2b73ef"><code>ce91e23</code></a>
Follow pnpm <code>t</code>, <code>tst</code> and <code>run-script</code>
aliases</li>
<li><a
href="https://github.com/webpro-nl/knip/commit/89dfdc881e6865aef326c2b9dc700cf5c46b73b4"><code>89dfdc8</code></a>
Follow npm lifecycle scripts and run-script aliases (<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/2202">#2202</a>)</li>
<li><a
href="https://github.com/webpro-nl/knip/commit/ec293d1a33d30a8b0521725d1148e6e72c6e4220"><code>ec293d1</code></a>
Resolve function and promise entries in Vitest
<code>test.projects</code> (<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/2197">#2197</a>)</li>
<li><a
href="https://github.com/webpro-nl/knip/commit/12e4355a43ea63c417d3b9ebfbbac1c8510b139e"><code>12e4355</code></a>
Add Electron Forge plugin (<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/2158">#2158</a>)</li>
<li><a
href="https://github.com/webpro-nl/knip/commit/813fa30e55ef3a8ed749d9d6d5a02a00810cfe72"><code>813fa30</code></a>
Mark YAML catalog entries fixed only when their line is removed (<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/2201">#2201</a>)</li>
<li><a
href="https://github.com/webpro-nl/knip/commit/3601c6e32afef3d1d76a3944e540225da5190b9b"><code>3601c6e</code></a>
Detect <code>.travis.yml</code> with a stat instead of a glob</li>
<li><a
href="https://github.com/webpro-nl/knip/commit/204a8091baf91cb8cfb4e7a9aa70d461cc926baa"><code>204a809</code></a>
Skip <code>--fix</code> export ranges inside a larger removal</li>
<li>Additional commits viewable in <a
href="https://github.com/webpro-nl/knip/commits/knip@6.43.0/packages/knip">compare
view</a></li>
</ul>
</details>
<br />

Updates `oxfmt` from 0.74.0 to 0.75.0
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/oxc-project/oxc/releases">oxfmt's
releases</a>.</em></p>
<blockquote>
<h2>oxfmt v0.75.0</h2>
<h3>🐛 Bug Fixes</h3>
<ul>
<li>5042019 formatter: Keep fill layout for array with comment after [
(<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt/issues/28170">#28170</a>)
(leaysgur)</li>
<li>9d37ff6 formatter: Use position-based comment checks for concise
arrays (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt/issues/28169">#28169</a>)
(leaysgur)</li>
<li>016c4a2 formatter: Keep a comment before <code>}</code> inside a JSX
spread child (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt/issues/28103">#28103</a>)
(vidigoat)</li>
<li>190bb1e formatter: Keep parens around an instantiation expression
ending an operand through any tight parent (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt/issues/28168">#28168</a>)
(leaysgur)</li>
<li>a5314a1 formatter: Keep <code>;</code> before <code>in</code> and
<code>instanceof</code> class members (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt/issues/28166">#28166</a>)
(leaysgur)</li>
<li>3a56e7c formatter: Keep closing HTML comments at the start of a line
(<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt/issues/28064">#28064</a>)
(Cameron)</li>
<li>23d23d6 formatter_markdown: Honor range ignore inside directives (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt/issues/28164">#28164</a>)
(leaysgur)</li>
<li>5f1fd3d formatter: Measure width heuristics by display width (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt/issues/28098">#28098</a>)
(leaysgur)</li>
<li>a5a52cf formatter: Keep parens around private <code>in</code>
expressions (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt/issues/28096">#28096</a>)
(leaysgur)</li>
<li>999496e formatter: Measure a short key by its display width (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt/issues/28026">#28026</a>)
(vidigoat)</li>
<li>7e9aa44 formatter: Keep parens around a looser operand of an
instantiation expression (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt/issues/28025">#28025</a>)
(vidigoat)</li>
<li>6912d1f formatter: Keep an arrow signature that exactly fills the
line for any body (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt/issues/27989">#27989</a>)
(leaysgur)</li>
<li>eb1d9f9 formatter_core: Count a flat <code>line</code> space in
<code>fits</code> only when text follows (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt/issues/27987">#27987</a>)
(leaysgur)</li>
</ul>
<h3>⚡ Performance</h3>
<ul>
<li>1ed93af data_structures: Copy short strings without a memcpy call
(<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt/issues/27953">#27953</a>)
(leaysgur)</li>
<li>7948f0b formatter_core: Shrink <code>FormatElement</code> to 16B (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt/issues/28012">#28012</a>)
(leaysgur)</li>
<li>788af22 formatter: Keep <code>AstNode</code>s on the stack, and only
chain links in the arena (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt/issues/27963">#27963</a>)
(leaysgur)</li>
</ul>
<h3>📚 Documentation</h3>
<ul>
<li>068a8ea formatter: Fix up wording (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt/issues/28167">#28167</a>)
(leaysgur)</li>
<li>db55461 formatter_core: Document text width divergence (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt/issues/28095">#28095</a>)
(leaysgur)</li>
<li>880d229 formatter: Add vitest-test-call to known divergences (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt/issues/28006">#28006</a>)
(leaysgur)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/oxc-project/oxc/commit/9b6fff42fa6edf0bec55861b6f0764c7b9acd86b"><code>9b6fff4</code></a>
release(apps): oxlint v1.90.0 &amp;&amp; oxfmt v0.75.0 (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt/issues/28191">#28191</a>)</li>
<li><a
href="https://github.com/oxc-project/oxc/commit/3e7f0f5b73dfa921167061578afcfea31645eca5"><code>3e7f0f5</code></a>
chore(oxfmt): bump prettier-plugin-astro to 1.1.2 (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt/issues/28162">#28162</a>)</li>
<li>See full diff in <a
href="https://github.com/oxc-project/oxc/commits/oxfmt_v0.75.0/npm/oxfmt">compare
view</a></li>
</ul>
</details>
<br />

Updates `oxlint` from 1.89.0 to 1.90.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.90.0</h2>
<h3>🚀 Features</h3>
<ul>
<li>cf34678 oxlint: Export <code>Oxlintrc</code> type (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/28132">#28132</a>)
(Sysix)</li>
<li>831cdf1 oxlint: Require mininum oxlint-tsgolint version v7.0.2005
(<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/28118">#28118</a>)
(camc314)</li>
<li>d1b00f4 linter/typescript: Implement no-unsafe-enum-assignment rule
(<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/28117">#28117</a>)
(camc314)</li>
<li>165c91f linter/typescript/await-thenable: Support
<code>allowMixedPromiseArrays</code> option (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/28116">#28116</a>)
(camc314)</li>
</ul>
<h3>🐛 Bug Fixes</h3>
<ul>
<li>7f3c908 linter/eslint/array-callback-return: Check callbacks passed
with extra arguments (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/28133">#28133</a>)
(sarankumar)</li>
<li>53be530 linter: Filter by type aware rules when looking up tsgolint
rule severity (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/28138">#28138</a>)
(NavpreetSSidhu)</li>
<li>e0f8937 react-compiler: Compile a <code>try</code> whose
<code>catch</code> is unreachable instead of panicking (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/28066">#28066</a>)
(Dunqing)</li>
<li>f5b625f linter/eslint/no-useless-concat: See through parenthesized
expressions (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/27984">#27984</a>)
(Rahul Kashyap)</li>
<li>4629bb2 linter/jest/no-conditional-expect: Check function expression
callbacks (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/28055">#28055</a>)
(camc314)</li>
<li>5c84098 semantic,linter: Report names with lone surrogates in enum
and lint checks (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/27991">#27991</a>)
(Dunqing)</li>
<li>f3705a9 linter/eslint/prefer-const: Skip fixers if any declarator is
reassigned (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/28008">#28008</a>)
(Prince Jain)</li>
<li>4c0fa90 minifier,linter: Keep <code>delete</code> of constant
members and later JSX keys (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/27990">#27990</a>)
(Dunqing)</li>
<li>0298062 linter/arrow-body-style: Keep type assertions when adding a
block body (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/27978">#27978</a>)
(Yan Almeida)</li>
</ul>
</blockquote>
</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.90.0] - 2026-10-27</h2>
<h3>🚀 Features</h3>
<ul>
<li>831cdf1 oxlint: Require mininum oxlint-tsgolint version v7.0.2005
(<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/28118">#28118</a>)
(camc314)</li>
<li>d1b00f4 linter/typescript: Implement no-unsafe-enum-assignment rule
(<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/28117">#28117</a>)
(camc314)</li>
<li>165c91f linter/typescript/await-thenable: Support
<code>allowMixedPromiseArrays</code> option (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/28116">#28116</a>)
(camc314)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/oxc-project/oxc/commit/9b6fff42fa6edf0bec55861b6f0764c7b9acd86b"><code>9b6fff4</code></a>
release(apps): oxlint v1.90.0 &amp;&amp; oxfmt v0.75.0 (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/28191">#28191</a>)</li>
<li><a
href="https://github.com/oxc-project/oxc/commit/831cdf19d4070d39128e771a926562280911e490"><code>831cdf1</code></a>
feat(oxlint): require mininum oxlint-tsgolint version v7.0.2005 (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/28118">#28118</a>)</li>
<li><a
href="https://github.com/oxc-project/oxc/commit/d1b00f45ec3e44ed2ff6815ee8597615e532e681"><code>d1b00f4</code></a>
feat(linter/typescript): implement no-unsafe-enum-assignment rule (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/28117">#28117</a>)</li>
<li><a
href="https://github.com/oxc-project/oxc/commit/165c91f9c1021a4fac3da5fe6f794ee627a0d988"><code>165c91f</code></a>
feat(linter/typescript/await-thenable): support
<code>allowMixedPromiseArrays</code> opt...</li>
<li>See full diff in <a
href="https://github.com/oxc-project/oxc/commits/oxlint_v1.90.0/npm/oxlint">compare
view</a></li>
</ul>
</details>
<br />

Updates `oxlint-tsgolint` from 7.0.2004 to 7.0.2005
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/oxc-project/tsgolint/releases">oxlint-tsgolint's
releases</a>.</em></p>
<blockquote>
<h2>v7.0.2005</h2>
<h2>What's Changed</h2>
<ul>
<li>chore(deps): update github actions by <a
href="https://github.com/renovate"><code>@​renovate</code></a>[bot] in
<a
href="https://redirect.github.com/oxc-project/tsgolint/pull/1273">oxc-project/tsgolint#1273</a></li>
<li>chore(deps): update gomod by <a
href="https://github.com/renovate"><code>@​renovate</code></a>[bot] in
<a
href="https://redirect.github.com/oxc-project/tsgolint/pull/1274">oxc-project/tsgolint#1274</a></li>
<li>chore(deps): update npm packages by <a
href="https://github.com/renovate"><code>@​renovate</code></a>[bot] in
<a
href="https://redirect.github.com/oxc-project/tsgolint/pull/1275">oxc-project/tsgolint#1275</a></li>
<li>chore(deps): update oxc-project/security-action action to v1.0.9 by
<a href="https://github.com/renovate"><code>@​renovate</code></a>[bot]
in <a
href="https://redirect.github.com/oxc-project/tsgolint/pull/1276">oxc-project/tsgolint#1276</a></li>
<li>fix(unbound-method): respect <code>this: void</code> on class
properties by <a
href="https://github.com/baevm"><code>@​baevm</code></a> in <a
href="https://redirect.github.com/oxc-project/tsgolint/pull/1277">oxc-project/tsgolint#1277</a></li>
<li>docs(security): inherit organization security policy by <a
href="https://github.com/Boshen"><code>@​Boshen</code></a> in <a
href="https://redirect.github.com/oxc-project/tsgolint/pull/1295">oxc-project/tsgolint#1295</a></li>
<li>chore(deps): update npm packages by <a
href="https://github.com/renovate"><code>@​renovate</code></a>[bot] in
<a
href="https://redirect.github.com/oxc-project/tsgolint/pull/1301">oxc-project/tsgolint#1301</a></li>
<li>fix(no-deprecated): avoid infinite loop on circular alias chains by
<a href="https://github.com/magurotuna"><code>@​magurotuna</code></a> in
<a
href="https://redirect.github.com/oxc-project/tsgolint/pull/1309">oxc-project/tsgolint#1309</a></li>
<li>feat(await-thenable): add allowMixedPromiseArrays option by <a
href="https://github.com/camc314"><code>@​camc314</code></a> in <a
href="https://redirect.github.com/oxc-project/tsgolint/pull/1307">oxc-project/tsgolint#1307</a></li>
<li>chore: remove <code>update-typescript-go.yml</code> by <a
href="https://github.com/camc314"><code>@​camc314</code></a> in <a
href="https://redirect.github.com/oxc-project/tsgolint/pull/1311">oxc-project/tsgolint#1311</a></li>
<li>ci: pin just version in setup and autofix by <a
href="https://github.com/camc314"><code>@​camc314</code></a> in <a
href="https://redirect.github.com/oxc-project/tsgolint/pull/1312">oxc-project/tsgolint#1312</a></li>
<li>fix(no-unnecessary-type-parameters): count generic type arguments by
<a href="https://github.com/farnoodma"><code>@​farnoodma</code></a> in
<a
href="https://redirect.github.com/oxc-project/tsgolint/pull/1297">oxc-project/tsgolint#1297</a></li>
<li>feat(no-unsafe-enum-assignment): add rule by <a
href="https://github.com/camc314"><code>@​camc314</code></a> in <a
href="https://redirect.github.com/oxc-project/tsgolint/pull/1316">oxc-project/tsgolint#1316</a></li>
<li>fix(require-array-sort-compare): report tuples by <a
href="https://github.com/baevm"><code>@​baevm</code></a> in <a
href="https://redirect.github.com/oxc-project/tsgolint/pull/1317">oxc-project/tsgolint#1317</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a
href="https://github.com/magurotuna"><code>@​magurotuna</code></a> made
their first contribution in <a
href="https://redirect.github.com/oxc-project/tsgolint/pull/1309">oxc-project/tsgolint#1309</a></li>
<li><a href="https://github.com/farnoodma"><code>@​farnoodma</code></a>
made their first contribution in <a
href="https://redirect.github.com/oxc-project/tsgolint/pull/1297">oxc-project/tsgolint#1297</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/oxc-project/tsgolint/compare/v7.0.2004...v7.0.2005">https://github.com/oxc-project/tsgolint/compare/v7.0.2004...v7.0.2005</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/oxc-project/tsgolint/commit/4ebf80fd2b166cba4890c6896220fec4a1462b29"><code>4ebf80f</code></a>
fix(require-array-sort-compare): report tuples (<a
href="https://redirect.github.com/oxc-project/tsgolint/issues/1317">#1317</a>)</li>
<li><a
href="https://github.com/oxc-project/tsgolint/commit/62e97c915725ca2263508dc91639669609bd4360"><code>62e97c9</code></a>
feat(no-unsafe-enum-assignment): add rule (<a
href="https://redirect.github.com/oxc-project/tsgolint/issues/1316">#1316</a>)</li>
<li><a
href="https://github.com/oxc-project/tsgolint/commit/e649c1ef6384513cb481a928218da8e5488ece22"><code>e649c1e</code></a>
fix(no-unnecessary-type-parameters): count generic type arguments (<a
href="https://redirect.github.com/oxc-project/tsgolint/issues/1297">#1297</a>)</li>
<li><a
href="https://github.com/oxc-project/tsgolint/commit/e3337870400c17398f56a545c29470015eb033d1"><code>e333787</code></a>
ci: pin just version in setup and autofix (<a
href="https://redirect.github.com/oxc-project/tsgolint/issues/1312">#1312</a>)</li>
<li><a
href="https://github.com/oxc-project/tsgolint/commit/bcab203a3b22c1701580b84f69ecb49fa3d45ed3"><code>bcab203</code></a>
chore: remove <code>update-typescript-go.yml</code> (<a
href="https://redirect.github.com/oxc-project/tsgolint/issues/1311">#1311</a>)</li>
<li><a
href="https://github.com/oxc-project/tsgolint/commit/bb8225c110f9288c92ed92881337c43a48fe1b11"><code>bb8225c</code></a>
feat(await-thenable): add allowMixedPromiseArrays option (<a
href="https://redirect.github.com/oxc-project/tsgolint/issues/1307">#1307</a>)</li>
<li><a
href="https://github.com/oxc-project/tsgolint/commit/086f19a14d1a8184c3607027e1dfb5c0e640e48f"><code>086f19a</code></a>
fix(no-deprecated): avoid infinite loop on circular alias chains (<a
href="https://redirect.github.com/oxc-project/tsgolint/issues/1309">#1309</a>)</li>
<li><a
href="https://github.com/oxc-project/tsgolint/commit/a18037134a44ca331e0e2451e50b2dd2e374acb3"><code>a180371</code></a>
chore(deps): update npm packages (<a
href="https://redirect.github.com/oxc-project/tsgolint/issues/1301">#1301</a>)</li>
<li><a
href="https://github.com/oxc-project/tsgolint/commit/8897346fff24a9bd0c719099dc1af8e7200572c1"><code>8897346</code></a>
docs(security): inherit organization security policy (<a
href="https://redirect.github.com/oxc-project/tsgolint/issues/1295">#1295</a>)</li>
<li><a
href="https://github.com/oxc-project/tsgolint/commit/69698fe8ebe234009db8a207cc84f1722c7bffb8"><code>69698fe</code></a>
fix(unbound-method): respect <code>this: void</code> on class properties
(<a
href="https://redirect.github.com/oxc-project/tsgolint/issues/1277">#1277</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/oxc-project/tsgolint/compare/v7.0.2004...v7.0.2005">compare
view</a></li>
</ul>
</details>
<br />

Updates `vite` from 8.3.4 to 8.3.5
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/vitejs/vite/releases">vite's
releases</a>.</em></p>
<blockquote>
<h2>v8.3.5</h2>
<h3>Bug Fixes</h3>
<ul>
<li><strong>build:</strong> handle missing chunk import maps (<a
href="https://redirect.github.com/vitejs/vite/issues/23777">#23777</a>)
(<a
href="https://github.com/vitejs/vite/commit/8d7623d9b5647714a5b20cbd787a5401ca846229">8d7623d</a>)</li>
<li><strong>build:</strong> include preload dependencies to chunk import
map hashing (<a
href="https://redirect.github.com/vitejs/vite/issues/23793">#23793</a>)
(<a
href="https://github.com/vitejs/vite/commit/4d16e7c14d5e1c361f148238b1e81131238ab8e9">4d16e7c</a>)</li>
<li><strong>bundled-dev:</strong> apply <code>server.origin</code> to
asset URLs (<a
href="https://redirect.github.com/vitejs/vite/issues/23683">#23683</a>)
(<a
href="https://github.com/vitejs/vite/commit/52ba877693c8633458d163117504a8259beb3cf5">52ba877</a>)</li>
<li><strong>bundled-dev:</strong> declare utf-8 charset in fallback page
(<a
href="https://redirect.github.com/vitejs/vite/issues/23846">#23846</a>)
(<a
href="https://github.com/vitejs/vite/commit/197daf6c4c7bfdaa005bebcc41dd2ff22fa3a5b6">197daf6</a>)</li>
<li><strong>bundled-dev:</strong> ignore build.chunkImportMap (<a
href="https://redirect.github.com/vitejs/vite/issues/23685">#23685</a>)
(<a
href="https://github.com/vitejs/vite/commit/cfe917d67d279a13f20ae9449e3befa736b64b07">cfe917d</a>)</li>
<li><strong>bundled-dev:</strong> include user
<code>import.meta.env.*</code> defines in the
<code>import.meta.env</code> object (<a
href="https://redirect.github.com/vitejs/vite/issues/23713">#23713</a>)
(<a
href="https://github.com/vitejs/vite/commit/6019aa79975465516da7c6f532d568422b019a25">6019aa7</a>)</li>
<li><strong>bundled-dev:</strong> map forwarded error stacks through the
bundle source maps (<a
href="https://redirect.github.com/vitejs/vite/issues/23741">#23741</a>)
(<a
href="https://github.com/vitejs/vite/commit/11fe1e46ff0c136cb90fbe263ba03993bc94df18">11fe1e4</a>)</li>
<li><strong>bundled-dev:</strong> normalize served output files (<a
href="https://redirect.github.com/vitejs/vite/issues/23684">#23684</a>)
(<a
href="https://github.com/vitejs/vite/commit/df4e02cc8317a62f21edefc79a9690571ffed1f3">df4e02c</a>)</li>
<li><strong>bundled-dev:</strong> stop hmr propagation at modules that
are no longer imported (<a
href="https://redirect.github.com/vitejs/vite/issues/23839">#23839</a>)
(<a
href="https://github.com/vitejs/vite/commit/7471e8c3b32d3d55243590bb7713e602c9fb0212">7471e8c</a>)</li>
<li><strong>css:</strong> don't fail on a literal <code>%</code> in
<code>url()</code> (<a
href="https://redirect.github.com/vitejs/vite/issues/23676">#23676</a>)
(<a
href="https://github.com/vitejs/vite/commit/50b382d31835b8b4bc8b6964738ac03146e44261">50b382d</a>)</li>
<li><strong>deps:</strong> update all non-major dependencies (<a
href="https://redirect.github.com/vitejs/vite/issues/23731">#23731</a>)
(<a
href="https://github.com/vitejs/vite/commit/cf80aa552cac02eac0293330ccd5fde3c7ae8d29">cf80aa5</a>)</li>
<li><strong>deps:</strong> update all non-major dependencies (<a
href="https://redirect.github.com/vitejs/vite/issues/23826">#23826</a>)
(<a
href="https://github.com/vitejs/vite/commit/77d8373510da1b26ce057eb2248e570b7f63fde9">77d8373</a>)</li>
<li><strong>deps:</strong> update rolldown-related dependencies (<a
href="https://redirect.github.com/vitejs/vite/issues/23733">#23733</a>)
(<a
href="https://github.com/vitejs/vite/commit/43f0d052e514c3ad2dea2f7dc44b0720e1f62f48">43f0d05</a>)</li>
<li><strong>deps:</strong> update rolldown-related dependencies (<a
href="https://redirect.github.com/vitejs/vite/issues/23827">#23827</a>)
(<a
href="https://github.com/vitejs/vite/commit/c2e04187e49aab7ab809989f0537b4906653acab">c2e0418</a>)</li>
<li><strong>optimizer:</strong> fall back to yarn.lock for lockfile hash
(<a
href="https://redirect.github.com/vitejs/vite/issues/23667">#23667</a>)
(<a
href="https://github.com/vitejs/vite/commit/f20609630faa93026924275115d1426fdc08629e">f206096</a>)</li>
<li><strong>optimizer:</strong> scan <code>oxc.jsxInject</code> imports
(<a
href="https://redirect.github.com/vitejs/vite/issues/23758">#23758</a>)
(<a
href="https://github.com/vitejs/vite/commit/38f3cb45b1490ed24775547ceafa677b91a57714">38f3cb4</a>)</li>
<li><strong>optimizer:</strong> skip patches dir check for yarn.lock
fallback (<a
href="https://redirect.github.com/vitejs/vite/issues/23725">#23725</a>)
(<a
href="https://github.com/vitejs/vite/commit/8e890e1ce438bfbc07ea4931ce332e40589940fa">8e890e1</a>)</li>
<li><strong>optimizer:</strong> skip patches dir hash for nub lockfile
(<a
href="https://redirect.github.com/vitejs/vite/issues/23761">#23761</a>)
(<a
href="https://github.com/vitejs/vite/commit/a7d56967ef0050a09c0507e63a18164bc7a0d486">a7d5696</a>)</li>
<li><strong>resolve:</strong> support subpath imports with multiple
<code>#</code> (<a
href="https://redirect.github.com/vitejs/vite/issues/23631">#23631</a>)
(<a
href="https://github.com/vitejs/vite/commit/0d86b3485fbbd0fcb9a1f70a14adedfcb7a1fff1">0d86b34</a>)</li>
<li><strong>resolve:</strong> update resolve.exports to 2.0.4 (<a
href="https://redirect.github.com/vitejs/vite/issues/23739">#23739</a>)
(<a
href="https://github.com/vitejs/vite/commit/4ea708a3ae9a324c6aabca59fade4082bf72f3e5">4ea708a</a>)</li>
<li><strong>server:</strong> handle socket errors on upgrade requests
not taken by the HMR server (<a
href="https://redirect.github.com/vitejs/vite/issues/23728">#23728</a>)
(<a
href="https://github.com/vitejs/vite/commit/c2c9ef4dcd05b3e4d88844ea3a13dc23819ca2a5">c2c9ef4</a>)</li>
<li><strong>server:</strong> only strip base when path matches a full
segment (<a
href="https://redirect.github.com/vitejs/vite/issues/23641">#23641</a>)
(<a
href="https://github.com/vitejs/vite/commit/3dfde87b90a9f80b125d710e3e5d4fc795662db6">3dfde87</a>)</li>
</ul>
<h3>Performance Improvements</h3>
<ul>
<li><strong>ssr:</strong> extract inline source maps without a regexp
(<a
href="https://redirect.github.com/vitejs/vite/issues/23775">#23775</a>)
(<a
href="https://github.com/vitejs/vite/commit/6a0278055d8501bbaf65b8d34ec094722b4e877a">6a02780</a>)</li>
</ul>
<h3>Miscellaneous Chores</h3>
<ul>
<li>migrate to Oxlint (<a
href="https://redirect.github.com/vitejs/vite/issues/23746">#23746</a>)
(<a
href="https://github.com/vitejs/vite/commit/5b73935881424ed5919571036dd89d3868590f92">5b73935</a>)</li>
</ul>
<h3>Code Refactoring</h3>
<ul>
<li><strong>deps:</strong> replace <code>@vercel/detect-agent</code>
with <code>std-env</code> (<a
href="https://redirect.github.com/vitejs/vite/issues/23744">#23744</a>)
(<a
href="https://github.com/vitejs/vite/commit/ce4293f15cc5cd221c5ed60f5cd64e82ca5cbaf5">ce4293f</a>)</li>
<li><strong>dev:</strong> consolidate moduleURL to file path utilities
(<a
href="https://redirect.github.com/vitejs/vite/issues/23828">#23828</a>)
(<a
href="https://github.com/vitejs/vite/commit/df7b40ee9d691e9f4e2413244f6665ef5482dce3">df7b40e</a>)</li>
<li><strong>dev:</strong> separate import resolution from URL conversion
(<a
href="https://redirect.github.com/vitejs/vite/issues/23791">#23791</a>)
(<a
href="https://github.com/vitejs/vite/commit/611668983d4b717f5e5e66cf9f7125bd94f92bf7">6116689</a>)</li>
<li><strong>dev:</strong> share ModuleURL and BrowserURL conversions (<a
href="https://redirect.github.com/vitejs/vite/issues/23790">#23790</a>)
(<a
href="https://github.com/vitejs/vite/commit/446954c69c806795c83fe2f97c28ae8f9ef83731">446954c</a>)</li>
<li><strong>dev:</strong> use ModuleURLs throughout HTML transforms (<a
href="https://redirect.github.com/vitejs/vite/issues/23815">#23815</a>)
(<a
href="https://github.com/vitejs/vite/commit/a16299092c0f0f3f90e05d4bb5f0168b293c2b52">a162990</a>)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md">vite's
changelog</a>.</em></p>
<blockquote>
<h2><!-- raw HTML omitted --><a
href="https://github.com/vitejs/vite/compare/v8.3.4...v8.3.5">8.3.5</a>
(2026-10-21)<!-- raw HTML omitted --></h2>
<h3>Bug Fixes</h3>
<ul>
<li><strong>build:</strong> handle missing chunk import maps (<a
href="https://redirect.github.com/vitejs/vite/issues/23777">#23777</a>)
(<a
href="https://github.com/vitejs/vite/commit/8d7623d9b5647714a5b20cbd787a5401ca846229">8d7623d</a>)</li>
<li><strong>build:</strong> include preload dependencies to chunk import
map hashing (<a
href="https://redirect.github.com/vitejs/vite/issues/23793">#23793</a>)
(<a
href="https://github.com/vitejs/vite/commit/4d16e7c14d5e1c361f148238b1e81131238ab8e9">4d16e7c</a>)</li>
<li><strong>bundled-dev:</strong> apply <code>server.origin</code> to
asset URLs (<a
href="https://redirect.github.com/vitejs/vite/issues/23683">#23683</a>)
(<a
href="https://github.com/vitejs/vite/commit/52ba877693c8633458d163117504a8259beb3cf5">52ba877</a>)</li>
<li><strong>bundled-dev:</strong> declare utf-8 charset in fallback page
(<a
href="https://redirect.github.com/vitejs/vite/issues/23846">#23846</a>)
(<a
href="https://github.com/vitejs/vite/commit/197daf6c4c7bfdaa005bebcc41dd2ff22fa3a5b6">197daf6</a>)</li>
<li><strong>bundled-dev:</strong> ignore build.chunkImportMap (<a
href="https://redirect.github.com/vitejs/vite/issues/23685">#23685</a>)
(<a
href="https://github.com/vitejs/vite/commit/cfe917d67d279a13f20ae9449e3befa736b64b07">cfe917d</a>)</li>
<li><strong>bundled-dev:</strong> include user
<code>import.meta.env.*</code> defines in the
<code>import.meta.env</code> object (<a
href="https://redirect.github.com/vitejs/vite/issues/23713">#23713</a>)
(<a
href="https://github.com/vitejs/vite/commit/6019aa79975465516da7c6f532d568422b019a25">6019aa7</a>)</li>
<li><strong>bundled-dev:</strong> map forwarded error stacks through the
bundle source maps (<a
href="https://redirect.github.com/vitejs/vite/issues/23741">#23741</a>)
(<a
href="https://github.com/vitejs/vite/commit/11fe1e46ff0c136cb90fbe263ba03993bc94df18">11fe1e4</a>)</li>
<li><strong>bundled-dev:</strong> normalize served output files (<a
href="https://redirect.github.com/vitejs/vite/issues/23684">#23684</a>)
(<a
href="https://github.com/vitejs/vite/commit/df4e02cc8317a62f21edefc79a9690571ffed1f3">df4e02c</a>)</li>
<li><strong>bundled-dev:</strong> stop hmr propagation at modules that
are no longer imported (<a
href="https://redirect.github.com/vitejs/vite/issues/23839">#23839</a>)
(<a
href="https://github.com/vitejs/vite/commit/7471e8c3b32d3d55243590bb7713e602c9fb0212">7471e8c</a>)</li>
<li><strong>css:</strong> don't fail on a literal <code>%</code> in
<code>url()</code> (<a
href="https://redirect.github.com/vitejs/vite/issues/23676">#23676</a>)
(<a
href="https://github.com/vitejs/vite/commit/50b382d31835b8b4bc8b6964738ac03146e44261">50b382d</a>)</li>
<li><strong>deps:</strong> update all non-major dependencies (<a
href="https://redirect.github.com/vitejs/vite/issues/23731">#23731</a>)
(<a
href="https://github.com/vitejs/vite/commit/cf80aa552cac02eac0293330ccd5fde3c7ae8d29">cf80aa5</a>)</li>
<li><strong>deps:</strong> update all non-major dependencies (<a
href="https://redirect.github.com/vitejs/vite/issues/23826">#23826</a>)
(<a
href="https://github.com/vitejs/vite/commit/77d8373510da1b26ce057eb2248e570b7f63fde9">77d8373</a>)</li>
<li><strong>deps:</strong> update rolldown-related dependencies (<a
href="https://redirect.github.com/vitejs/vite/issues/23733">#23733</a>)
(<a
href="https://github.com/vitejs/vite/commit/43f0d052e514c3ad2dea2f7dc44b0720e1f62f48">43f0d05</a>)</li>
<li><strong>deps:</strong> update rolldown-related dependencies (<a
href="https://redirect.github.com/vitejs/vite/issues/23827">#23827</a>)
(<a
href="https://github.com/vitejs/vite/commit/c2e04187e49aab7ab809989f0537b4906653acab">c2e0418</a>)</li>
<li><strong>optimizer:</strong> fall back to yarn.lock for lockfile hash
(<a
href="https://redirect.github.com/vitejs/vite/issues/23667">#23667</a>)
(<a
href="https://github.com/vitejs/vite/commit/f20609630faa93026924275115d1426fdc08629e">f206096</a>)</li>
<li><strong>optimizer:</strong> scan <code>oxc.jsxInject</code> imports
(<a
href="https://redirect.github.com/vitejs/vite/issues/23758">#23758</a>)
(<a
href="https://github.com/vitejs/vite/commit/38f3cb45b1490ed24775547ceafa677b91a57714">38f3cb4</a>)</li>
<li><strong>optimizer:</strong> skip patches dir check for yarn.lock
fallback (<a
href="https://redirect.github.com/vitejs/vite/issues/23725">#23725</a>)
(<a
href="https://github.com/vitejs/vite/commit/8e890e1ce438bfbc07ea4931ce332e40589940fa">8e890e1</a>)</li>
<li><strong>optimizer:</strong> skip patches dir hash for nub lockfile
(<a
href="https://redirect.github.com/vitejs/vite/issues/23761">#23761</a>)
(<a
href="https://github.com/vitejs/vite/commit/a7d56967ef0050a09c0507e63a18164bc7a0d486">a7d5696</a>)</li>
<li><strong>resolve:</strong> support subpath imports with multiple
<code>#</code> (<a
href="https://redirect.github.com/vitejs/vite/issues/23631">#23631</a>)
(<a
href="https://github.com/vitejs/vite/commit/0d86b3485fbbd0fcb9a1f70a14adedfcb7a1fff1">0d86b34</a>)</li>
<li><strong>resolve:</strong> update resolve.exports to 2.0.4 (<a
href="https://redirect.github.com/vitejs/vite/issues/23739">#23739</a>)
(<a
href="https://github.com/vitejs/vite/commit/4ea708a3ae9a324c6aabca59fade4082bf72f3e5">4ea708a</a>)</li>
<li><strong>server:</strong> handle socket errors on upgrade requests
not taken by the HMR server (<a
href="https://redirect.github.com/vitejs/vite/issues/23728">#23728</a>)
(<a
href="https://github.com/vitejs/vite/commit/c2c9ef4dcd05b3e4d88844ea3a13dc23819ca2a5">c2c9ef4</a>)</li>
<li><strong>server:</strong> only strip base when path matches a full
segment (<a
href="https://redirect.github.com/vitejs/vite/issues/23641">#23641</a>)
(<a
href="https://github.com/vitejs/vite/commit/3dfde87b90a9f80b125d710e3e5d4fc795662db6">3dfde87</a>)</li>
</ul>
<h3>Performance Improvements</h3>
<ul>
<li><strong>ssr:</strong> extract inline source maps without a regexp
(<a
href="https://redirect.github.com/vitejs/vite/issues/23775">#23775</a>)
(<a
href="https://github.com/vitejs/vite/commit/6a0278055d8501bbaf65b8d34ec094722b4e877a">6a02780</a>)</li>
</ul>
<h3>Miscellaneous Chores</h3>
<ul>
<li>migrate to Oxlint (<a
href="https://redirect.github.com/vitejs/vite/issues/23746">#23746</a>)
(<a
href="https://github.com/vitejs/vite/commit/5b73935881424ed5919571036dd89d3868590f92">5b73935</a>)</li>
</ul>
<h3>Code Refactoring</h3>
<ul>
<li><strong>deps:</strong> replace <code>@vercel/detect-agent</code>
with <code>std-env</code> (<a
href="https://redirect.github.com/vitejs/vite/issues/23744">#23744</a>)
(<a
href="https://github.com/vitejs/vite/commit/ce4293f15cc5cd221c5ed60f5cd64e82ca5cbaf5">ce4293f</a>)</li>
<li><strong>dev:</strong> consolidate moduleURL to file path utilities
(<a
href="https://redirect.github.com/vitejs/vite/issues/23828">#23828</a>)
(<a
href="https://github.com/vitejs/vite/commit/df7b40ee9d691e9f4e2413244f6665ef5482dce3">df7b40e</a>)</li>
<li><strong>dev:</strong> separate import resolution from URL conversion
(<a
href="https://redirect.github.com/vitejs/vite/issues/23791">#23791</a>)
(<a
href="https://github.com/vitejs/vite/commit/611668983d4b717f5e5e66cf9f7125bd94f92bf7">6116689</a>)</li>
<li><strong>dev:</strong> share ModuleURL and BrowserURL conversions (<a
href="https://redirect.github.com/vitejs/vite/issues/23790">#23790</a>)
(<a
href="https://github.com/vitejs/vite/commit/446954c69c806795c83fe2f97c28ae8f9ef83731">446954c</a>)</li>
<li><strong>dev:</strong> use ModuleURLs throughout HTML transforms (<a
href="https://redirect.github.com/vitejs/vite/issues/23815">#23815</a>)
(<a
href="https://github.com/vitejs/vite/commit/a16299092c0f0f3f90e05d4bb5f0168b293c2b52">a162990</a>)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/vitejs/vite/commit/981f07fa4580fb88b333c4450431e4715d2a0b82"><code>981f07f</code></a>
release: v8.3.5 (<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/23856">#23856</a>)</li>
<li><a
href="https://github.com/vitejs/vite/commit/0d86b3485fbbd0fcb9a1f70a14adedfcb7a1fff1"><code>0d86b34</code></a>
fix(resolve): support subpath imports with multiple <code>#</code> (<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/23631">#23631</a>)</li>
<li><a
href="https://github.com/vitejs/vite/commit/6a0278055d8501bbaf65b8d34ec094722b4e877a"><code>6a02780</code></a>
perf(ssr): extract inline source maps without a regexp (<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/23775">#23775</a>)</li>
<li><a
href="https://github.com/vitejs/vite/commit/8e890e1ce438bfbc07ea4931ce332e40589940fa"><code>8e890e1</code></a>
fix(optimizer): skip patches dir check for yarn.lock fallback (<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/23725">#23725</a>)</li>
<li><a
href="https://github.com/vitejs/vite/commit/197daf6c4c7bfdaa005bebcc41dd2ff22fa3a5b6"><code>197daf6</code></a>
fix(bundled-dev): declare utf-8 charset in fallback page (<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/23846">#23846</a>)</li>
<li><a
href="https://github.com/vitejs/vite/commit/4d16e7c14d5e1c361f148238b1e81131238ab8e9"><code>4d16e7c</code></a>
fix(build): include preload dependencies to chunk import map hashing (<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/23793">#23793</a>)</li>
<li><a
href="https://github.com/vitejs/vite/commit/df7b40ee9d691e9f4e2413244f6665ef5482dce3"><code>df7b40e</code></a>
refactor(dev): consolidate moduleURL to file path utilities (<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/23828">#23828</a>)</li>
<li><a
href="https://github.com/vitejs/vite/commit/a16299092c0f0f3f90e05d4bb5f0168b293c2b52"><code>a162990</code></a>
refactor(dev): use ModuleURLs throughout HTML transforms (<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/23815">#23815</a>)</li>
<li><a
href="https://github.com/vitejs/vite/commit/611668983d4b717f5e5e66cf9f7125bd94f92bf7"><code>6116689</code></a>
refactor(dev): separate import resolution from URL conversion (<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/23791">#23791</a>)</li>
<li><a
href="https://github.com/vitejs/vite/commit/446954c69c806795c83fe2f97c28ae8f9ef83731"><code>446954c</code></a>
refactor(dev): share ModuleURL and BrowserURL conversions (<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/23790">#23790</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/vitejs/vite/commits/v8.3.5/packages/vite">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-10-30 01:56:34 -04:00
dependabot[bot] 833698fd33 build(deps): bump astral-sh/uv from 0.13.5 to 0.13.6 in the docker-base-image-digests group (#1609)
> [!WARNING]
> Cooldown could not be applied because no publication date was
available from the registry.
>

Bumps the docker-base-image-digests group with 1 update:
[astral-sh/uv](https://github.com/astral-sh/uv).

Updates `astral-sh/uv` from 0.13.5 to 0.13.6
<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.13.6</h2>
<h2>Release Notes</h2>
<p>Released on 2026-10-26.</p>
<h3>Enhancements</h3>
<ul>
<li>Accept an empty <code>UV_RUN_RECURSION_DEPTH</code> as zero and
report invalid values consistently (<a
href="https://redirect.github.com/astral-sh/uv/pull/22814">#22814</a>)</li>
<li>Explain how to add project metadata when <code>uv init</code> finds
a <code>pyproject.toml</code> without a <code>[project]</code> table (<a
href="https://redirect.github.com/astral-sh/uv/pull/22916">#22916</a>)</li>
</ul>
<h3>Python</h3>
<ul>
<li>Add CPython 3.11.18, 3.12.16, 3.13.18, 3.14.10, and 3.15.2 (<a
href="https://redirect.github.com/astral-sh/uv/pull/22938">#22938</a>)</li>
</ul>
<h3>Preview features</h3>
<ul>
<li>Detect NVIDIA GPU capabilities when selecting compatible wheel
variants with <code>wheel-variants</code> (<a
href="https://redirect.github.com/astral-sh/uv/pull/22690">#22690</a>)</li>
<li>Nest each module's owners in an object in <code>uv workspace
metadata</code> output (<a
href="https://redirect.github.com/astral-sh/uv/pull/22638">#22638</a>)</li>
<li>Report the unsupported properties required by an incompatible wheel
variant (<a
href="https://redirect.github.com/astral-sh/uv/pull/22719">#22719</a>)</li>
</ul>
<h3>Performance</h3>
<ul>
<li>Reduce peak memory use when resolving large workspaces (<a
href="https://redirect.github.com/astral-sh/uv/pull/22860">#22860</a>,
<a
href="https://redirect.github.com/astral-sh/uv/pull/22863">#22863</a>,
<a
href="https://redirect.github.com/astral-sh/uv/pull/22864">#22864</a>,
<a
href="https://redirect.github.com/astral-sh/uv/pull/22866">#22866</a>,
<a
href="https://redirect.github.com/astral-sh/uv/pull/22870">#22870</a>)</li>
<li>Reduce peak memory use when locking or upgrading large projects (<a
href="https://redirect.github.com/astral-sh/uv/pull/22833">#22833</a>,
<a
href="https://redirect.github.com/astral-sh/uv/pull/22834">#22834</a>)</li>
<li>Speed up redirected output from <code>uv export</code> and <code>uv
pip compile</code> (<a
href="https://redirect.github.com/astral-sh/uv/pull/22840">#22840</a>)</li>
<li>Reuse cached or active environments when running PEP 723 scripts
without dependencies (<a
href="https://redirect.github.com/astral-sh/uv/pull/21458">#21458</a>)</li>
<li>Reduce parsing overhead for build dependency and build backend
settings (<a
href="https://redirect.github.com/astral-sh/uv/pull/22848">#22848</a>,
<a
href="https://redirect.github.com/astral-sh/uv/pull/22871">#22871</a>,
<a
href="https://redirect.github.com/astral-sh/uv/pull/22879">#22879</a>,
<a
href="https://redirect.github.com/astral-sh/uv/pull/22884">#22884</a>)</li>
<li>Reduce parsing overhead for project sources and workspace references
(<a
href="https://redirect.github.com/astral-sh/uv/pull/22880">#22880</a>,
<a
href="https://redirect.github.com/astral-sh/uv/pull/22883">#22883</a>)</li>
<li>Reduce parsing overhead for cache, exclusion, cutoff, and preview
settings (<a
href="https://redirect.github.com/astral-sh/uv/pull/22876">#22876</a>,
<a
href="https://redirect.github.com/astral-sh/uv/pull/22877">#22877</a>,
<a
href="https://redirect.github.com/astral-sh/uv/pull/22878">#22878</a>,
<a
href="https://redirect.github.com/astral-sh/uv/pull/22887">#22887</a>)</li>
<li>Reduce PyPI metadata parsing overhead (<a
href="https://redirect.github.com/astral-sh/uv/pull/22874">#22874</a>,
<a
href="https://redirect.github.com/astral-sh/uv/pull/22875">#22875</a>)</li>
<li>Speed up parsing tool receipt requirements (<a
href="https://redirect.github.com/astral-sh/uv/pull/22885">#22885</a>)</li>
</ul>
<h3>Bug fixes</h3>
<ul>
<li>Prevent <code>uv run --active --script</code> and <code>uv sync
--active --script</code> from deleting non-virtual-environment
directories (<a
href="https://redirect.github.com/astral-sh/uv/pull/21464">#21464</a>)</li>
<li>Use <code>AMD64</code> for <code>platform_machine</code> when
resolving for x86-64 Windows (<a
href="https://redirect.github.com/astral-sh/uv/pull/22935">#22935</a>)</li>
<li>Preserve free-threaded and other non-default variants in <code>uv
python upgrade</code> (<a
href="https://redirect.github.com/astral-sh/uv/pull/22836">#22836</a>)</li>
<li>Allow <code>uv init --no-package</code> and <code>uv init
--script</code> when <code>UV_INIT_BUILD_BACKEND</code> is set (<a
href="https://redirect.github.com/astral-sh/uv/pull/22907">#22907</a>)</li>
<li>Prevent panics when rendering containment markers with local
versions (<a
href="https://redirect.github.com/astral-sh/uv/pull/22815">#22815</a>)</li>
</ul>
<h3>Documentation</h3>
<ul>
<li>Update the Docker guide and reproduction examples to use uv 0.13.5
(<a
href="https://redirect.github.com/astral-sh/uv/pull/22867">#22867</a>)</li>
</ul>
<h2>Install uv 0.13.6</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.13.6/uv-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/uv/blob/main/CHANGELOG.md">astral-sh/uv's
changelog</a>.</em></p>
<blockquote>
<h2>0.13.6</h2>
<p>Released on 2026-10-26.</p>
<h3>Enhancements</h3>
<ul>
<li>Accept an empty <code>UV_RUN_RECURSION_DEPTH</code> as zero and
report invalid values consistently (<a
href="https://redirect.github.com/astral-sh/uv/pull/22814">#22814</a>)</li>
<li>Explain how to add project metadata when <code>uv init</code> finds
a <code>pyproject.toml</code> without a <code>[project]</code> table (<a
href="https://redirect.github.com/astral-sh/uv/pull/22916">#22916</a>)</li>
</ul>
<h3>Python</h3>
<ul>
<li>Add CPython 3.11.18, 3.12.16, 3.13.18, 3.14.10, and 3.15.2 (<a
href="https://redirect.github.com/astral-sh/uv/pull/22938">#22938</a>)</li>
</ul>
<h3>Preview features</h3>
<ul>
<li>Detect NVIDIA GPU capabilities when selecting compatible wheel
variants with <code>wheel-variants</code> (<a
href="https://redirect.github.com/astral-sh/uv/pull/22690">#22690</a>)</li>
<li>Nest each module's owners in an object in <code>uv workspace
metadata</code> output (<a
href="https://redirect.github.com/astral-sh/uv/pull/22638">#22638</a>)</li>
<li>Report the unsupported properties required by an incompatible wheel
variant (<a
href="https://redirect.github.com/astral-sh/uv/pull/22719">#22719</a>)</li>
</ul>
<h3>Performance</h3>
<ul>
<li>Reduce peak memory use when resolving large workspaces (<a
href="https://redirect.github.com/astral-sh/uv/pull/22860">#22860</a>,
<a
href="https://redirect.github.com/astral-sh/uv/pull/22863">#22863</a>,
<a
href="https://redirect.github.com/astral-sh/uv/pull/22864">#22864</a>,
<a
href="https://redirect.github.com/astral-sh/uv/pull/22866">#22866</a>,
<a
href="https://redirect.github.com/astral-sh/uv/pull/22870">#22870</a>)</li>
<li>Reduce peak memory use when locking or upgrading large projects (<a
href="https://redirect.github.com/astral-sh/uv/pull/22833">#22833</a>,
<a
href="https://redirect.github.com/astral-sh/uv/pull/22834">#22834</a>)</li>
<li>Speed up redirected output from <code>uv export</code> and <code>uv
pip compile</code> (<a
href="https://redirect.github.com/astral-sh/uv/pull/22840">#22840</a>)</li>
<li>Reuse cached or active environments when running PEP 723 scripts
without dependencies (<a
href="https://redirect.github.com/astral-sh/uv/pull/21458">#21458</a>)</li>
<li>Reduce parsing overhead for build dependency and build backend
settings (<a
href="https://redirect.github.com/astral-sh/uv/pull/22848">#22848</a>,
<a
href="https://redirect.github.com/astral-sh/uv/pull/22871">#22871</a>,
<a
href="https://redirect.github.com/astral-sh/uv/pull/22879">#22879</a>,
<a
href="https://redirect.github.com/astral-sh/uv/pull/22884">#22884</a>)</li>
<li>Reduce parsing overhead for project sources and workspace references
(<a
href="https://redirect.github.com/astral-sh/uv/pull/22880">#22880</a>,
<a
href="https://redirect.github.com/astral-sh/uv/pull/22883">#22883</a>)</li>
<li>Reduce parsing overhead for cache, exclusion, cutoff, and preview
settings (<a
href="https://redirect.github.com/astral-sh/uv/pull/22876">#22876</a>,
<a
href="https://redirect.github.com/astral-sh/uv/pull/22877">#22877</a>,
<a
href="https://redirect.github.com/astral-sh/uv/pull/22878">#22878</a>,
<a
href="https://redirect.github.com/astral-sh/uv/pull/22887">#22887</a>)</li>
<li>Reduce PyPI metadata parsing overhead (<a
href="https://redirect.github.com/astral-sh/uv/pull/22874">#22874</a>,
<a
href="https://redirect.github.com/astral-sh/uv/pull/22875">#22875</a>)</li>
<li>Speed up parsing tool receipt requirements (<a
href="https://redirect.github.com/astral-sh/uv/pull/22885">#22885</a>)</li>
</ul>
<h3>Bug fixes</h3>
<ul>
<li>Prevent <code>uv run --active --script</code> and <code>uv sync
--active --script</code> from deleting non-virtual-environment
directories (<a
href="https://redirect.github.com/astral-sh/uv/pull/21464">#21464</a>)</li>
<li>Use <code>AMD64</code> for <code>platform_machine</code> when
resolving for x86-64 Windows (<a
href="https://redirect.github.com/astral-sh/uv/pull/22935">#22935</a>)</li>
<li>Preserve free-threaded and other non-default variants in <code>uv
python upgrade</code> (<a
href="https://redirect.github.com/astral-sh/uv/pull/22836">#22836</a>)</li>
<li>Allow <code>uv init --no-package</code> and <code>uv init
--script</code> when <code>UV_INIT_BUILD_BACKEND</code> is set (<a
href="https://redirect.github.com/astral-sh/uv/pull/22907">#22907</a>)</li>
<li>Prevent panics when rendering containment markers with local
versions (<a
href="https://redirect.github.com/astral-sh/uv/pull/22815">#22815</a>)</li>
</ul>
<h3>Documentation</h3>
<ul>
<li>Update the Docker guide and reproduction examples to use uv 0.13.5
(<a
href="https://redirect.github.com/astral-sh/uv/pull/22867">#22867</a>)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/astral-sh/uv/commit/329bf9448afd9f9088d14a4d57fbc61eb454f117"><code>329bf94</code></a>
Bump version to 0.13.6 (<a
href="https://redirect.github.com/astral-sh/uv/issues/22947">#22947</a>)</li>
<li><a
href="https://github.com/astral-sh/uv/commit/5b5a53e1803d426ba04f4af8ff7d6ff747548218"><code>5b5a53e</code></a>
Use checked-in Python download metadata for release preparation (<a
href="https://redirect.github.com/astral-sh/uv/issues/22945">#22945</a>)</li>
<li><a
href="https://github.com/astral-sh/uv/commit/b8b339fe4794e2919c7d2ef3214027aebc59d023"><code>b8b339f</code></a>
Update Rust crate h2 to v0.4.19 (<a
href="https://redirect.github.com/astral-sh/uv/issues/21873">#21873</a>)</li>
<li><a
href="https://github.com/astral-sh/uv/commit/1eb5989ea886d33add8c0ebc0adbfa54b1c32c82"><code>1eb5989</code></a>
Update Rust crate hyper to v1.11.1 (<a
href="https://redirect.github.com/astral-sh/uv/issues/20903">#20903</a>)</li>
<li><a
href="https://github.com/astral-sh/uv/commit/68677f5439c027db17b4db7821882a9881d376f6"><code>68677f5</code></a>
Update Rust crate reqwest to v0.13.5 (<a
href="https://redirect.github.com/astral-sh/uv/issues/22475">#22475</a>)</li>
<li><a
href="https://github.com/astral-sh/uv/commit/69e574e368d31145692b3e22e59285316566e6d7"><code>69e574e</code></a>
Cargo vet integration (<a
href="https://redirect.github.com/astral-sh/uv/issues/21610">#21610</a>)</li>
<li><a
href="https://github.com/astral-sh/uv/commit/acee8d938b52225be3e90ccfcbe69fa4eb8ed215"><code>acee8d9</code></a>
Sync latest Python releases, Python 3.11.18, 3.12.16, 3.13.18, 3.14.10,
3.15....</li>
<li><a
href="https://github.com/astral-sh/uv/commit/4b46af05893283739f94dea65e2bf667bda64d12"><code>4b46af0</code></a>
Fix <code>io-bound</code> Windows CI nextest grouping (<a
href="https://redirect.github.com/astral-sh/uv/issues/22939">#22939</a>)</li>
<li><a
href="https://github.com/astral-sh/uv/commit/d397ecbf703c00768a68d6fad0978cd20f45d8a6"><code>d397ecb</code></a>
Add hints when <code>uv init</code> finds <code>pyproject.toml</code>
without a project table (<a
href="https://redirect.github.com/astral-sh/uv/issues/22916">#22916</a>)</li>
<li><a
href="https://github.com/astral-sh/uv/commit/7b5d227102f51c311cdfed3370b473331144b2c6"><code>7b5d227</code></a>
Let <code>--no-package</code> and <code>--script</code> take precedence
over `UV_INIT_BUILD_BACKEND...</li>
<li>Additional commits viewable in <a
href="https://github.com/astral-sh/uv/compare/0.13.5...0.13.6">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=astral-sh/uv&package-manager=docker&previous-version=0.13.5&new-version=0.13.6)](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-10-30 01:56:15 -04:00
dependabot[bot] bbdbaade84 build(deps): bump the python-deps group across 1 directory with 3 updates (#1608)
Bumps the python-deps group with 3 updates in the / directory:
[apprise](https://github.com/caronc/apprise),
[seleniumbase](https://github.com/seleniumbase/SeleniumBase) and
[ruff](https://github.com/astral-sh/ruff).

Updates `apprise` from 2.0.1 to 2.1.0
<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>Apprise v2.1.0</h2>
<h2>What's Changed</h2>
<p>First off, a huge kudos to the large increase in community support! A
lot of bugfixes from a number of very active contributors that make a
huge bulk of this release. 12 new services supported in this release
bringing the total (Apprise) supported plugin count to170! 🔥</p>
<h3>📣 New Notification Services:</h3>
<ul>
<li>GoAlert notification support in <a
href="https://redirect.github.com/caronc/apprise/pull/1767">caronc/apprise#1767</a></li>
<li>Added OneBot (QQ) Support in <a
href="https://redirect.github.com/caronc/apprise/pull/1768">caronc/apprise#1768</a></li>
<li>AMQP v0.9.1 and v1.0 support in <a
href="https://redirect.github.com/caronc/apprise/pull/1809">caronc/apprise#1809</a></li>
<li>GitHub issue, discussions and pull request notifications in <a
href="https://redirect.github.com/caronc/apprise/pull/1815">caronc/apprise#1815</a></li>
<li>Add Gorush push notification support in <a
href="https://redirect.github.com/caronc/apprise/pull/1818">caronc/apprise#1818</a></li>
<li>Add Better Stack support in <a
href="https://redirect.github.com/caronc/apprise/pull/1819">caronc/apprise#1819</a></li>
<li>incident.io support added in <a
href="https://redirect.github.com/caronc/apprise/pull/1822">caronc/apprise#1822</a></li>
<li>Squadcast support added in <a
href="https://redirect.github.com/caronc/apprise/pull/1830">caronc/apprise#1830</a></li>
<li>Added ilert support in <a
href="https://redirect.github.com/caronc/apprise/pull/1833">caronc/apprise#1833</a></li>
<li>LFG Alarm support added by <a
href="https://github.com/LFGWin"><code>@​LFGWin</code></a> in <a
href="https://redirect.github.com/caronc/apprise/pull/1832">caronc/apprise#1832</a></li>
<li>Added Zalo support in <a
href="https://redirect.github.com/caronc/apprise/pull/1836">caronc/apprise#1836</a></li>
<li>Zoho Cliq support added in <a
href="https://redirect.github.com/caronc/apprise/pull/1835">caronc/apprise#1835</a></li>
</ul>
<h3>🐞  Bugfixes</h3>
<ul>
<li>YAML config no longer drops plugin ?tags= from URLs by <a
href="https://github.com/TheFiZi"><code>@​TheFiZi</code></a> in <a
href="https://redirect.github.com/caronc/apprise/pull/1782">caronc/apprise#1782</a></li>
<li>tags= and tag= on urls not supported in <a
href="https://redirect.github.com/caronc/apprise/pull/1783">caronc/apprise#1783</a></li>
<li>notify decorator honours common URL options in <a
href="https://redirect.github.com/caronc/apprise/pull/1784">caronc/apprise#1784</a></li>
<li>Close MQTT connection when the notifier is freed by <a
href="https://github.com/LuisFigueroaG"><code>@​LuisFigueroaG</code></a>
in <a
href="https://redirect.github.com/caronc/apprise/pull/1773">caronc/apprise#1773</a></li>
<li>Matrix E2EE no longer publishes unused one-time keys by <a
href="https://github.com/Cassian433"><code>@​Cassian433</code></a> in <a
href="https://redirect.github.com/caronc/apprise/pull/1770">caronc/apprise#1770</a></li>
<li>Keep from=, flash= and label= in seven:// url() by <a
href="https://github.com/LuisFigueroaG"><code>@​LuisFigueroaG</code></a>
in <a
href="https://redirect.github.com/caronc/apprise/pull/1774">caronc/apprise#1774</a></li>
<li>Keep the from= sender address in Pingram url() by <a
href="https://github.com/SulimanAbdulrazzaq"><code>@​SulimanAbdulrazzaq</code></a>
in <a
href="https://redirect.github.com/caronc/apprise/pull/1776">caronc/apprise#1776</a></li>
<li>Keep the from= sender address in Mailgun url() by <a
href="https://github.com/SulimanAbdulrazzaq"><code>@​SulimanAbdulrazzaq</code></a>
in <a
href="https://redirect.github.com/caronc/apprise/pull/1777">caronc/apprise#1777</a></li>
<li>Do not re-send message pieces that were already accepted when
retrying by <a
href="https://github.com/SulimanAbdulrazzaq"><code>@​SulimanAbdulrazzaq</code></a>
in <a
href="https://redirect.github.com/caronc/apprise/pull/1779">caronc/apprise#1779</a></li>
<li>Clear LC_MESSAGES in translation tests by <a
href="https://github.com/wolfgang-aura"><code>@​wolfgang-aura</code></a>
in <a
href="https://redirect.github.com/caronc/apprise/pull/1775">caronc/apprise#1775</a></li>
<li>Hide a bare access token in matrix:// privacy url() by <a
href="https://github.com/LuisFigueroaG"><code>@​LuisFigueroaG</code></a>
in <a
href="https://redirect.github.com/caronc/apprise/pull/1786">caronc/apprise#1786</a></li>
<li>Improve persistent storage namespaces path handling in <a
href="https://redirect.github.com/caronc/apprise/pull/1796">caronc/apprise#1796</a></li>
<li>Hide the password in smpp:// privacy url() by <a
href="https://github.com/LuisFigueroaG"><code>@​LuisFigueroaG</code></a>
in <a
href="https://redirect.github.com/caronc/apprise/pull/1785">caronc/apprise#1785</a></li>
<li>fix(matrix): use standard Base64 for E2EE attachment IV by <a
href="https://github.com/vampywiz17"><code>@​vampywiz17</code></a> in <a
href="https://redirect.github.com/caronc/apprise/pull/1788">caronc/apprise#1788</a></li>
<li>Use the default file expiry as a cutoff date in disk_prune() by <a
href="https://github.com/cristianchiriac"><code>@​cristianchiriac</code></a>
in <a
href="https://redirect.github.com/caronc/apprise/pull/1808">caronc/apprise#1808</a></li>
<li>Fail for empty or invalid storage env variables by <a
href="https://github.com/cristianchiriac"><code>@​cristianchiriac</code></a>
in <a
href="https://redirect.github.com/caronc/apprise/pull/1807">caronc/apprise#1807</a></li>
<li>Do not send an empty piece when overflow=split breaks at a leading
line break or space by <a
href="https://github.com/SulimanAbdulrazzaq"><code>@​SulimanAbdulrazzaq</code></a>
in <a
href="https://redirect.github.com/caronc/apprise/pull/1792">caronc/apprise#1792</a></li>
<li>Keep an invalid backslash sequence as written when interpret_escapes
is on by <a
href="https://github.com/SulimanAbdulrazzaq"><code>@​SulimanAbdulrazzaq</code></a>
in <a
href="https://redirect.github.com/caronc/apprise/pull/1791">caronc/apprise#1791</a></li>
<li>Fix renewal of persistent files read with the default key by <a
href="https://github.com/Dcastroro"><code>@​Dcastroro</code></a> in <a
href="https://redirect.github.com/caronc/apprise/pull/1814">caronc/apprise#1814</a></li>
<li>Keep pending persistent writes after a non-persistent set() by <a
href="https://github.com/cristianchiriac"><code>@​cristianchiriac</code></a>
in <a
href="https://redirect.github.com/caronc/apprise/pull/1813">caronc/apprise#1813</a></li>
<li>Keep custom from= address in email url() round trips by <a
href="https://github.com/cestercian"><code>@​cestercian</code></a> in <a
href="https://redirect.github.com/caronc/apprise/pull/1811">caronc/apprise#1811</a></li>
<li>Keep cc/bcc/reply display names intact in email url() by <a
href="https://github.com/cristianchiriac"><code>@​cristianchiriac</code></a>
in <a
href="https://redirect.github.com/caronc/apprise/pull/1812">caronc/apprise#1812</a></li>
<li>Keep encoded slashes and other escapes intact in URL paths in <a
href="https://redirect.github.com/caronc/apprise/pull/1816">caronc/apprise#1816</a></li>
<li>Hide the API key in twilio:// privacy url() by <a
href="https://github.com/LuisFigueroaG"><code>@​LuisFigueroaG</code></a>
in <a
href="https://redirect.github.com/caronc/apprise/pull/1817">caronc/apprise#1817</a></li>
<li>Make incident plugins consistent and let PagerDuty, PagerTree and
SIGNL4 resolve alerts in <a
href="https://redirect.github.com/caronc/apprise/pull/1824">caronc/apprise#1824</a></li>
<li>Stop uppercasing name=, identifier= and sound_href= in strmlabs://
by <a
href="https://github.com/LuisFigueroaG"><code>@​LuisFigueroaG</code></a>
in <a
href="https://redirect.github.com/caronc/apprise/pull/1821">caronc/apprise#1821</a></li>
<li>Fix email reply= failing after the first recipient by <a
href="https://github.com/LuisFigueroaG"><code>@​LuisFigueroaG</code></a>
in <a
href="https://redirect.github.com/caronc/apprise/pull/1827">caronc/apprise#1827</a></li>
<li>Honor +Arg=Val and -Arg in ifttt:// URLs by <a
href="https://github.com/MaxFreedomPollard"><code>@​MaxFreedomPollard</code></a>
in <a
href="https://redirect.github.com/caronc/apprise/pull/1831">caronc/apprise#1831</a></li>
<li>Fix SendPulse sending only the last cc/bcc address by <a
href="https://github.com/LuisFigueroaG"><code>@​LuisFigueroaG</code></a>
in <a
href="https://redirect.github.com/caronc/apprise/pull/1834">caronc/apprise#1834</a></li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/caronc/apprise/commit/736fcf36c5acabe153e5b0b0b444caea8dc4c235"><code>736fcf3</code></a>
bumped version to v2.1.0</li>
<li><a
href="https://github.com/caronc/apprise/commit/c8db34ef6a077abf7c14b73806b116b925507449"><code>c8db34e</code></a>
Zoho Cliq support added (<a
href="https://redirect.github.com/caronc/apprise/issues/1835">#1835</a>)</li>
<li><a
href="https://github.com/caronc/apprise/commit/8f2117168cbd169782322d74e718e97d8d90dd46"><code>8f21171</code></a>
Added Zalo support (<a
href="https://redirect.github.com/caronc/apprise/issues/1836">#1836</a>)</li>
<li><a
href="https://github.com/caronc/apprise/commit/f39501d011bc1853b88b47a5ed615ef9bb9887c7"><code>f39501d</code></a>
Fix SendPulse sending only the last cc/bcc address (<a
href="https://redirect.github.com/caronc/apprise/issues/1834">#1834</a>)</li>
<li><a
href="https://github.com/caronc/apprise/commit/8cae8d3071c2c0d48a0a28593045cae5e33bcdd4"><code>8cae8d3</code></a>
Update security policy to include version 2.0.1</li>
<li><a
href="https://github.com/caronc/apprise/commit/726055e1d789018004aac82e76f65e59530542c6"><code>726055e</code></a>
Revise supported versions in SECURITY.md</li>
<li><a
href="https://github.com/caronc/apprise/commit/80526d601c6f144f21d5ea1eaf0d57e1ddc6c879"><code>80526d6</code></a>
LFG Alarm support added (<a
href="https://redirect.github.com/caronc/apprise/issues/1832">#1832</a>)</li>
<li><a
href="https://github.com/caronc/apprise/commit/c6d3af32f69783eb4a40247d7c01b35534d3bf8a"><code>c6d3af3</code></a>
Honor +Arg=Val and -Arg in ifttt:// URLs (<a
href="https://redirect.github.com/caronc/apprise/issues/1831">#1831</a>)</li>
<li><a
href="https://github.com/caronc/apprise/commit/a915602d5a2c8e1dd5af493dba9e5db89bf000ef"><code>a915602</code></a>
Send Pushover markup with no text as plain text (<a
href="https://redirect.github.com/caronc/apprise/issues/1829">#1829</a>)</li>
<li><a
href="https://github.com/caronc/apprise/commit/1867c9aebf5d9c1ff88f8a5598246a61d1147395"><code>1867c9a</code></a>
Added ilert support (<a
href="https://redirect.github.com/caronc/apprise/issues/1833">#1833</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/caronc/apprise/compare/v2.0.1...v2.1.0">compare
view</a></li>
</ul>
</details>
<br />

Updates `seleniumbase` from 4.56.3 to 4.56.4
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/seleniumbase/SeleniumBase/releases">seleniumbase's
releases</a>.</em></p>
<blockquote>
<h2>4.56.4 - CDP Mode: Patch 130</h2>
<h2>CDP Mode: Patch 130</h2>
<ul>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/8310a78ab3b7d356f4f75888cc74e5992ed92efc">Fix
issue with listing tabs in CDP Mode</a>
--&gt; This resolves <a
href="https://redirect.github.com/seleniumbase/SeleniumBase/issues/4533">seleniumbase/SeleniumBase#4533</a></li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/1393a024a0d37d567acdcc3abfe262901acbe222">Refresh
Python dependencies</a>
--&gt; <code>certifi</code>
--&gt; <code>soupsieve</code>
--&gt; <code>coverage</code> (optional dependency)
--&gt; <code>playwright</code> (optional dependency)
--&gt; <code>pycparser</code> (optional dependency)
--&gt; <code>uv</code> (optional dependency)</li>
</ul>
<h2>What's Changed</h2>
<ul>
<li>CDP Mode: Patch 130 by <a
href="https://github.com/mdmintz"><code>@​mdmintz</code></a> in <a
href="https://redirect.github.com/seleniumbase/SeleniumBase/pull/4535">seleniumbase/SeleniumBase#4535</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/seleniumbase/SeleniumBase/compare/v4.56.3...v4.56.4">https://github.com/seleniumbase/SeleniumBase/compare/v4.56.3...v4.56.4</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/abf61932bc89bfbabd59eaa776b9d09f75618d2f"><code>abf6193</code></a>
Merge pull request <a
href="https://redirect.github.com/seleniumbase/SeleniumBase/issues/4535">#4535</a>
from seleniumbase/cdp-mode-patch-130</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/7a8363acb71f15c1ce7956295d63699b104f291f"><code>7a8363a</code></a>
Version 4.56.4</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/c71b7a24976a2f14a03bce5d0c05862d237510fa"><code>c71b7a2</code></a>
Update MCP server versioning</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/1393a024a0d37d567acdcc3abfe262901acbe222"><code>1393a02</code></a>
Refresh Python dependencies</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/8310a78ab3b7d356f4f75888cc74e5992ed92efc"><code>8310a78</code></a>
Fix issue with listing tabs in CDP Mode</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/c297e2dd8ed46b5a0b49226d37e1efda87bfbdb1"><code>c297e2d</code></a>
Refresh mkdocs dependencies</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/da6679741f65e34fb30db50b98742cfaf05b009b"><code>da66797</code></a>
Update images</li>
<li>See full diff in <a
href="https://github.com/seleniumbase/SeleniumBase/compare/v4.56.3...v4.56.4">compare
view</a></li>
</ul>
</details>
<br />

Updates `ruff` from 0.17.2 to 0.17.3
<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.17.3</h2>
<h2>Release Notes</h2>
<p>Released on 2026-10-26.</p>
<h3>Preview features</h3>
<ul>
<li>[<code>suspicious</code>] Add <code>shared-clock-mock</code> (<a
href="https://redirect.github.com/astral-sh/ruff/pull/29074">#29074</a>)</li>
</ul>
<h3>Bug fixes</h3>
<ul>
<li>[<code>pyupgrade</code>] Avoid incorrect conversions to f-strings
(<code>UP032</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/25485">#25485</a>)</li>
</ul>
<h3>Performance</h3>
<ul>
<li>Buffer stdout in <code>ruff format</code> (<a
href="https://redirect.github.com/astral-sh/ruff/pull/29631">#29631</a>)</li>
<li>Specialize ASCII identifier lexing (<a
href="https://redirect.github.com/astral-sh/ruff/pull/29503">#29503</a>)</li>
<li>Speed up identifier and keyword lexing (<a
href="https://redirect.github.com/astral-sh/ruff/pull/29463">#29463</a>)</li>
</ul>
<h3>Documentation</h3>
<ul>
<li>[<code>flake8-bandit</code>] Describe what the rule actually checks
for <code>httpx</code> (<code>S113</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/27936">#27936</a>)</li>
</ul>
<h3>Contributors</h3>
<ul>
<li><a
href="https://github.com/itsvedantkumar"><code>@​itsvedantkumar</code></a></li>
<li><a
href="https://github.com/charliermarsh"><code>@​charliermarsh</code></a></li>
<li><a
href="https://github.com/anishgirianish"><code>@​anishgirianish</code></a></li>
</ul>
<h2>Install ruff 0.17.3</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.17.3/ruff-installer.sh
| sh
</code></pre>
<h3>Install prebuilt binaries via powershell script</h3>
<pre lang="sh"><code>powershell -ExecutionPolicy Bypass -c &quot;irm
https://releases.astral.sh/github/ruff/releases/download/0.17.3/ruff-installer.ps1
| iex&quot;
</code></pre>
<h2>Download ruff 0.17.3</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/ruff/releases/download/0.17.3/ruff-aarch64-apple-darwin.tar.gz">ruff-aarch64-apple-darwin.tar.gz</a></td>
<td>Apple Silicon macOS</td>
<td><a
href="https://releases.astral.sh/github/ruff/releases/download/0.17.3/ruff-aarch64-apple-darwin.tar.gz.sha256">checksum</a></td>
</tr>
<tr>
<td><a
href="https://releases.astral.sh/github/ruff/releases/download/0.17.3/ruff-x86_64-apple-darwin.tar.gz">ruff-x86_64-apple-darwin.tar.gz</a></td>
<td>Intel macOS</td>
<td><a
href="https://releases.astral.sh/github/ruff/releases/download/0.17.3/ruff-x86_64-apple-darwin.tar.gz.sha256">checksum</a></td>
</tr>
<tr>
<td><a
href="https://releases.astral.sh/github/ruff/releases/download/0.17.3/ruff-aarch64-pc-windows-msvc.zip">ruff-aarch64-pc-windows-msvc.zip</a></td>
<td>ARM64 Windows</td>
<td><a
href="https://releases.astral.sh/github/ruff/releases/download/0.17.3/ruff-aarch64-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/astral-sh/ruff/blob/main/CHANGELOG.md">ruff's
changelog</a>.</em></p>
<blockquote>
<h2>0.17.3</h2>
<p>Released on 2026-10-26.</p>
<h3>Preview features</h3>
<ul>
<li>[<code>suspicious</code>] Add <code>shared-clock-mock</code> (<a
href="https://redirect.github.com/astral-sh/ruff/pull/29074">#29074</a>)</li>
</ul>
<h3>Bug fixes</h3>
<ul>
<li>[<code>pyupgrade</code>] Avoid incorrect conversions to f-strings
(<code>UP032</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/25485">#25485</a>)</li>
</ul>
<h3>Performance</h3>
<ul>
<li>Buffer stdout in <code>ruff format</code> (<a
href="https://redirect.github.com/astral-sh/ruff/pull/29631">#29631</a>)</li>
<li>Specialize ASCII identifier lexing (<a
href="https://redirect.github.com/astral-sh/ruff/pull/29503">#29503</a>)</li>
<li>Speed up identifier and keyword lexing (<a
href="https://redirect.github.com/astral-sh/ruff/pull/29463">#29463</a>)</li>
</ul>
<h3>Documentation</h3>
<ul>
<li>[<code>flake8-bandit</code>] Describe what the rule actually checks
for <code>httpx</code> (<code>S113</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/27936">#27936</a>)</li>
</ul>
<h3>Contributors</h3>
<ul>
<li><a
href="https://github.com/itsvedantkumar"><code>@​itsvedantkumar</code></a></li>
<li><a
href="https://github.com/charliermarsh"><code>@​charliermarsh</code></a></li>
<li><a
href="https://github.com/anishgirianish"><code>@​anishgirianish</code></a></li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/astral-sh/ruff/commit/ba65abf8ba7bfbcc059f7fb9712be5ba9cab6abc"><code>ba65abf</code></a>
Bump version to 0.17.3 (<a
href="https://redirect.github.com/astral-sh/ruff/issues/29745">#29745</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/fb2f9218793e3b0e979175d43d88dcac791e92d6"><code>fb2f921</code></a>
[ty] Fix narrowing of strings and integers compared with enum members
(<a
href="https://redirect.github.com/astral-sh/ruff/issues/29743">#29743</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/d1812878d5f81af6f5810d193ebc44413d1b21cc"><code>d181287</code></a>
[ty] Index nominal union alternatives (<a
href="https://redirect.github.com/astral-sh/ruff/issues/29196">#29196</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/07872adbafa7c00f42f1174d8aa3c3e324033009"><code>07872ad</code></a>
[ty] Preserve string literals in comparisons with
<code>LiteralString</code> (<a
href="https://redirect.github.com/astral-sh/ruff/issues/29741">#29741</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/95888b2edfaa34d0159732856b1466cf53b33e64"><code>95888b2</code></a>
[ty] Preserve narrowing after guarded match subject captures (<a
href="https://redirect.github.com/astral-sh/ruff/issues/29558">#29558</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/3c8166b683805aa89557b0739f5b9918ad58ea6d"><code>3c8166b</code></a>
[ty] Fix hang when negating recursive unions of instance types (<a
href="https://redirect.github.com/astral-sh/ruff/issues/29652">#29652</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/3fd20e08598ac345b8afd524d3f7086456e4e758"><code>3fd20e0</code></a>
[ty] Preserve generic callback specializations during return refinement
(<a
href="https://redirect.github.com/astral-sh/ruff/issues/29667">#29667</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/2f0a29ece1f1a539c65c0671c04c76bb886219fe"><code>2f0a29e</code></a>
[ty] Record predicate truthiness alongside expression inference (<a
href="https://redirect.github.com/astral-sh/ruff/issues/29706">#29706</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/60f104a1a2ec4e980198ff54c38ba6fe7045b29f"><code>60f104a</code></a>
[ty] Honor <a
href="https://github.com/override"><code>@​override</code></a> on
overload implementations in stubs (<a
href="https://redirect.github.com/astral-sh/ruff/issues/29470">#29470</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/207ec3affeb118fce03a65f9333959157e228312"><code>207ec3a</code></a>
[ty] Add diagnostic baseline support (<a
href="https://redirect.github.com/astral-sh/ruff/issues/27636">#27636</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/astral-sh/ruff/compare/0.17.2...0.17.3">compare
view</a></li>
</ul>
</details>
<br />


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

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

---

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

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


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-30 01:42:22 -04:00
splitsec2 7583f997bb fix(oidc): catch joserfc's InvalidClaimError so the issuer guidance shows (#1603)
A wrong OIDC issuer, which is the usual IdP setup mistake, sends the
user back to the login page with a bare "Authentication failed". The
callback has a handler that explains it ("OIDC issuer validation failed.
Verify your discovery URL and IdP issuer/external URL configuration."),
and it never runs.

Authlib validates the ID token with joserfc now, so the error that comes
out of `authorize_access_token()` is `joserfc.errors.InvalidClaimError`.
The handler catches the one in `authlib.jose.errors`, which is a
different class with a different base, so the isinstance check is False.
I confirmed it by validating a real `CodeIDToken` with a wrong `iss` on
Authlib 1.9.1. I haven't run it against a live IdP.

The existing tests passed because they raise the old class from a mock.
The new one raises the error from a real `CodeIDToken`, and it fails on
the old import with `'Authentication failed'` in place of the guidance.

Two small things come with it:
- The new class names its attribute `claim` where the old one used
`claim_name`, so that read changes.
- `authlib.jose` is deprecated in favour of joserfc and Authlib's own
warning says it goes in 2.0. This was the only place Shelfmark used it,
so the import moving also takes us off the module that is going away.
joserfc is already installed as an Authlib requirement, so I added it to
`pyproject.toml` (`>=1.6.8`, Authlib's own floor) and the lock gets the
one entry. No new package.

Ruff, basedpyright and the backend suite pass (5424 passed).
2026-10-27 11:06:51 -06:00
CaliBrain 947ce7bfe5 test(e2e): serve the mock Prowlarr .torrent from Prowlarr's own origin (#1602)
The nightly client-deluge profile has failed since 61c7ad3 with "Failed
to
add to deluge: Failed to fetch torrent file: torrent fetch refused:
address
is not public". Shelfmark now fetches a .torrent only from the
configured
Prowlarr/Newznab/Jackett origin or from a public address, and the mock
Prowlarr's release linked straight to http://mock-aa/payload.torrent, a
private address on another origin. qBittorrent and Transmission hid this
by
handing the URL to the client; Deluge needs the file and failed.

Real Prowlarr rewrites indexer links to its own /{id}/download endpoint.
The
mock now does the same: the torznab link uses the host Shelfmark reached
it
on, and /{id}/download fetches the .torrent from mock-aa. Every client
now
gets the file through Shelfmark's own fetch.

tests/download/test_prowlarr_mock_torrent_fetch.py runs the real
extract_torrent_info against the mock in normal CI, so the link cannot
drift
off the Prowlarr origin again unnoticed; it fails on the old mock with
the
same error.
2026-10-27 11:25:49 -04:00
CaliBrain 16e20679b4 fix(docker): bump Chromium pin to 156.0.8078.47 (#1601) 2026-10-27 10:50:22 -04:00
dependabot[bot] c0b112f2f8 build(deps): bump publicsuffixlist from 1.1.2.20261021 to 1.1.2.20261023 (#1600)
Bumps [publicsuffixlist](https://github.com/ko-zu/psl) from
1.1.2.20261021 to 1.1.2.20261023.
<details>
<summary>Commits</summary>
<ul>
<li>See full diff in <a
href="https://github.com/ko-zu/psl/commits">compare view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=publicsuffixlist&package-manager=uv&previous-version=1.1.2.20261021&new-version=1.1.2.20261023)](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-10-27 10:36:13 -04:00
splitsec2 83e61975c4 fix(releases): apply the record policy to a provider named alongside a source (#1598)
Two small things I found while going back over what has landed since
1.5.0.

`/api/releases` takes a `provider` and a `source`, and the policy check
only looked at `source`. A user whose policy blocks one release source
but not another could pass the blocked one as `provider` with the
allowed one as `source`, and get the blocked source's record back. The
record route already refuses that with a 403, so the listing route now
applies the same refusal when `provider` names a source that is not
being listed. The normal case, where provider and source are the same,
is already gated and skips the extra check.

The second commit is wording only. The Automatic Downloads setting and
the Hardcover sync doc said "synced requests", but the pass picks up
every pending Hardcover request, including ones a user made themselves
with Request Book. The download is still queued as the user who asked
(the admin is only the reviewer, same as a manual approval), and admins
already see every user's requests. The text now says that, and a new
test pins the behaviour for a user's own request.

I opened a scratch instance and looked at the Hardcover Sync tab: the
Automatic Downloads heading and the checkbox description read as
intended and nothing else on the tab moved. Full suite, ruff and
basedpyright pass, and the new policy test fails without the fix.
2026-10-26 14:49:40 -06:00
splitsec2 cfba44b754 refactor: one module for taking credentials and links out of text (#1578)
Draft until #1571 merges: this is stacked on it, so the first two
commits here are that PR's and only the last one is new. I will rebase
once #1571 is in.

Shelfmark strips credentials and links out of text in five places,
written in different PRs with different patterns: the log formatter,
`_safe_url` and `_redact_urls_in_text` in `torrent_utils.py`,
`redact_status_message` in `viewer_redaction.py`, the `sed` rules in
`genDebug.sh`, and the password handling in the settings registry.
Between them there were three regexes that find a URL in text, two
separate userinfo patterns and no shared list of what counts as a
secret. #1571 made the gap visible, because every pattern added to the
logger had to be remembered somewhere else too.

This moves the text and URL part into `shelfmark/core/redaction.py`,
which imports nothing from the app so the logger can depend on it:

- `redact_secrets` (mask in place, for logs) moved from
`core/logger.py`, patterns unchanged. The logger imports it.
- `safe_url` and `redact_urls_in_text` (keep scheme, host and path, drop
the query) moved from `torrent_utils.py` and are public now, so
`alldebrid.py` no longer imports a private name from another module.
- `strip_links` (remove links entirely) is what `redact_status_message`
now calls. It and `redact_urls_in_text` share one link pattern, the one
the viewer redaction already used.

Two small behaviour changes come from sharing that pattern. A closing
bracket or quote right after a URL is no longer swallowed into it when
scrubbing an exception message. And the scheme-less path that requests
prints after `url: ` now keeps only what is before its query in
`redact_urls_in_text`, where before it passed through as it was (the log
formatter masked the key, but the path and query stayed in the message).

Also here: the two `sed` rules in `genDebug.sh` that mask userinfo now
stop at `?` and `#` like the logger pattern does, with a test that fails
on the old script.

Not in this PR: the debug bundle zips the live log directory as it is,
so log lines written before #1571 are not redacted in it. Fixing that
means copying the logs to a staging folder and running them through this
module, which I would do as a separate change.

Tests: a new file covers `safe_url`, `redact_urls_in_text` and
`strip_links` directly, and two checks that the viewer and the logger
really use these. Full suite passes.
2026-10-26 14:48:36 -06:00
splitsec2 7a4f1670dc fix(slskd): try a batch enqueue once more when slskd answers 500 (#1590)
slskd answers a bare 500 when the peer doesn't reply to a queue request
inside its own 5 second wait (its log says "The wait timed out after
5000 milliseconds"). The peer often answers a moment later, so a
Soulseek download that would have worked failed on the first try.

The batch enqueue is now tried twice with 5 seconds between. A 4xx or a
connection failure is not retried, only a 5xx. While it waits the card
reads "The peer did not answer, trying again", and cancelling during the
wait stops without the second try. If the second try fails too, the
error message carries what slskd said (trimmed to 160 characters).
Before, that text was dropped and the card only showed the status line.

It's been running on my instance since mid-October, and the timeouts
still show up in slskd's log, so I can't say how many it saved. The five
new tests cover the retry that works, giving up after the second 500, a
400 and a connection error not being retried, and cancelling mid-wait.
Ruff, basedpyright and the slskd tests pass.
2026-10-26 11:37:05 -06:00
splitsec2 e8b28d05e0 fix(sources): take the retry download URL from the release cache only (#1597)
Newznab and Jackett fell back to the download URL sent with the queue
request when the release was no longer in the cache. Prowlarr already
returns no URL in that case, so this makes the other two match it: the
retry URL comes from the cache and nothing else.

A request for a release that has expired from the cache now fails with
"Release not found in cache" instead of being queued with whatever URL
came along.

The new tests cover both handlers on a cache miss and with no source_id,
and they fail on main. Full suite, ruff and basedpyright pass.
2026-10-26 11:15:44 -06:00
splitsec2 8b80f32476 fix(sources): keep credentials out of a source-unavailable message (#1593)
Code scanning alert 106 (stack trace exposure on the release search 503)
turned out to be worth a look. Jackett sends its API key as `?apikey=`
in the query string, and when Jackett is unreachable the source builds
its "unavailable" message from the requests exception, whose text
includes the full request URL. `/api/releases` returns that message to
whoever ran the search, so any signed-in user could read the Jackett key
while Jackett was down. I reproduced it with Jackett pointed at a closed
port: the 503 body had `apikey=<the key>` in it.

Jackett was added in #1521 and isn't in a release yet, so nothing
shipped with this.

`SourceUnavailableError` now masks credentials in its message with the
same `redact_secrets` the log formatter uses (query parameters like
`apikey=`, `user:pass@` in URLs and so on). I did it in the exception
rather than at the two Jackett sites that build the text, since its
message is written for the user and any other source that ends up with a
key in a URL would hit the same thing. The rest of the message stays
readable, for example "Max retries exceeded with url:
...?apikey=(removed)&t=book".

Prowlarr and slskd send their keys in headers so they didn't leak a key,
but their messages do include the internal host and port. I left that
alone here.

Tests: two Jackett ones (a connection error on the search and on the
indexer lookup, with the key in the exception text) and three on the
exception itself. The four that check masking fail on current main. The
full suite, ruff and basedpyright are clean.
2026-10-26 10:34:17 -06:00
splitsec2 72b094db7a fix(qbittorrent): map the qBittorrent 5 stopped states and forcedMetaDL (#1570)
On qBittorrent 5 a stopped torrent shows up as an unknown state. 5.0
renamed `pausedDL` and `pausedUP` to `stoppedDL` and `stoppedUP`, and
`get_status` only knows the old names, so the torrent comes back as
state `unknown` with the raw state name as its message. The paused
handling in the download handler never applies to it, for example the
grace it gives a torrent that has not started. `forcedMetaDL` is missing
from the same table, so a force-started magnet fetching its metadata
also shows up as unknown, although it is already in the
incomplete-states set and the metadata wait.

The three missing states are now mapped like their neighbours: the two
stopped states as paused, `forcedMetaDL` as downloading with "Fetching
metadata". The state names are the ones `qbittorrent-api` lists in its
`TorrentState` enum.

Tests: one parametrized test covers the old and new paused names and
`forcedMetaDL` (the two old paused names are there as controls). The
three new cases fail without the change. Full suite passes.
2026-10-26 02:44:19 -06:00
dependabot[bot] 5d94e96bd9 build(deps): bump the python-deps group with 5 updates (#1586)
Bumps the python-deps group with 5 updates:

| Package | From | To |
| --- | --- | --- |
| [qbittorrent-api](https://github.com/rmartin16/qbittorrent-api) |
`2026.10.0` | `2026.10.1` |
| [publicsuffixlist](https://github.com/ko-zu/psl) | `1.1.0.20261018` |
`1.1.2.20261021` |
| [seleniumbase](https://github.com/seleniumbase/SeleniumBase) |
`4.56.2` | `4.56.3` |
| [prek](https://github.com/j178/prek) | `0.5.6` | `0.5.7` |
| [ruff](https://github.com/astral-sh/ruff) | `0.17.1` | `0.17.2` |

Updates `qbittorrent-api` from 2026.10.0 to 2026.10.1
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/rmartin16/qbittorrent-api/releases">qbittorrent-api's
releases</a>.</em></p>
<blockquote>
<h2>release-2026.10.1</h2>
<h2>What's Changed</h2>
<h3>Features</h3>
<ul>
<li>feat: support qBittorrent v5.3.0 by <a
href="https://github.com/rmartin16"><code>@​rmartin16</code></a> in <a
href="https://redirect.github.com/rmartin16/qbittorrent-api/pull/689">rmartin16/qbittorrent-api#689</a></li>
</ul>
<h3>Chores</h3>
<ul>
<li>build(deps): bump uv from 0.11.18 to 0.13.4 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/rmartin16/qbittorrent-api/pull/687">rmartin16/qbittorrent-api#687</a></li>
<li>build(deps): bump certifi from 2026.5.20 to 2026.10.19 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/rmartin16/qbittorrent-api/pull/688">rmartin16/qbittorrent-api#688</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/rmartin16/qbittorrent-api/compare/v2026.10.0...v2026.10.1">https://github.com/rmartin16/qbittorrent-api/compare/v2026.10.0...v2026.10.1</a></p>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/rmartin16/qbittorrent-api/blob/main/CHANGELOG.md">qbittorrent-api's
changelog</a>.</em></p>
<blockquote>
<h3>v2026.10.1 (21 oct 2026)</h3>
<ul>
<li>Advertise support for qBittorrent v5.3.0 (<a
href="https://redirect.github.com/rmartin16/qbittorrent-api/issues/689">#689</a>)</li>
<li>Existing changes that are now supported in qBittorrent:
<ul>
<li>Add support for <code>app/getFreeSpaceAtPath</code> (<a
href="https://redirect.github.com/rmartin16/qbittorrent-api/issues/659">#659</a>)</li>
<li>Add support for <code>rss/cloneRule</code> (<a
href="https://redirect.github.com/rmartin16/qbittorrent-api/issues/659">#659</a>)</li>
<li>Add support for <code>rss/exportRules</code> and
<code>rss/importRules</code> (<a
href="https://redirect.github.com/rmartin16/qbittorrent-api/issues/686">#686</a>)</li>
<li>Add support for <code>torrents/downloadFile</code> (<a
href="https://redirect.github.com/rmartin16/qbittorrent-api/issues/659">#659</a>)</li>
<li>Add support for <code>transfer/getSpeedLimits</code> and
<code>transfer/setSpeedLimits</code> (<a
href="https://redirect.github.com/rmartin16/qbittorrent-api/issues/659">#659</a>)</li>
<li>Add support for <code>transfer/pauseSession</code> and
<code>transfer/resumeSession</code> (<a
href="https://redirect.github.com/rmartin16/qbittorrent-api/issues/678">#678</a>)</li>
<li>Add <code>share_limits_mode</code> for <code>torrents/add</code> (<a
href="https://redirect.github.com/rmartin16/qbittorrent-api/issues/659">#659</a>)</li>
<li>Add <code>ignore_dotfiles</code> for
<code>torrentcreator/addTask</code> (<a
href="https://redirect.github.com/rmartin16/qbittorrent-api/issues/678">#678</a>)</li>
<li>Add <code>ratio_limit</code>, <code>seeding_time_limit</code>,
<code>inactive_seeding_time_limit</code>,
<code>share_limits_mode</code>, and <code>share_limit_action</code> for
<code>torrents/createCategory</code> and
<code>torrents/editCategory</code> (<a
href="https://redirect.github.com/rmartin16/qbittorrent-api/issues/686">#686</a>)</li>
<li><code>save_path</code> is no longer required for
<code>torrents/editCategory</code> (<a
href="https://redirect.github.com/rmartin16/qbittorrent-api/issues/686">#686</a>)</li>
</ul>
</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/rmartin16/qbittorrent-api/commit/8f6f243b3f7591fcfdb40af128a8d5c1dde7e2bc"><code>8f6f243</code></a>
feat: support qBittorrent v5.3.0 (<a
href="https://redirect.github.com/rmartin16/qbittorrent-api/issues/689">#689</a>)</li>
<li><a
href="https://github.com/rmartin16/qbittorrent-api/commit/b11988e0d4c5bfe94b6104807be60c22f612b8ba"><code>b11988e</code></a>
build(deps): bump certifi from 2026.5.20 to 2026.10.19 (<a
href="https://redirect.github.com/rmartin16/qbittorrent-api/issues/688">#688</a>)</li>
<li><a
href="https://github.com/rmartin16/qbittorrent-api/commit/8fcff6c6da1ee1489e82f5cbd9f2d737ba7da0d1"><code>8fcff6c</code></a>
build(deps): bump uv from 0.11.18 to 0.13.4 (<a
href="https://redirect.github.com/rmartin16/qbittorrent-api/issues/687">#687</a>)</li>
<li>See full diff in <a
href="https://github.com/rmartin16/qbittorrent-api/compare/v2026.10.0...v2026.10.1">compare
view</a></li>
</ul>
</details>
<br />

Updates `publicsuffixlist` from 1.1.0.20261018 to 1.1.2.20261021
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/ko-zu/psl/blob/master/CHANGES.md">publicsuffixlist's
changelog</a>.</em></p>
<blockquote>
<h3>1.1.1, 1.1.2 (2026-10-21)</h3>
<p>No code changes. This release only refreshes CI and development
tooling.</p>
<ul>
<li>Update <code>astral-sh/setup-uv</code> to v10.3.0 in the GitHub
Actions workflows.</li>
<li>Refresh <code>uv.lock</code>, updating <code>certifi</code> to pick
up the bundled CA certificate
changes.</li>
<li>Fix the auto-release workflow so that it no longer bundles a stale
PSL file.</li>
</ul>
<h3>1.1.0 (2026-10-10)</h3>
<p>This version drops support for Python 3.5, 3.6 and 3.7, and now
requires
Python 3.8 or later.</p>
<p>Packaging has been migrated to PEP 621 metadata in
<code>pyproject.toml</code> using
hatchling, replacing <code>setup.py</code>, <code>setup.cfg</code> and
<code>MANIFEST.in</code>. The version is
now read from <code>publicsuffixlist.__version__</code>, and development
dependencies are
locked with uv.</p>
<ul>
<li>Add PEP 561 <code>py.typed</code> marker and complete type
annotations for the public
API, including <code>compat</code> and the update script.</li>
<li>Add an <code>accept_unknown</code> keyword argument to
<code>is_private()</code> and <code>is_public()</code>.</li>
<li>Fix <code>subdomain()</code> to honor the per-call
<code>accept_unknown</code> argument, and to
return <code>None</code> for invalid domains instead of raising
<code>TypeError</code>.</li>
<li>Correct the <code>subdomain()</code> and <code>privateparts()</code>
overload signatures.</li>
<li>Handle a malformed <code>Last-Modified</code> response header in the
update script.</li>
<li>Run tests with uv on Python 3.8 and 3.14, and update CI
actions.</li>
</ul>
<h3>1.0.2 (2024-07-13)</h3>
<ul>
<li>Use <a
href="https://github.com/typing"><code>@​typing</code></a>.overload to
indicate str-to-str domain input/output. <a
href="https://redirect.github.com/ko-zu/psl/issues/32">#32</a>.</li>
<li>Address deprecation of ByteString in python 3.14.</li>
</ul>
<h3>1.0.1 (2024-06-22)</h3>
<p>Fix internal logic where domain name passed as a tuple of bytes, that
contains
UTF-8 encoded non-ascii chars, unintentionally matched PSL. Fixes <a
href="https://redirect.github.com/ko-zu/psl/issues/31">#31</a>.</p>
<h3>1.0.0 (2024-06-20)</h3>
<p>This version drops support for Python 2.x and 3.4.</p>
<p>The internal logic has been slightly changed to address conflicting
evaluation
rules between the PSL wiki and the test data.
As with versions before 1.0.0, this module assumes that wildcards have
implicit public declarations on the wildcard root.</p>
<p>With the minimum Python version changed to 3.5, type annotations have
been added to exposed API methods to clarify expected input types.
For example, psl.publicsuffix() now accepts <code>str</code> or
<code>Tuple[bytes, ...]</code>.</p>
<ul>
<li>Add tuple of bytes style input</li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li>See full diff in <a
href="https://github.com/ko-zu/psl/commits">compare view</a></li>
</ul>
</details>
<br />

Updates `seleniumbase` from 4.56.2 to 4.56.3
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/seleniumbase/SeleniumBase/releases">seleniumbase's
releases</a>.</em></p>
<blockquote>
<h2>4.56.3 - Refresh dependencies for security</h2>
<h2>Refresh dependencies for security</h2>
<ul>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/ce39e995cd50d821b2b145b775ac4b06b775bf2b">Refresh
Python dependencies</a>
--&gt; This resolves <a
href="https://redirect.github.com/seleniumbase/SeleniumBase/issues/4531">seleniumbase/SeleniumBase#4531</a></li>
</ul>
<h2>What's Changed</h2>
<ul>
<li>Refresh dependencies for security by <a
href="https://github.com/mdmintz"><code>@​mdmintz</code></a> in <a
href="https://redirect.github.com/seleniumbase/SeleniumBase/pull/4532">seleniumbase/SeleniumBase#4532</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/seleniumbase/SeleniumBase/compare/v4.56.2...v4.56.3">https://github.com/seleniumbase/SeleniumBase/compare/v4.56.2...v4.56.3</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/ea0a30dc546fa82c11b6c5e2029f20238cd9cc75"><code>ea0a30d</code></a>
Merge pull request <a
href="https://redirect.github.com/seleniumbase/SeleniumBase/issues/4532">#4532</a>
from seleniumbase/refresh-dependencies-for-security</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/80bdd561374ab2a971ae6f0b1e443f5d0253d4c3"><code>80bdd56</code></a>
Version 4.56.3</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/31580882ad87b4435babc612c54878ec56c24e7a"><code>3158088</code></a>
Update MCP server versioning</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/ce39e995cd50d821b2b145b775ac4b06b775bf2b"><code>ce39e99</code></a>
Refresh Python dependencies</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/c314a34b85fe382c89e300e44caadaaaff4fb0e8"><code>c314a34</code></a>
Update Hacker News web-scraping examples</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/b82016faa306f499f73cdd4581d2965fbe04421b"><code>b82016f</code></a>
Update CDP Mode examples</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/effe113bead069ae40e4e93e14e00fa45b021c40"><code>effe113</code></a>
Update the mkdocs prepare script</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/5596ac6d2eda7689e59f5aa448b44ae8cea3be7d"><code>5596ac6</code></a>
Update the ReadMe</li>
<li>See full diff in <a
href="https://github.com/seleniumbase/SeleniumBase/compare/v4.56.2...v4.56.3">compare
view</a></li>
</ul>
</details>
<br />

Updates `prek` from 0.5.6 to 0.5.7
<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.7</h2>
<h2>Release Notes</h2>
<p>Released on 2026-10-21.</p>
<h3>Enhancements</h3>
<ul>
<li>Support system Rust toolchains without rustup (<a
href="https://redirect.github.com/j178/prek/pull/2887">#2887</a>)</li>
</ul>
<h3>Performance</h3>
<ul>
<li>Use blocking file checks for CPU-heavy builtin hooks (<a
href="https://redirect.github.com/j178/prek/pull/2910">#2910</a>)</li>
</ul>
<h3>Other changes</h3>
<ul>
<li>Update CA root crates and uv for the eMudhra intermediate CA
compromise (<a
href="https://redirect.github.com/j178/prek/pull/2919">#2919</a>)</li>
</ul>
<h3>Contributors</h3>
<ul>
<li><a
href="https://github.com/shaanmajid"><code>@​shaanmajid</code></a></li>
<li><a href="https://github.com/j178"><code>@​j178</code></a></li>
</ul>
<h2>Install prek 0.5.7</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.7/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.7/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.7</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.7/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.7/prek-aarch64-apple-darwin.tar.gz.sha256">checksum</a></td>
</tr>
<tr>
<td><a
href="https://github.com/j178/prek/releases/download/v0.5.7/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.7/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.7/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.7/prek-aarch64-pc-windows-msvc.zip.sha256">checksum</a></td>
</tr>
<tr>
<td><a
href="https://github.com/j178/prek/releases/download/v0.5.7/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.7/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/main/CHANGELOG.md">prek's
changelog</a>.</em></p>
<blockquote>
<h2>0.5.7</h2>
<p>Released on 2026-10-21.</p>
<h3>Enhancements</h3>
<ul>
<li>Support system Rust toolchains without rustup (<a
href="https://redirect.github.com/j178/prek/pull/2887">#2887</a>)</li>
</ul>
<h3>Performance</h3>
<ul>
<li>Use blocking file checks for CPU-heavy builtin hooks (<a
href="https://redirect.github.com/j178/prek/pull/2910">#2910</a>)</li>
</ul>
<h3>Other changes</h3>
<ul>
<li>Update CA root crates and uv for the eMudhra intermediate CA
compromise (<a
href="https://redirect.github.com/j178/prek/pull/2919">#2919</a>)</li>
</ul>
<h3>Contributors</h3>
<ul>
<li><a
href="https://github.com/shaanmajid"><code>@​shaanmajid</code></a></li>
<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/8569a4ac59a79212fe152f3c815505775301ec1d"><code>8569a4a</code></a>
Bump version to 0.5.7 (<a
href="https://redirect.github.com/j178/prek/issues/2920">#2920</a>)</li>
<li><a
href="https://github.com/j178/prek/commit/c290bac7b2bc85e5d113bd3f20585b48f07c4cbe"><code>c290bac</code></a>
Bump certifi from 2026.7.22 to 2026.10.19 (<a
href="https://redirect.github.com/j178/prek/issues/2921">#2921</a>)</li>
<li><a
href="https://github.com/j178/prek/commit/40287b8205068b2a0c01448e59b9ac7116763c6d"><code>40287b8</code></a>
Temporarily skip Aliyun uv download in CI (<a
href="https://redirect.github.com/j178/prek/issues/2922">#2922</a>)</li>
<li><a
href="https://github.com/j178/prek/commit/062c8a0bd20cd04ea815fd1820671c42e748a39d"><code>062c8a0</code></a>
Update CA root crates and uv for the eMudhra intermediate CA compromise
(<a
href="https://redirect.github.com/j178/prek/issues/2919">#2919</a>)</li>
<li><a
href="https://github.com/j178/prek/commit/5b66378566f209aaaf71656540f2dac852ffb125"><code>5b66378</code></a>
Log download and archive extraction stages (<a
href="https://redirect.github.com/j178/prek/issues/2917">#2917</a>)</li>
<li><a
href="https://github.com/j178/prek/commit/c3693539c41d72323190d93ccab5cd312d8fcd2d"><code>c369353</code></a>
Share tar extraction across compression formats (<a
href="https://redirect.github.com/j178/prek/issues/2916">#2916</a>)</li>
<li><a
href="https://github.com/j178/prek/commit/1c82a0d0f01ecf06df2745bde2a997c742be3c90"><code>1c82a0d</code></a>
Move managed Rust toolchain discovery into the installer (<a
href="https://redirect.github.com/j178/prek/issues/2914">#2914</a>)</li>
<li><a
href="https://github.com/j178/prek/commit/2bf1958a626bd61f35c62f185aa4cf3681bf9dd2"><code>2bf1958</code></a>
Support system Rust toolchains without rustup (<a
href="https://redirect.github.com/j178/prek/issues/2887">#2887</a>)</li>
<li><a
href="https://github.com/j178/prek/commit/812e80f6d52d39db3627e0d666842919ed3ce864"><code>812e80f</code></a>
Move shared hook helpers and rename run to concurrency (<a
href="https://redirect.github.com/j178/prek/issues/2911">#2911</a>)</li>
<li><a
href="https://github.com/j178/prek/commit/615a0016fbad79be1cfb63b15cc7288ea9c6046b"><code>615a001</code></a>
Use blocking file checks for CPU-heavy builtin hooks (<a
href="https://redirect.github.com/j178/prek/issues/2910">#2910</a>)</li>
<li>See full diff in <a
href="https://github.com/j178/prek/compare/v0.5.6...v0.5.7">compare
view</a></li>
</ul>
</details>
<br />

Updates `ruff` from 0.17.1 to 0.17.2
<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.17.2</h2>
<h2>Release Notes</h2>
<p>Released on 2026-10-22.</p>
<h3>Preview features</h3>
<ul>
<li>[<code>complexity</code>] Add
<code>unnecessary-regular-expression-compile</code> (<a
href="https://redirect.github.com/astral-sh/ruff/pull/25533">#25533</a>)</li>
</ul>
<h3>Bug fixes</h3>
<ul>
<li>Avoid syntax errors for type variables with starred constraints
(<code>UP040</code>, <code>UP046</code>, <code>UP047</code>,
<code>RUF053</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/28362">#28362</a>)</li>
<li>[<code>pydocstyle</code>] Treat backslash-escaped <code>*args</code>
and <code>**kwargs</code> as documented (<code>D417</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/29536">#29536</a>)</li>
<li>[<code>pylint</code>] Avoid false positive on starred elements
(<code>PLE0643</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/29483">#29483</a>)</li>
<li>[<code>pylint</code>] Mark fixes unsafe when deleting comments
(<code>PLR5501</code>, <code>PLW0120</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/29414">#29414</a>)</li>
<li>[<code>pyupgrade</code>] Mark fix unsafe when deleting comments
(<code>UP035</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/29413">#29413</a>)</li>
</ul>
<h3>Performance</h3>
<ul>
<li>Avoid intermediate names in lint name resolution (<a
href="https://redirect.github.com/astral-sh/ruff/pull/29506">#29506</a>)</li>
<li>Prefilter bidirectional Unicode checks by UTF-8 leading bytes (<a
href="https://redirect.github.com/astral-sh/ruff/pull/29561">#29561</a>)</li>
<li>Share postfix parsing and reduce expression copies (<a
href="https://redirect.github.com/astral-sh/ruff/pull/29480">#29480</a>)</li>
</ul>
<h3>Configuration</h3>
<ul>
<li>[<code>pylint</code>] Add a configurable allowlist for
<code>magic-value-comparison</code> (<code>PLR2004</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/18961">#18961</a>)</li>
</ul>
<h3>Documentation</h3>
<ul>
<li>Clarify documentation and correct stale guidance (<a
href="https://redirect.github.com/astral-sh/ruff/pull/29374">#29374</a>)</li>
<li>[<code>pyupgrade</code>] Remove mentions of variance related to fix
safety (<code>UP040</code>, <code>UP047</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/29592">#29592</a>)</li>
</ul>
<h3>Contributors</h3>
<ul>
<li><a href="https://github.com/fixfon"><code>@​fixfon</code></a></li>
<li><a
href="https://github.com/MichaReiser"><code>@​MichaReiser</code></a></li>
<li><a
href="https://github.com/zaniebot"><code>@​zaniebot</code></a></li>
<li><a
href="https://github.com/FrancescoPezzella"><code>@​FrancescoPezzella</code></a></li>
<li><a
href="https://github.com/anishgirianish"><code>@​anishgirianish</code></a></li>
<li><a
href="https://github.com/rajveer3x"><code>@​rajveer3x</code></a></li>
<li><a
href="https://github.com/charliermarsh"><code>@​charliermarsh</code></a></li>
<li><a
href="https://github.com/LckyLke"><code>@​LckyLike</code></a></li>
<li><a
href="https://github.com/DaniBodor"><code>@​DaniBodor</code></a></li>
</ul>
<h2>Install ruff 0.17.2</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.17.2/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.17.2</h2>
<p>Released on 2026-10-22.</p>
<h3>Preview features</h3>
<ul>
<li>[<code>complexity</code>] Add
<code>unnecessary-regular-expression-compile</code> (<a
href="https://redirect.github.com/astral-sh/ruff/pull/25533">#25533</a>)</li>
</ul>
<h3>Bug fixes</h3>
<ul>
<li>Avoid syntax errors for type variables with starred constraints
(<code>UP040</code>, <code>UP046</code>, <code>UP047</code>,
<code>RUF053</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/28362">#28362</a>)</li>
<li>[<code>pydocstyle</code>] Treat backslash-escaped <code>*args</code>
and <code>**kwargs</code> as documented (<code>D417</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/29536">#29536</a>)</li>
<li>[<code>pylint</code>] Avoid false positive on starred elements
(<code>PLE0643</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/29483">#29483</a>)</li>
<li>[<code>pylint</code>] Mark fixes unsafe when deleting comments
(<code>PLR5501</code>, <code>PLW0120</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/29414">#29414</a>)</li>
<li>[<code>pyupgrade</code>] Mark fix unsafe when deleting comments
(<code>UP035</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/29413">#29413</a>)</li>
</ul>
<h3>Performance</h3>
<ul>
<li>Avoid intermediate names in lint name resolution (<a
href="https://redirect.github.com/astral-sh/ruff/pull/29506">#29506</a>)</li>
<li>Prefilter bidirectional Unicode checks by UTF-8 leading bytes (<a
href="https://redirect.github.com/astral-sh/ruff/pull/29561">#29561</a>)</li>
<li>Share postfix parsing and reduce expression copies (<a
href="https://redirect.github.com/astral-sh/ruff/pull/29480">#29480</a>)</li>
</ul>
<h3>Configuration</h3>
<ul>
<li>[<code>pylint</code>] Add a configurable allowlist for
<code>magic-value-comparison</code> (<code>PLR2004</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/18961">#18961</a>)</li>
</ul>
<h3>Documentation</h3>
<ul>
<li>Clarify documentation and correct stale guidance (<a
href="https://redirect.github.com/astral-sh/ruff/pull/29374">#29374</a>)</li>
<li>[<code>pyupgrade</code>] Remove mentions of variance related to fix
safety (<code>UP040</code>, <code>UP047</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/29592">#29592</a>)</li>
</ul>
<h3>Contributors</h3>
<ul>
<li><a href="https://github.com/fixfon"><code>@​fixfon</code></a></li>
<li><a
href="https://github.com/MichaReiser"><code>@​MichaReiser</code></a></li>
<li><a
href="https://github.com/zaniebot"><code>@​zaniebot</code></a></li>
<li><a
href="https://github.com/FrancescoPezzella"><code>@​FrancescoPezzella</code></a></li>
<li><a
href="https://github.com/anishgirianish"><code>@​anishgirianish</code></a></li>
<li><a
href="https://github.com/rajveer3x"><code>@​rajveer3x</code></a></li>
<li><a
href="https://github.com/charliermarsh"><code>@​charliermarsh</code></a></li>
<li><a
href="https://github.com/LckyLke"><code>@​LckyLike</code></a></li>
<li><a
href="https://github.com/DaniBodor"><code>@​DaniBodor</code></a></li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/astral-sh/ruff/commit/30516b42ab972ffa2569e5efcfee81135da466d3"><code>30516b4</code></a>
Bump version to 0.17.2 (<a
href="https://redirect.github.com/astral-sh/ruff/issues/29637">#29637</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/59a9e85a7b6dc4e4592cd29de32a214c150d663c"><code>59a9e85</code></a>
[ty] Generalize nested types in inferred return types (<a
href="https://redirect.github.com/astral-sh/ruff/issues/29488">#29488</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/0d562aac5d7c9adae13c8a4ac69dc106d2a331ff"><code>0d562aa</code></a>
[ty] Check inherited attribute conflicts (<a
href="https://redirect.github.com/astral-sh/ruff/issues/28559">#28559</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/48dcdd0e65ee4320da2ceff3e76a8b10cbc5d53b"><code>48dcdd0</code></a>
[ty] Restrict range implications from gradual equivalence (<a
href="https://redirect.github.com/astral-sh/ruff/issues/29577">#29577</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/734d75ae5baf6471ac37dd9b766fceecd8a09497"><code>734d75a</code></a>
[ty] Check for unsatisfiable bodies before existentially quantifying (<a
href="https://redirect.github.com/astral-sh/ruff/issues/29504">#29504</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/b0f95269f9dc6e14dd4ac8738d99f211f75d73bc"><code>b0f9526</code></a>
[ty] Preserve checks for recursive descriptor setter types (<a
href="https://redirect.github.com/astral-sh/ruff/issues/29148">#29148</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/32869b468e607e77bc0331881e18f9c4f2d5fc6d"><code>32869b4</code></a>
Avoid creating deployments for automation jobs (<a
href="https://redirect.github.com/astral-sh/ruff/issues/29626">#29626</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/ec891bab3b4dbd18bee7ce5d61097044a8d62acd"><code>ec891ba</code></a>
Link the commit in pull request report footers (<a
href="https://redirect.github.com/astral-sh/ruff/issues/29621">#29621</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/895874820940b48192b7d3d27e55b7fd5ad136f2"><code>8958748</code></a>
[ty] Publish ecosystem reports from GitHub Actions (<a
href="https://redirect.github.com/astral-sh/ruff/issues/29609">#29609</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/01f539487f851a8fe7963388e22b147824df3a1f"><code>01f5394</code></a>
[ty] Publish typing conformance reports from GitHub Actions (<a
href="https://redirect.github.com/astral-sh/ruff/issues/29608">#29608</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/astral-sh/ruff/compare/0.17.1...0.17.2">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-10-26 02:40:31 -06:00
splitsec2 f02f0d7365 fix(notifications): say what applies on a user's own notification routes page (#1588)
This is the UI side of #1581. A non-admin looking at their own
notification routes reads the same help text the admin does, including
"or mailtos://...?to={Email} in the admin default". They can't set that
default, a personal route can't use mailtos (personal routes are limited
to ntfy, Gotify, Discord, Slack, Telegram, Pushover and PushBullet), and
nothing on the page says whether an admin default exists. The default's
URL is hidden from them on purpose since it can carry credentials, so
the page just shows an empty All row, the same as when nothing is set
up.

What changes, on the user's own page only (the admin's view of a user
keeps the full text):

- The help text drops the admin default and mailtos part and lists the
services a personal route can use.
- The edit-context response now carries `adminDefaultRoutesSet`, a
boolean that is true when an admin default route with a URL exists. The
URL itself is still not sent, and the existing test that checks nothing
from the admin's values leaks still passes.
- When that is true, the help text adds a line saying a default applies
until the user adds their own, and once they have, that theirs replace
it.

How routes are resolved is unchanged. A user's own routes still replace
the default and don't merge with it. I didn't try to add mailtos for
personal routes, since keeping SMTP credentials in a user's own URL is
what the admin default avoids.

Tests: two route tests (flag true with a URL in the admin default, false
with an empty row), a vitest file for the description text, and the full
frontend suite, oxlint, oxfmt, tsc, ruff and basedpyright are clean. I
haven't looked at the page in a browser.
2026-10-26 01:32:35 -06:00
splitsec2 46b9e7d8a3 fix(library): match a shelf title against the full title when the search title is only the series (#1587)
Fixes #1584. Thanks to @joeRob2468 for the write-up, which pinned this
down to the line.

Hardcover cuts a title like "Mistborn: The Final Empire" down to
"Mistborn" for searching when it can't tell the series prefix from a
title prefix, and search results don't carry the series. The library
check then matched the shelf title "Mistborn: The Final Empire" against
"Mistborn", saw "final empire" as extra words and took it for a longer,
different work (the "Dune" vs "Dune Messiah" rule), so the book got no
In library badge.

`match_entries` now tries the search title first as before and, if that
finds nothing and the full title is different, tries the full title too.
The full title is the more specific of the two so it should rarely add
false positives. Another book in the same series still doesn't match,
there is a test for that, and the Dune and Dune Messiah test passes
unchanged. The matching loop moved into a small helper to run it twice,
nothing in it changed.

This only touches the library check. The same short search title also
feeds release searches and auto-download matching, which I left alone
here because loosening or tightening those changes what gets searched
and downloaded for everyone.

Tests fail on current main without the change. The library tests, ruff
and basedpyright are clean.
2026-10-26 01:30:33 -06:00
Josh Theisen e3fd7898f6 fix(user-notifications): simplify row key serialization in TableField and clean up UserNo… (#1583)
- simplify row key serialization in TableField
- clean up userNotificationOverridesSection

Previously, after typing a single character, the browser would lose
focus due to the row keys changes on every new value. The fix makes the
row key independent of the URL’s changing text.

Additionally, fixed the per-user notification routes so that unchecking
"All" clears all event selections.
2026-10-26 00:51:41 -06:00
splitsec2 ac14e4dd24 fix(downloads): a swarm note change is not activity for the stall timer (#1572)
The torrent stall timer can be deferred for as long as the swarm keeps
changing. Since #1494 the torrent handler appends a note to its progress
message (`0% · 2 seeds`, and `· 0 seeds, giving up in 7m` while the
no-seeders clock runs). `update_download_status` treats any changed
message as activity, so a seed count that goes 2, 3, 1 between polls, or
the give-up countdown ticking down every minute, refreshes
`_last_activity` and the torrent never stalls. A torrent whose seed
count stays constant still stalls, which is why this is easy to miss.

`shelfmark/download/activity.py` already says what counts: a changed
status event or a changed progress value, and a message that changes on
a timer proves nothing. So `update_download_status` now compares
messages through a small `activity_key()` that drops that trailing seed
note. The card still gets every message and shows the live seed count,
only the stall clock ignores a change in the note. A new progress
percentage, a different client message or a status change still counts
as activity.

The handler builds the note in `_seed_note` and `activity_key` has to
match it, so one test builds the note for a range of seed counts and
countdowns and checks the key strips all of them. That also covers the
gap #1494 left, where no test connected a handler's message to the
orchestrator: the other two drive `update_download_status` with real
notes and a fake clock. Without the change the note test fails and the
key tests cannot even import `activity_key`. The other orchestrator test
is a control that a progress or status change still counts. Full suite
passes, and each new test also passes when run alone.
2026-10-26 00:45:28 -06:00
misko903andClaude Opus 5.5 d50440f6c2 fix(webshare): clear the "Resolving" status once the download starts (#1575)
## What

A Webshare.cz download shows "Resolving Webshare link" for its entire
duration, so a large download (e.g. a 1.3 GB audiobook archive) looks
stuck.

## Why

After the link was resolved, the handler called
`status_callback("downloading", None)`. `update_download_status` treats
a `None` message as "keep the current message", so the "Resolving
Webshare link" detail stayed in the activity panel. The HTTP handler
sends `""` for exactly this reason.

## How

- Report `downloading` with an empty message, which clears the resolving
text.
- When the download response has no `Content-Length`, use the release's
size (`task.size`, parsed with `parse_size_string`) as the total for
progress. Without it, progress never moved, and stall detection would
flag a healthy long download after 5 minutes.

## Testing

- New tests in `tests/webshare/test_handler.py`: the downloading status
clears the message, and progress is computed from the release size when
there is no Content-Length.
- `uv run pytest tests/webshare` is green (34 passed), and `ruff check`
and `ruff format --check` are clean.

Follow-up to #1543.

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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 11:00:15 -04:00
splitsec2 525b094b57 fix(clients): hand a finished wrong-type directory on instead of polling out the timeout (#1573)
A torrent that finishes with the wrong kind of files in it holds the
download for the whole completed-path timeout (60 seconds by default, up
to an hour) and then fails with "Completed download has no stable
supported files yet." An ebook task whose torrent holds only an `.m4b`,
an audiobook task that got an `.epub`, or a `.pdf`-only release with the
default format list all land here. In v1.4.0 these failed with the
specific format error from post-processing, but the wait for the sync to
settle (#1464) only counts files of the task's own type, so it keeps
polling for files that are never going to appear.

If the directory has no supported files, `_wait_for_completed_path` now
looks for finished book or audio files that this task does not accept
(the audiobook formats, the default ebook formats and pdf). When it
finds some and they are the same on two polls in a row, it returns the
path so post-processing can say what is wrong with the formats. Cover
art and notes alone do not count, because that is what a directory looks
like while the real files are still syncing over a remote mount, so
those keep waiting as before. A wrong-type file that is still growing
keeps waiting too. With no wait configured there is only one sample, and
a wrong-type file is handed on straight away.

The existing test that pins "no book files, no wait configured" uses a
jpg, so it stays as it is. I extended the test helper to take a content
type, which the wait tests never passed before. Four new cases fail
without the change (a wrong-type directory for ebook, audiobook and pdf
tasks), and two more check that sidecar-only and still-growing
directories keep waiting. Full suite passes.
2026-10-24 20:41:21 -06:00
splitsec2 47f0acdd31 fix(logging): mask userinfo and path-segment credentials, route stray loggers through the formatter (#1571)
Logs get pasted into issues, and the formatter from #1488 only masks
`?key=value` query parameters. I went through what else commonly ends up
in a log line and found these unmasked:

- credentials in a URL's userinfo, like a proxy's
`socks5://user:pass@host:1080` or `http://admin:secret@qbittorrent:8080`
- a passkey as a path segment, like `/rss/<key>` or
`/passkey/<key>/get.torrent`, which private trackers use
- the token in a Telegram bot, Discord webhook or Slack webhook URL,
which is what a notification URL looks like when it gets logged

All three are masked now, in `redact_secrets`, so they apply to every
handler. The path rule only fires after a segment that names a secret
(`passkey`, `apikey`, `rsskey`, `authkey`, `token`, `secret`, `rss`,
`torznab`) followed by 16 or more token characters. I left out generic
words like `download` on purpose, because `/api/download/<md5>/cancel`
appears in our own request log and the ids there are useful when
debugging. That is the tradeoff: a passkey in a `/download/<passkey>/`
path still gets through.

Separately, four modules created their logger with plain
`logging.getLogger`, so nothing they logged went through the formatter
at all: `api/websocket.py`, `download/clients/__init__.py`, and the
Jackett, Newznab and Prowlarr package inits. They use `setup_logger` now
like everything else.

Tests: ten cases, seven for the new shapes and three that must stay
readable (an md5 route id, a short `/rss/latest`, an email address),
plus two that check the stray modules now carry the redacting formatter.
Nine of them fail without the change. Full suite passes.

Not covered here: `/api/debug` still zips the existing log files as they
are on disk, so anything logged before this change rides along in a
bundle. That is a separate change in `genDebug.sh`.
2026-10-24 17:19:53 -06:00
splitsec2 ef40a38546 fix: report a stopped download coordinator in /api/health (#1568)
If the download coordinator thread dies, every task sits at "Queued" and
nothing says why. The rest of the app keeps answering, `/api/health`
stays green and the container looks fine. #823 and #1166 were both that,
and the loop's `except Exception` cannot catch everything that can end a
thread.

`/api/health` now reports it. The response gets a `coordinator` field,
`running` or `stopped`, and when it is stopped the status is `error` and
the HTTP code is 503. The Docker healthcheck in the Dockerfile and the
one in the readme both use `curl -f`, so the container shows as
unhealthy and anything watching health (Uptime Kuma, autoheal) can act
on it. It needs no login, same as before, and shows no queue contents.

That 503 is the one decision here. Keeping the 200 and only adding a
`degraded` entry would not trip a status-code health check, which is the
whole point, so I went with 503. Easy to flip if you would rather not.

The orchestrator gets a small `coordinator_running()` that asks whether
the thread is alive. `start()` already knows how to restart a dead
thread, so restarting it automatically would be the true fix, but I
would rather the failure be visible first and decide on that separately.

Tests: three for the route (ok, 503, no login needed) and three for
`coordinator_running` (never started, alive, dead). They fail without
the change. One existing test hits `/api/health` through an app that
never starts the coordinator, so it now patches that. The readme's
health section has a sentence about the 503. Full suite passes, ruff and
basedpyright are clean.
2026-10-24 17:12:58 -06:00
splitsec2 973cf472a2 test(image-cache): flush window test no longer depends on float rounding (#1580)
`test_cache_hit_flushes_the_index_once_the_window_has_passed` (from
#1544) failed on the first attempt of two of my PRs' Python Tests runs
today (#1576, #1579) and passed on rerun, with nothing in either PR
touching the image cache.

The test sets the clock to exactly `last_saved + ACCESS_FLUSH_SECONDS`
and expects a flush. `(now + 30) - now` isn't always 30.0 in floating
point. For example `105.65207077218265 + 30 - 105.65207077218265` is
`29.999999999999986`, so the `>=` check is false and the flush never
happens. That only shows up when the monotonic value is small, which
fits a freshly booted runner (on this box the uptime is in the millions
of seconds and it never fails). I haven't been able to see the runner's
actual uptime in the CI logs, so that part is an inference, but pinning
`_last_saved` to that value reproduces the exact failure (`assert [] ==
[1]`) locally.

The test now puts the clock one second past the window. The production
check is fine, real elapsed time doesn't have this edge, so only the
test changes.
2026-10-24 17:10:08 -06:00
splitsec2 b248bcd536 fix(clients): import a qBittorrent torrent only when every listed file is on disk (#1577)
Fixes #1561, with thanks to @tmussche for the write-up, which had the
cause and the fix. When the files reach Shelfmark through a sync
(Resilio in the report), the wait for the completed path accepts the
folder once two samples a few seconds apart look the same. A sync that
copies in batches can pause longer than that, so 10 of 23 files were
imported and some of those were still partial copies.

For qBittorrent the client can say what the finished download holds, so
the wait now checks that. `DownloadClient` gets an optional
`get_expected_files()` that returns `ExpectedFile(name, size)` entries,
or None when the client cannot say, which is the default and keeps every
other client as it was. qBittorrent answers it from
`/api/v2/torrents/files`, leaving out files the torrent does not
download (priority 0), zero-byte entries and BEP 47 padding files.
`_wait_for_completed_path` asks once, and while any of those files is
missing or smaller than its final size it keeps waiting, using the same
timeout as before. When the wait runs out the error now says how far it
got, for example "Completed download is not fully on disk yet (10 of 23
files)." Once everything is there the existing stability check still
runs, so nothing is accepted that was accepted before.

Names from the client start with the torrent's top-level folder, so each
file is looked up with and without it, which also covers a torrent saved
without a subfolder. A single-file torrent is checked by size too, since
a file that is still syncing exists at a smaller size.

Two things I would like a second look at: the comparison is on exact
size, so a setup where something rewrites the files after the torrent
finishes would never match and would run into the timeout; and the file
list is read once per wait, not on every poll. If the client does not
answer, nothing changes.

I also moved the nested authenticated GET in the path derivation into a
`_get_with_auth` method so both calls share it, and added a sentence
about this to `docs/configuration.md`. Tests: eight for the wait
(partial folder, truncated file, files arriving during the wait,
complete folder, no top-level folder, single file both ways, and a
client without a list), and six for the qBittorrent file list. The wait
tests that depend on the new behaviour fail on current main. Full suite
passes.
2026-10-24 17:08:04 -06:00
splitsec2 9011319ade refactor: one lookup for the supported formats, public names for two shared helpers (#1576)
Six places read the Supported Book Formats and Supported Audiobook
Formats settings on their own: post-processing, the post-process scan,
IRC, slskd, Prowlarr and direct download. For ebooks they already
agreed, since they all fall back to `DEFAULT_EBOOK_FORMATS`. For
audiobooks they did not. The settings registry and slskd fall back to
every audio format plus zip and rar, while post-processing and IRC fell
back to m4b and mp3 only.

They now share `shelfmark/core/formats.py`, with
`get_supported_formats`, `get_supported_audiobook_formats` and a
content-type aware `get_supported_formats_for`, and a shared
`DEFAULT_AUDIOBOOK_FORMATS` in `core/utils.py` next to the ebook one.
`policy.py` re-exports the same functions, so nothing that imports them
from there changes. It is not in `policy.py` itself because importing
the `postprocess` package pulls in the router, which imports the release
sources, so a source could not import from it without a cycle.

One behaviour difference, and only in the fallback: with the audiobook
setting unset, or emptied, post-processing and IRC used to accept m4b
and mp3 and now accept the full default list like slskd and the settings
page already did. A configured list is read exactly as before. Direct
download's own helper used the raw configured list and now reads the
same normalized one, which is identical for any list the settings page
can save.

Also public now: `parse_size` in the Prowlarr source (slskd and Newznab
imported it with the underscore) and
`coerce_completed_path_timeout_seconds` in the client base handler
(slskd imported that too).

Not changed on purpose: slskd's `_coerce_int` looks like a copy of
`coerce_int` but is not. It reads `"12.0"` as 12 and `True` as 0, where
`coerce_int` gives 0 and 1. And the `_parse_size` in the IRC source is a
different function, string to bytes.

Tests: a new set checks that post-processing, IRC and slskd all fall
back to the shared defaults and that the settings default is the shared
one, and the scan-archive and IRC tests now patch the shared config
instead of the per-module copies. Full suite passes.
2026-10-24 16:31:07 -06:00
splitsec2 606e94fde2 fix(settings): offer the seed settings for qBittorrent only (#1574)
The two seed settings, Give Up With No Seeders and Minimum Seeders, show
up for every torrent client, but only qBittorrent reports a seed count.
With Transmission, Deluge or rTorrent you can set both and nothing
happens. The first one says "Only qBittorrent reports seed counts" at
the end of its description, the second says nothing about it.

Both are now shown only when qBittorrent is the selected client, the
same `show_when` the other qBittorrent fields use, and Minimum Seeders
gets the same sentence. A value someone already saved for another client
stays in their config, it just is not shown, and it had no effect before
either.

`docs/environment-variables.md` is regenerated for the changed
description, which is two lines. One test checks that both fields are
tied to qBittorrent and say so, and it fails without the change.
2026-10-24 09:15:55 -06:00
splitsec2 70fe4b8c89 fix(hardcover): keep the scheduler alive on an unreadable interval, share one history connection per sync (#1569)
These are the follow-ups from the review notes on #1516 and #1534 that I
said I would do once the series had merged.

The scheduler waits for the next cycle outside the try that guards the
cycle itself, so an interval setting that raised would end the thread
and nothing would sync again until a restart. It now logs the error and
tries again after six hours. `docs/hardcover-sync.md` says so.

The history check in the sync opened a new SQLite connection for every
book on the shelf. It opens one per sync now and shares it, which adds
up on a slow disk or a small board.

The two test nits from #1534 are in as well: a `parametrize` with a
single case, and an unused argument on a helper.

The thread surviving a failed wait and the shared connection each have a
test that fails without the change. Ruff, basedpyright and the backend
suite pass (5297 passed).
2026-10-24 09:11:47 -06:00
splitsec2 a63d9e3e21 docs: regenerate the environment variable reference (#1565)
Sorry about this one. The environment variable references had also
fallen well behind, and again I was a big part of that. A bunch of the
gaps come from my own PRs (Hardcover sync, auto-download, the
Audiobookshelf library check) where I charged ahead with the
functionality and skipped the boring documentation part (did I mention
that documentation is boring?).

I again had Claude compare the docs to the code and generate the
updates, so it would be good to have another human look this over before
it merges. The doc diff is just the generator's output. The part worth a
real read is the handful of new entries in
`scripts/generate_env_docs.py`, since I wrote those descriptions from
`entrypoint.sh` and `tor.sh` and not from a settings definition.

What changed:

- Regenerated `docs/environment-variables.md`. It had 287 variables and
the generator now produces 311. The 21 that were missing are the
Hardcover Sync tab (6), auto-download (11) and the Audiobookshelf
library check (4). Nothing was removed and no existing default, type or
option changed.
- `SHELFMARK_API_KEY_READONLY` was only ever hand-edited into the doc,
so the generator did not know it and a plain regeneration would have
dropped the row. It is in the generator now.
- Added `UMASK`, `TOR_CHECK_INTERVAL` and `TOR_MAX_FAILURES`. The code
reads all three and no doc mentioned them.
- The table of contents built its anchors by lowercasing and swapping
spaces, so the Webshare.cz and Moly.hu links were already broken (GitHub
drops the dots) and Soulseek (slskd) would have broken with the
parentheses. They now follow GitHub's rule.

Two tests cover the new entries and the anchors, and both fail without
the generator change. To regenerate: `python
scripts/generate_env_docs.py` on Python 3.14.

A test that fails when the committed file differs from the generator
output would have caught this drift, but then every PR that adds a
setting has to regenerate the doc. I left that out, your call if you
want it.
2026-10-23 22:02:37 -06:00
splitsec2 e273e7b0ed docs(dev): bring the plugin guides in line with the current code (#1566)
This is the biggest of the docs batch, and this time I don't think it
was my PRs that caused it. The plugin guides fell behind as the plugin
API changed over several releases, and nobody updated them along the
way.

Same arrangement as the others: Claude compared the guides to the code
and wrote the changes, so another human look before this merges would be
good. Someone who knows the plugin API well would catch things I would
not. These are the parts I would read first: the search plan table, the
`SourceUnavailableError` advice in the error handling section, and the
`locating` status row.

What was wrong and is now fixed, all checked against the code:

Release sources guide
- `search` takes `(book, plan, *, expand_search, content_type)`. Every
sample had the old one-argument signature, so a source written from the
guide raised a TypeError on its first search. There is now a short table
of the search plan fields.
- A source is registered by adding its module to
`_BUILTIN_SOURCE_MODULES`. The guide said to add an import at the bottom
of `__init__.py`.
- `get_column_config` is an instance method, not a classmethod, and
`ColumnRenderType.SEEDERS` does not exist (the real members are listed).
- `download` can return a `HandoffResult`. `list_available_sources`
lists every source with an `enabled` flag, it does not filter.
- `group="sources"` is gone, since only `direct_download` and
`metadata_providers` exist.
- Field lists for `Release`, `DownloadTask`, `BookMetadata`,
`ColumnSchema` and `ReleaseColumnConfig` are brought up to date, with
the protocol enum and the `locating` status.
- The optional hooks that were not mentioned (`post_process_cleanup`,
`build_retry_resolution_fields`, `list_authors`, `seed_complaints`,
`get_record`, `get_destination_override`, `search_results_are_releases`)
are documented.

Plugin settings guide
- The examples imported from `shelfmark.metadata_providers.base` and
`shelfmark.release_sources.base`, which do not exist. The metadata
example also skipped the abstract `search_by_isbn` and the kwargs
factory.
- Value priority now starts with the environment variable, and
`search_mode` is listed next to `general` as using `settings.json`. The
`metadata_providers` group order is 12, not 50.
- Documented `TagListField`, `OrderableListField`, `TableField`,
`env_var`, `env_supported`, `user_overridable`, `universal_only`,
`filter_by_field`, `register_on_save` and the `current_values` argument
for action callbacks.

Also in here: `docs/dev/index.md` was a TODO stub and is now a short
index linked from `docs/index.md`, a dead link in the metadata providers
README is fixed, and the README table gains Databáze knih and Moly.hu.

To check the samples were not just plausible, I ran the quick start and
the full BookNet example against the real registry. Both register,
instantiate and return their column config. The old BookNet example
failed on import with a NameError, because the Test Connection callback
was defined below the settings function that references it. The
decorator calls that function straight away, so both settings snippets
now define the callback first.
2026-10-23 22:02:18 -06:00
splitsec2 20848a8774 docs: small fixes from a check of the docs against the code (#1567)
These are the leftovers from the docs check, the fixes that did not
belong in the other three.

Claude did the comparison and wrote the changes here too, so I would
still like a human look before it merges. The one new piece of prose
worth a read is `troubleshooting.md`. It was a TODO page that two other
pages link to, so I filled it with only what I could confirm in the
code: where the logs go, `LOG_LEVEL`, the `DEBUG=true` bundle from the
bug report template, and reporting an issue. Happy to drop it back to a
stub if you would rather write that one yourself.

- `configuration.md`, `custom-scripts.md`, `users-and-requests.md`:
Booklore is Grimmory in the UI now. The stored mode value is still
`booklore`, and the custom script doc says so. The hardlink heading now
matches the "Hardlink Book Torrents" and "Hardlink Audiobook Torrents"
checkboxes.
- `custom-scripts.md`: the email output runs scripts too
(`phase=post_email`, `output.mode=email`, details under
`output.details.email`). It was missing from the phase list.
- `users-and-requests.md`: a delivery that fails with no retry available
goes back to pending on its own, and the admin reopen action is for
rejected requests. The page said an admin reopens a failed delivery.
Also noted that rules cannot use Request Book.
- `hardcover-sync.md`: added the missing
`AUTO_DOWNLOAD_AUDIOBOOK_MIN_SIZE_PERCENT` row and a sentence in the
ranking paragraph.
- `api-access.md`: an admin session also reads `/api/stats`, and what
the 403 says.
- `installation.md`: points at the Lite, Tor and WireGuard variants.

Docs only, no code touched.
2026-10-23 22:01:54 -06:00
splitsec2 6b775cd9e8 refactor: move /api/stats into core/stats_routes.py (#1563)
The sample extraction from Discussion #1536: `/api/stats` moves out of
`main.py` into `shelfmark/core/stats_routes.py`, registered with
`register_stats_routes(app, ...)` like the other route modules. The
route logic is the same, and no test changed.

`main.py` passes two callables instead of values, one for the user
database and one for the auth mode. They are called per request, so the
tests that patch `main.user_db` and `main.get_auth_mode` after import
still work. The read-only key allowlist in the middleware now uses the
path constant from the new module, so the path lives in one place.

This is only the shape. If it looks right I'll move the route groups
from the 1.5.0 PRs the same way, one PR each. If you'd rather leave
`main.py` as it is, close this and no harm done.
2026-10-23 21:51:58 -06:00
splitsec2 61419451e4 docs(readme): bring the feature lists and project scope up to date (#1564)
Sorry, this one is overdue. The readme still describes Shelfmark as it
was a few releases ago. Part of that is on me: Hardcover Sync, Library
Check and a few other things I added came in through PRs where I charged
ahead with the functionality and skipped the boring documentation part.
The rest are features other people added (Jackett, Webshare.cz, DNB and
so on) that never made it into the readme either.

I had Claude compare the readme to the code and generate the updates, so
it would be good to have another human look this over before it merges.
The scope section in particular is your call, since it states what
Shelfmark is for.

What changed:

- **Project Scope** said Shelfmark doesn't know what you own, doesn't
integrate with library software and has no background automation.
Library Check and Hardcover Sync ship exactly those things, so the list
now says what is still true (it never edits your library, and Hardcover
Sync is the one optional background job) and links both docs. I kept the
four bullets and the "not missing features" line.
- **Sources and providers** now include Jackett, Webshare.cz and
OceanofPDF, and DNB, Databáze knih and Moly.hu.
- **Features** gain Library Check and Hardcover Sync, both linking to
their existing docs pages.
- **Settings list** gets the download clients (including the four debrid
services), Jackett and Webshare.cz, and the Library Link bullet mentions
the audiobook library URL.

Docs only, no code touched. Every name and link was checked against the
code and the docs folder.
2026-10-23 21:51:32 -06:00
splitsec2 d7a4b6b859 fix: keep exception text out of the status, config and release search responses (#1562)
Same idea as #1558, for three more places that hand `str(e)` to whoever
is logged in. These are code scanning alerts 106, 142 and 143.

`/api/status` and `/api/config` returned the raw exception text of an
OSError, RuntimeError and so on with the 500. They now return "Could not
read the download status" and "Could not load the configuration". The
full error is still logged with its trace.

For `/api/releases`, a source failing with an unexpected error put
`source: <exception text>` into the error list that goes back to the
caller. `search_source_releases` takes a new `include_error_detail`
flag. The route passes True for admins (and when auth is off, where
everyone is treated as an admin) and False for everyone else, who get
`source: search failed`. The message of a `SourceUnavailableError` is
written for the user, so it is kept either way, and the background
auto-download caller keeps the default and sees the full text as before.

Left alone on purpose: the `reason` in the release inspect response,
since a test covers that it carries the error text and it looks
intentional.

Tests: two for the routes and two for the helper. All four fail without
the change. Full suite passes (5295), ruff and basedpyright are clean.
2026-10-23 21:06:26 -06:00
23b3b3a9d8 feat: add Jackett release source (#1521)
## Summary
Adds Jackett as a separate release source (`jackett`), as requested in
#1443.

- New `shelfmark/release_sources/jackett/` package: API client, settings
tab, source and handler.
- Searches Jackett's Torznab endpoints: the `all` aggregate by default,
or each selected indexer individually. Failed or timed-out searches
raise `SourceUnavailableError` instead of reporting "no releases found".
- Reuses Prowlarr code rather than duplicating it: the Torznab parser
(now also reads `<jackettindexer>`), `_prowlarr_result_to_release` (new
optional `source`/`source_id` args), the release cache, URL/protocol
helpers, the timeout clamp and size-limit helper (new optional
config-key args), and the handler's `list_files` / retry-restore logic.
- Downloads use the existing shared torrent client settings.
- `JACKETT_URL` is a trusted origin for `.torrent` fetching (it is
usually on the LAN). Prowlarr's `X-Api-Key` is not sent to it.
- Added a Jackett section to `docs/environment-variables.md`.

## Screenshots

<img width="1117" height="936" alt="Screenshot 2026-10-21 at 13-45-34
Shelfmark"
src="https://github.com/user-attachments/assets/00339999-72c7-4226-bd9a-df8b325a7e22"
/>

<img width="1117" height="938" alt="Screenshot 2026-10-21 at 13-50-38
Shelfmark"
src="https://github.com/user-attachments/assets/3c9cd2b4-932a-4b5c-9e10-6ec22b5f987d"
/>

<img width="1117" height="937" alt="Screenshot 2026-10-21 at 13-50-57
Shelfmark"
src="https://github.com/user-attachments/assets/9c4d0ed9-da2f-4323-b188-341b45066efd"
/>

## Not included
- First-run onboarding does not offer Jackett yet.
- No Jackett-specific category settings; it uses the standard 7000 /
3030 categories.

## Testing
- New unit tests in `tests/jackett/` (client, source, handler) using
mocked responses.
- Full suite passes except 11 `tests/e2e` tests that also fail on a
clean tree here (cannot write `/var/log/shelfmark`).
- Tested against a live Jackett instance.

Closes #1443

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

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: CaliBrain <calibrain@l4n.xyz>
2026-10-23 17:46:41 -04:00
dependabot[bot] b8e466cc64 build(deps-dev): bump prek from 0.5.5 to 0.5.6 in the python-deps group (#1548)
Bumps the python-deps group with 1 update:
[prek](https://github.com/j178/prek).

Updates `prek` from 0.5.5 to 0.5.6
<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.6</h2>
<h2>Release Notes</h2>
<p>Released on 2026-10-19.</p>
<h3>Enhancements</h3>
<ul>
<li>Add builtin hook <code>check-commit-trailers</code> (<a
href="https://redirect.github.com/j178/prek/pull/2885">#2885</a>)</li>
<li>Add builtin hook <code>check-signed-commit</code> (<a
href="https://redirect.github.com/j178/prek/pull/2725">#2725</a>)</li>
</ul>
<h3>Performance</h3>
<ul>
<li>Enable PGO for native release builds (<a
href="https://redirect.github.com/j178/prek/pull/2890">#2890</a>)</li>
<li>Reduce hook process launches with balanced batches (<a
href="https://redirect.github.com/j178/prek/pull/2907">#2907</a>)</li>
<li>Resolve hook commands before batching (<a
href="https://redirect.github.com/j178/prek/pull/2840">#2840</a>)</li>
</ul>
<h3>Bug fixes</h3>
<ul>
<li>Accept UTF-8 BOM in <code>check-json</code> (<a
href="https://redirect.github.com/j178/prek/pull/2879">#2879</a>)</li>
<li>Preserve unquoted dotted revisions in <code>prek update</code> (<a
href="https://redirect.github.com/j178/prek/pull/2853">#2853</a>)</li>
</ul>
<h3>Documentation</h3>
<ul>
<li>Document standalone installer options (<a
href="https://redirect.github.com/j178/prek/pull/2859">#2859</a>)</li>
<li>Update benchmarks for prek 0.5.5 (<a
href="https://redirect.github.com/j178/prek/pull/2786">#2786</a>)</li>
</ul>
<h3>Other changes</h3>
<ul>
<li>Enable ICF for release builds (<a
href="https://redirect.github.com/j178/prek/pull/2891">#2891</a>)</li>
<li>Update uv to 0.13.0 (<a
href="https://redirect.github.com/j178/prek/pull/2856">#2856</a>)</li>
</ul>
<h3>Contributors</h3>
<ul>
<li><a href="https://github.com/ebuildy"><code>@​ebuildy</code></a></li>
<li><a href="https://github.com/j178"><code>@​j178</code></a></li>
<li><a
href="https://github.com/shaanmajid"><code>@​shaanmajid</code></a></li>
</ul>
<h2>Install prek 0.5.6</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.6/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.6/prek-installer.ps1
| iex&quot;
</code></pre>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/j178/prek/blob/main/CHANGELOG.md">prek's
changelog</a>.</em></p>
<blockquote>
<h2>0.5.6</h2>
<p>Released on 2026-10-19.</p>
<h3>Enhancements</h3>
<ul>
<li>Add builtin hook <code>check-commit-trailers</code> (<a
href="https://redirect.github.com/j178/prek/pull/2885">#2885</a>)</li>
<li>Add builtin hook <code>check-signed-commit</code> (<a
href="https://redirect.github.com/j178/prek/pull/2725">#2725</a>)</li>
</ul>
<h3>Performance</h3>
<ul>
<li>Enable PGO for native release builds (<a
href="https://redirect.github.com/j178/prek/pull/2890">#2890</a>)</li>
<li>Reduce hook process launches with balanced batches (<a
href="https://redirect.github.com/j178/prek/pull/2907">#2907</a>)</li>
<li>Resolve hook commands before batching (<a
href="https://redirect.github.com/j178/prek/pull/2840">#2840</a>)</li>
</ul>
<h3>Bug fixes</h3>
<ul>
<li>Accept UTF-8 BOM in <code>check-json</code> (<a
href="https://redirect.github.com/j178/prek/pull/2879">#2879</a>)</li>
<li>Preserve unquoted dotted revisions in <code>prek update</code> (<a
href="https://redirect.github.com/j178/prek/pull/2853">#2853</a>)</li>
</ul>
<h3>Documentation</h3>
<ul>
<li>Document standalone installer options (<a
href="https://redirect.github.com/j178/prek/pull/2859">#2859</a>)</li>
<li>Update benchmarks for prek 0.5.5 (<a
href="https://redirect.github.com/j178/prek/pull/2786">#2786</a>)</li>
</ul>
<h3>Other changes</h3>
<ul>
<li>Enable ICF for release builds (<a
href="https://redirect.github.com/j178/prek/pull/2891">#2891</a>)</li>
<li>Update uv to 0.13.0 (<a
href="https://redirect.github.com/j178/prek/pull/2856">#2856</a>)</li>
</ul>
<h3>Contributors</h3>
<ul>
<li><a href="https://github.com/ebuildy"><code>@​ebuildy</code></a></li>
<li><a href="https://github.com/j178"><code>@​j178</code></a></li>
<li><a
href="https://github.com/shaanmajid"><code>@​shaanmajid</code></a></li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/j178/prek/commit/ee8a6cce37fe70d21281910e673bceddc003cb40"><code>ee8a6cc</code></a>
Bump version to 0.5.6 (<a
href="https://redirect.github.com/j178/prek/issues/2909">#2909</a>)</li>
<li><a
href="https://github.com/j178/prek/commit/36116817569560119b2891e4dcf061dbdcc8f16a"><code>3611681</code></a>
Reduce hook process launches with balanced batches (<a
href="https://redirect.github.com/j178/prek/issues/2907">#2907</a>)</li>
<li><a
href="https://github.com/j178/prek/commit/1af6b80acdee193effce67850897560530663008"><code>1af6b80</code></a>
Use PR head commits for CI change detection</li>
<li><a
href="https://github.com/j178/prek/commit/29455ef294ce0abf416da951e1249b03e18c4460"><code>29455ef</code></a>
Update dependency zensical to &gt;=0.0.69 (<a
href="https://redirect.github.com/j178/prek/issues/2896">#2896</a>)</li>
<li><a
href="https://github.com/j178/prek/commit/1fd9308df9d9fec874e1aad556ddd2dfa3e97e0d"><code>1fd9308</code></a>
Update dependency prefix-dev/pixi to v0.81.0 (<a
href="https://redirect.github.com/j178/prek/issues/2905">#2905</a>)</li>
<li><a
href="https://github.com/j178/prek/commit/140ab6b9654f938b3c496a580a6a859a7616df06"><code>140ab6b</code></a>
Update docker.io/astral/uv Docker tag to v0.13.0 (<a
href="https://redirect.github.com/j178/prek/issues/2906">#2906</a>)</li>
<li><a
href="https://github.com/j178/prek/commit/3574ba305692928d223e0154f44853fb0bedd196"><code>3574ba3</code></a>
Update dependency cargo-zigbuild to &gt;=0.23.4 (<a
href="https://redirect.github.com/j178/prek/issues/2895">#2895</a>)</li>
<li><a
href="https://github.com/j178/prek/commit/7c23eaeabcbdfdae0b56d37eaddc2061a51a7d8f"><code>7c23eae</code></a>
Update Rust crate async_zip to 0.0.22 (<a
href="https://redirect.github.com/j178/prek/issues/2898">#2898</a>)</li>
<li><a
href="https://github.com/j178/prek/commit/09f486de46bd02c94d928e434fed8cdb45b64198"><code>09f486d</code></a>
Update Rust crate indoc to v2.0.8 (<a
href="https://redirect.github.com/j178/prek/issues/2899">#2899</a>)</li>
<li><a
href="https://github.com/j178/prek/commit/4d677216f0bb073883b8ab45384df517d3197e49"><code>4d67721</code></a>
Update Rust crate tokio-util to v0.7.20 (<a
href="https://redirect.github.com/j178/prek/issues/2901">#2901</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/j178/prek/compare/v0.5.5...v0.5.6">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=prek&package-manager=uv&previous-version=0.5.5&new-version=0.5.6)](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-10-23 14:40:10 -04:00
dependabot[bot] aa72850eab build(deps): bump the npm-deps group in /src/frontend with 6 updates (#1551)
Bumps the npm-deps group in /src/frontend with 6 updates:

| Package | From | To |
| --- | --- | --- |
| [react](https://github.com/react/react/tree/HEAD/packages/react) |
`19.3.0` | `19.3.1` |
|
[react-dom](https://github.com/react/react/tree/HEAD/packages/react-dom)
| `19.3.0` | `19.3.1` |
| [knip](https://github.com/webpro-nl/knip/tree/HEAD/packages/knip) |
`6.41.0` | `6.42.1` |
| [oxfmt](https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt) |
`0.72.0` | `0.74.0` |
| [oxlint](https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint) |
`1.87.0` | `1.89.0` |
|
[vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest)
| `5.0.4` | `5.0.5` |

Updates `react` from 19.3.0 to 19.3.1
<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.1 (October 14th, 2026)</h2>
<h2>React Server Components</h2>
<ul>
<li>Improve transport protocol (<a
href="https://redirect.github.com/facebook/react/pull/37845">#37845</a>
by <a
href="https://github.com/unstubbable"><code>@​unstubbable</code></a> and
<a href="https://github.com/eps1lon"><code>@​eps1lon</code></a>)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/react/react/commit/61aa00df822d0eb76d0362eb8b6fdd3527693efe"><code>61aa00d</code></a>
[19.3.x][Flight] Improve transport protocol (<a
href="https://github.com/react/react/tree/HEAD/packages/react/issues/37845">#37845</a>)</li>
<li>See full diff in <a
href="https://github.com/react/react/commits/v19.3.1/packages/react">compare
view</a></li>
</ul>
</details>
<br />

Updates `react-dom` from 19.3.0 to 19.3.1
<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.1 (October 14th, 2026)</h2>
<h2>React Server Components</h2>
<ul>
<li>Improve transport protocol (<a
href="https://redirect.github.com/facebook/react/pull/37845">#37845</a>
by <a
href="https://github.com/unstubbable"><code>@​unstubbable</code></a> and
<a href="https://github.com/eps1lon"><code>@​eps1lon</code></a>)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/react/react/commit/61aa00df822d0eb76d0362eb8b6fdd3527693efe"><code>61aa00d</code></a>
[19.3.x][Flight] Improve transport protocol (<a
href="https://github.com/react/react/tree/HEAD/packages/react-dom/issues/37845">#37845</a>)</li>
<li>See full diff in <a
href="https://github.com/react/react/commits/v19.3.1/packages/react-dom">compare
view</a></li>
</ul>
</details>
<br />

Updates `knip` from 6.41.0 to 6.42.1
<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.42.1</h2>
<ul>
<li>fix(semantic-release): resolve shareable configs from extends (<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/2159">#2159</a>)
(4c4a5ece9148cf621f3a6e091fd3550b18f6e19a) - thanks <a
href="https://github.com/giaBaoJS"><code>@​giaBaoJS</code></a>!</li>
<li>fix(jest): load projects entries as Jest configs (<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/2167">#2167</a>)
(4c22593724460890e4492ba86119d0188127f2ba) - thanks <a
href="https://github.com/Cayan"><code>@​Cayan</code></a>!</li>
<li>Update npmx.dev snapshot
(4b19797b77a559f7190e9119b3a156e1ac6aef9f)</li>
<li>fix(tailwind): track packages referenced from CSS url() (<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/2166">#2166</a>)
(949da00abf419c1eeabdab1541ce7e5c312371b9) - thanks <a
href="https://github.com/agustincastaing"><code>@​agustincastaing</code></a>!</li>
<li>Add .otf to foreign file extensions
(fcff4db0b88d6282569743cd779aec5a5df99862)</li>
<li>Match quoted SCSS url() values within a single line
(2c5c2a2efab8f08be538bfe1f40982cb6f0fca3c)</li>
<li>fix(bun): treat bun test arguments as path filters (<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/2165">#2165</a>)
(5f3a7fb526d3eca7561aacfc9d0ac60056525206) - thanks <a
href="https://github.com/jakeleventhal"><code>@​jakeleventhal</code></a>!</li>
<li>fix(vitest): use default test entries when config has no test key
(<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/2170">#2170</a>)
(7ee576ccd461f719b533e3783a5b58391ce89e91) - thanks <a
href="https://github.com/fixfon"><code>@​fixfon</code></a>!</li>
</ul>
<h2>Release 6.42.0</h2>
<ul>
<li>fix: add bun 1.4.3 check subcommand (<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/2131">#2131</a>)
(09129b8aaa554ffae2ff8f42a5ff4d6994eb7759) - thanks <a
href="https://github.com/NimmLor"><code>@​NimmLor</code></a>!</li>
<li>Detect pnpm workspaces correctly around <code>packages:</code>
(resolve <a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/2133">#2133</a>,
close <a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/2134">#2134</a>)
(d1cc0928be54e6cac9668460d9f6a21e3a94f818)</li>
<li>fix(github-actions): resolve working-directory nested under checkout
path (<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/2136">#2136</a>)
(87e051e45f12c107cc25ed08bba962b95bb23b51) - thanks <a
href="https://github.com/kenny-ish"><code>@​kenny-ish</code></a>!</li>
<li>fix(nx): track local generator and executor entry points (<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/2117">#2117</a>)
(<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/2121">#2121</a>)
(558407f51b5fdf4460275b69950676e7bace35d5) - thanks <a
href="https://github.com/DreamLongYT"><code>@​DreamLongYT</code></a>!</li>
<li>Update dependency bun to v1.4.3 (<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/2137">#2137</a>)
(71f90bf6d5fb2675430f60416e403851f9cb2cfe) - thanks <a
href="https://github.com/renovate"><code>@​renovate</code></a>[bot]!</li>
<li>docs(react-router): note to run react-router typegen before Knip (<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/2140">#2140</a>)
(f21ce9a9f1e3264cad1b763cf31ec2c177fb8656) - thanks <a
href="https://github.com/MannXo"><code>@​MannXo</code></a>!</li>
<li>fix: normalize scoped binary names (close <a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/2143">#2143</a>)
(c8509c4e45e1c343da707c26877a0a6584f6bc11)</li>
<li>Clarify plugin implementation boundaries
(c5a67f59d351e8d26dc6ecbbd3c3193e313772a4)</li>
<li>fix(jest): treat mocked and required dependencies as used (<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/2141">#2141</a>)
(3eaf1ba16045a3ac22f0717c4f1252312298bb76) - thanks <a
href="https://github.com/TkDodo"><code>@​TkDodo</code></a>!</li>
<li>fix: preserve root-only plugins for trailing-slash cwd (<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/2139">#2139</a>)
(7f0db0e7670bfd18fab081b522a104f787829f7d) - thanks <a
href="https://github.com/sean0407"><code>@​sean0407</code></a>!</li>
<li>fix(nx): resolve run-commands script paths from the target cwd (<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/2151">#2151</a>)
(516bd9a324303653978528f582287c857846fcc8) - thanks <a
href="https://github.com/JeanBeaurepaire"><code>@​JeanBeaurepaire</code></a>!</li>
<li>Resolve Nx run-commands loader paths from the target cwd
(fa8ca13e1345115d30122900ab68db7339487502)</li>
<li>Add <code>@​solidjs/vite-plugin</code> plugin (<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/2144">#2144</a>)
(579a88d306429694be43590efb50cb72067551ba) - thanks <a
href="https://github.com/everton-dgn"><code>@​everton-dgn</code></a>!</li>
<li>feat: add filesystem-routing plugin (<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/2145">#2145</a>)
(1abd9e1875bae36b0baf77f5864aab849d1528a8) - thanks <a
href="https://github.com/everton-dgn"><code>@​everton-dgn</code></a>!</li>
<li>Support tags in line comments (resolve <a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1924">#1924</a>)
(5abef4846e20f09fd4d0ee8dc2513ce50b616cdb)</li>
<li>Keep resolveFromAST to static values in plugin guide
(4cd353fd9ecb780228a63069080586a46ba6a8f2)</li>
<li>fix(solidjs-vite-plugin): keep entry overrides in production (<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/2155">#2155</a>)
(e4b5f890787da07824cc174cb624396f2133ff06) - thanks <a
href="https://github.com/everton-dgn"><code>@​everton-dgn</code></a>!</li>
<li>feat(angular): resolve styleUrl and styleUrls of <a
href="https://github.com/Component"><code>@​Component</code></a> (<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/2157">#2157</a>)
(c20d4d720c586c9c5dc6e3d1a6ea60c557688bfa) - thanks <a
href="https://github.com/Cayan"><code>@​Cayan</code></a>!</li>
<li>Update npmx.dev snapshot
(59eb873f3beecf40b7aed9579bd29e1ab8b631f0)</li>
<li>fix(next): recognize Turbopack alias targets (<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/2156">#2156</a>)
(1d44f6a1a39c5557c1e215c7cf9db17937967344) - thanks <a
href="https://github.com/jakeleventhal"><code>@​jakeleventhal</code></a>!</li>
<li>fix: preserve re-exports from framework entries (<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/2154">#2154</a>)
(263ce20408a77b2473c555f304ad3b55c696b6a6) - thanks <a
href="https://github.com/jakeleventhal"><code>@​jakeleventhal</code></a>!</li>
<li>feat(solidjs-vite-plugin): follow Solid's SSR and client mode entry
rules (<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/2148">#2148</a>)
(d711231eae14d8ecac562d73078125f3b30dc8fd) - thanks <a
href="https://github.com/everton-dgn"><code>@​everton-dgn</code></a>!</li>
<li>feat(filesystem-routing): read route options from Vite configs (<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/2149">#2149</a>)
(585f206ca52d54e30f2068c818e1b33b50085459) - thanks <a
href="https://github.com/everton-dgn"><code>@​everton-dgn</code></a>!</li>
<li>feat(filesystem-routing): resolve routes from the Vite root (<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/2150">#2150</a>)
(8cc0cb0c9ff09463cad30817c8cf78cbc48b7ed6) - thanks <a
href="https://github.com/everton-dgn"><code>@​everton-dgn</code></a>!</li>
<li>fix: track namespace members in Promise.all imports (<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/2162">#2162</a>)
(3f17cdfce7a0f90e2599a7074291c4c875fe8244) - thanks <a
href="https://github.com/everton-dgn"><code>@​everton-dgn</code></a>!</li>
<li>Update a few dependencies
(161dbed3ccffa574e3a9493ddb20842eefa26af8)</li>
<li>Fix Solid setup fixture coverage
(ebe4180785667f6ff85c1e6ce2a11226713b2d5a)</li>
<li>Migrate shell script parsing to unbash v6
(f86044c6760ac4f27e564f15feeae907cba3d297)</li>
<li>Add sponsors: <a
href="https://github.com/pennylane-hq"><code>@​pennylane-hq</code></a>,
<a href="https://github.com/agentscore"><code>@​agentscore</code></a>,
<a
href="https://github.com/oblique-security"><code>@​oblique-security</code></a>
(thank you!) (57ff23eb430aaece73e02574923cabc6016a920b)</li>
<li>Stop pairing Promise.all imports and bindings after a spread
(5bd2c64eaf92f82a316d8fa6551acdb1d6c48c32)</li>
<li>Update some stats (dd02ddeb235dd516cf93db99dda2180b0eb09a97)</li>
<li>fix(nx): read karmaConfig from target options (<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/2164">#2164</a>)
(4e1e4bc18f6d2f2705d729c375a20bd1679b8c0f) - thanks <a
href="https://github.com/Cayan"><code>@​Cayan</code></a>!</li>
<li>Add tsrs plugin (<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/2163">#2163</a>)
(ba09126c023a2c408de8d7dae3bf6ab97e420966) - thanks <a
href="https://github.com/jakeleventhal"><code>@​jakeleventhal</code></a>!</li>
<li>Drop redundant containingFilePath from plugin config inputs
(ffb6caf02891d29eee41c7a0ecf147312dbe7205)</li>
<li>Resolve Nx target option configs from the workspace root
(52c5233aa905124c5b30c0bb7f9b0b25cd4260c6)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/webpro-nl/knip/commit/ee32ee6ca4c8826ca45f306041d9f8c5ffdaa335"><code>ee32ee6</code></a>
Release knip@6.42.1</li>
<li><a
href="https://github.com/webpro-nl/knip/commit/7ee576ccd461f719b533e3783a5b58391ce89e91"><code>7ee576c</code></a>
fix(vitest): use default test entries when config has no test key (<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/2170">#2170</a>)</li>
<li><a
href="https://github.com/webpro-nl/knip/commit/5f3a7fb526d3eca7561aacfc9d0ac60056525206"><code>5f3a7fb</code></a>
fix(bun): treat bun test arguments as path filters (<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/2165">#2165</a>)</li>
<li><a
href="https://github.com/webpro-nl/knip/commit/2c5c2a2efab8f08be538bfe1f40982cb6f0fca3c"><code>2c5c2a2</code></a>
Match quoted SCSS url() values within a single line</li>
<li><a
href="https://github.com/webpro-nl/knip/commit/fcff4db0b88d6282569743cd779aec5a5df99862"><code>fcff4db</code></a>
Add .otf to foreign file extensions</li>
<li><a
href="https://github.com/webpro-nl/knip/commit/949da00abf419c1eeabdab1541ce7e5c312371b9"><code>949da00</code></a>
fix(tailwind): track packages referenced from CSS url() (<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/2166">#2166</a>)</li>
<li><a
href="https://github.com/webpro-nl/knip/commit/4c22593724460890e4492ba86119d0188127f2ba"><code>4c22593</code></a>
fix(jest): load projects entries as Jest configs (<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/2167">#2167</a>)</li>
<li><a
href="https://github.com/webpro-nl/knip/commit/4c4a5ece9148cf621f3a6e091fd3550b18f6e19a"><code>4c4a5ec</code></a>
fix(semantic-release): resolve shareable configs from extends (<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/2159">#2159</a>)</li>
<li><a
href="https://github.com/webpro-nl/knip/commit/8aa7d0e281ecba684c35acf4effd072e90a8862d"><code>8aa7d0e</code></a>
Release knip@6.42.0</li>
<li><a
href="https://github.com/webpro-nl/knip/commit/52c5233aa905124c5b30c0bb7f9b0b25cd4260c6"><code>52c5233</code></a>
Resolve Nx target option configs from the workspace root</li>
<li>Additional commits viewable in <a
href="https://github.com/webpro-nl/knip/commits/knip@6.42.1/packages/knip">compare
view</a></li>
</ul>
</details>
<br />

Updates `oxfmt` from 0.72.0 to 0.74.0
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/oxc-project/oxc/releases">oxfmt's
releases</a>.</em></p>
<blockquote>
<h2>oxfmt v0.74.0</h2>
<h3>🐛 Bug Fixes</h3>
<ul>
<li>e236de0 formatter_tests: Skip conformance spec calls with
non-literal options (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt/issues/27957">#27957</a>)
(leaysgur)</li>
<li>782ab6e linter: Search string values without UTF-8 conversion (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt/issues/26716">#26716</a>)
(Dunqing)</li>
<li>a5d8184 jsdoc: Parse code spans after quotes in descriptions (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt/issues/27955">#27955</a>)
(leaysgur)</li>
<li>2645d2c formatter_markdown: Treat link destinations and footnote
references as opaque to code span fences (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt/issues/27948">#27948</a>)
(leaysgur)</li>
<li>4d8195c formatter_yaml: Keep a root plain scalar starting with a
document marker verbatim (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt/issues/27947">#27947</a>)
(leaysgur)</li>
<li>529b057 formatter_core: Keep front matter verbatim when body starts
with indented delimiter (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt/issues/27946">#27946</a>)
(leaysgur)</li>
<li>c22a9b2 formatter: Treat static block with only empty statements as
empty (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt/issues/27945">#27945</a>)
(leaysgur)</li>
<li>d3ff3f6 formatter/jsdoc: Write descriptions as written to not close
the comment (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt/issues/27823">#27823</a>)
(leaysgur)</li>
<li>c4c1727 formatter/sort_imports: Keep comments after the last import
(<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt/issues/27822">#27822</a>)
(leaysgur)</li>
<li>a64677e formatter: Print JSDoc embedded snippets with LF (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt/issues/27821">#27821</a>)
(leaysgur)</li>
<li>2c1cc20 formatter: Keep comment only Vue bindings as-is (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt/issues/27820">#27820</a>)
(leaysgur)</li>
<li>1bf236d formatter/sort_imports: Do not use comments as import source
(<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt/issues/27819">#27819</a>)
(leaysgur)</li>
<li>52ee29d formatter_yaml: Never start col-0 line with document marker
or BOM (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt/issues/27818">#27818</a>)
(leaysgur)</li>
<li>81c75e7 formatter/css: Keep glued css-in-js statement placeholders
glued (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt/issues/27814">#27814</a>)
(leaysgur)</li>
<li>a1ef7f4 formatter: Keep typecast parens in same-operator binary and
arrow chain (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt/issues/27812">#27812</a>)
(leaysgur)</li>
<li>de110ce formatter: Keep more parens around
<code>TSInstantiationExpression</code> (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt/issues/27811">#27811</a>)
(leaysgur)</li>
<li>8b02e90 formatter_json: Reject invalid JSON instead of dropping them
(<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt/issues/27785">#27785</a>)
(leaysgur)</li>
<li>6fe4c1b formatter: Treat lone CR in JSX text and argument blank
lines (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt/issues/27780">#27780</a>)
(leaysgur)</li>
<li>5605362 oxfmt: Validate editorconfig as like oxfmtrc (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt/issues/27778">#27778</a>)
(leaysgur)</li>
<li>5b2acb0 formatter_core: Report error position releative to CRLF
source (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt/issues/27777">#27777</a>)
(leaysgur)</li>
<li>c78e80e formatter_core: Report parse error positions relative to the
BOM-headed source (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt/issues/27776">#27776</a>)
(leaysgur)</li>
<li>3a980c9 formatter_toml: Handle BOM like other languages (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt/issues/27775">#27775</a>)
(leaysgur)</li>
<li>97c9fc4 oxfmt: Handle BOM in config JSON (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt/issues/27774">#27774</a>)
(leaysgur)</li>
<li>28eb4a7 oxfmt: Print trailing newline for
<code>--list-different</code> (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt/issues/27773">#27773</a>)
(leaysgur)</li>
<li>d2bea21 formatter: Glue comments to an opener in empty for-head
slots and array holes (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt/issues/27737">#27737</a>)
(leaysgur)</li>
<li>6a95ec1 formatter: Keep comment position on holey array (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt/issues/27730">#27730</a>)
(leaysgur)</li>
<li>54769da formatter: Break after dangling line comment in holey array
(<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt/issues/27729">#27729</a>)
(leaysgur)</li>
<li>99077ef formatter_json: Reject unary expressions other than signed
numbers (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt/issues/27728">#27728</a>)
(leaysgur)</li>
<li>22cd4cd formatter_json: Handle holey array and comments (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt/issues/27727">#27727</a>)
(leaysgur)</li>
<li>644ca1c formatter: Do not indent logical chains whose right sides
all inline (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt/issues/27667">#27667</a>)
(leaysgur)</li>
<li>a07a0fb formatter: Wrap comment-only JSX expression container in a
group (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt/issues/27666">#27666</a>)
(leaysgur)</li>
</ul>
<h3>⚡ Performance</h3>
<ul>
<li>e220293 formatter: Check only the operator suppression mark in
<code>is_quiet</code> (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt/issues/27952">#27952</a>)
(leaysgur)</li>
<li>e5fdc9c formatter_core: Write into one element stack through a
concrete <code>Formatter</code> (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt/issues/27559">#27559</a>)
(leaysgur)</li>
<li>a342f7e formatter: Skip comment handling for nodes with no comment
near them (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt/issues/27557">#27557</a>)
(leaysgur)</li>
<li>d8ce750 formatter: Write string literals with swapped quotes into
the arena (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt/issues/27558">#27558</a>)
(Yuji Sugiura)</li>
<li>1c393c0 formatter_core: Skip scanning texts with nothing to rewrite
(<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt/issues/27561">#27561</a>)
(leaysgur)</li>
<li>1d3ef10 formatter_core: Keep the embedded IR when nothing in it is
rewritten (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt/issues/27673">#27673</a>)
(leaysgur)</li>
</ul>
<h3>📚 Documentation</h3>
<ul>
<li>1325225 oxfmt: Update useless comment prefix (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt/issues/27956">#27956</a>)
(leaysgur)</li>
<li>d0dd140 formatter: Note lossy cooked round-trip in template embeds
(<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt/issues/27944">#27944</a>)
(leaysgur)</li>
<li>8a3a6c8 oxfmt: Update stale notes on the JSDoc string-out channel
and fixture origins (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt/issues/27824">#27824</a>)
(leaysgur)</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/07ed78f4abde4020ca7919381ffea4da8616b78a"><code>07ed78f</code></a>
release(apps): oxlint v1.89.0 &amp;&amp; oxfmt v0.74.0 (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt/issues/27961">#27961</a>)</li>
<li><a
href="https://github.com/oxc-project/oxc/commit/6941b4ed45ead21c45bc2164cb80a180eed85b43"><code>6941b4e</code></a>
release(apps): oxlint v1.88.0 &amp;&amp; oxfmt v0.73.0 (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt/issues/27643">#27643</a>)</li>
<li><a
href="https://github.com/oxc-project/oxc/commit/a495e8241e4927a65f77c5e97b9f4dee4dbff5aa"><code>a495e82</code></a>
feat(oxfmt): support <code>associations</code> config to specify
language over filepath ...</li>
<li><a
href="https://github.com/oxc-project/oxc/commit/408d87f62893d5dc3eb3e0aa8a0b5e7e2eca32f3"><code>408d87f</code></a>
docs(oxfmt): simplify oxfmtrc docs (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt/issues/27432">#27432</a>)</li>
<li><a
href="https://github.com/oxc-project/oxc/commit/5db28facf7bfe71d36829381b176f1c611e816f0"><code>5db28fa</code></a>
feat(oxfmt): support Astro via <code>prettier-plugin-astro@1.x</code>
(<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt/issues/27386">#27386</a>)</li>
<li><a
href="https://github.com/oxc-project/oxc/commit/902e188b3518afede5fc2b9823e36d8a48af6fda"><code>902e188</code></a>
docs(oxfmt): update how <code>embeddedLanguageFormatting</code> option
works (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt/issues/27362">#27362</a>)</li>
<li>See full diff in <a
href="https://github.com/oxc-project/oxc/commits/oxfmt_v0.74.0/npm/oxfmt">compare
view</a></li>
</ul>
</details>
<br />

Updates `oxlint` from 1.87.0 to 1.89.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.89.0</h2>
<h3>💥 BREAKING CHANGES</h3>
<ul>
<li>e3c1b11 ast: [<strong>BREAKING</strong>] Preserve lone surrogates in
static property names (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/27095">#27095</a>)
(Dunqing)</li>
<li>a0a987c ast: [<strong>BREAKING</strong>] Preserve lone surrogates in
static member names (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/26677">#26677</a>)
(Dunqing)</li>
</ul>
<h3>🚀 Features</h3>
<ul>
<li>770ada4 linter: Mark vue 3 rules as recommended (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/27717">#27717</a>)
(Cam McHenry)</li>
<li>ba38c1d linter: Mark react/react-hooks rules as recommended (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/27701">#27701</a>)
(Cam McHenry)</li>
<li>d85a3ba linter: Mark promise rules as recommended (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/27693">#27693</a>)
(Cam McHenry)</li>
<li>e71c88a linter: Mark node rule as recommended (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/27692">#27692</a>)
(Cam McHenry)</li>
<li>7213f61 linter: Mark nextjs rules as recommended (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/27691">#27691</a>)
(Cam McHenry)</li>
<li>d7c6b26 linter: Mark jsx-a11y rules as recommended (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/27690">#27690</a>)
(Cam McHenry)</li>
<li>199dfdc linter: Mark jsdoc rules as recommended (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/27689">#27689</a>)
(Cam McHenry)</li>
<li>780bff3 linter: Mark jest/vitest rules as recommended (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/27672">#27672</a>)
(Cam McHenry)</li>
<li>6ae6859 linter: Mark import rules as recommended (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/27671">#27671</a>)
(Cam McHenry)</li>
<li>b7a1df3 linter: Mark unicorn rules as recommended (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/27670">#27670</a>)
(Cam McHenry)</li>
<li>c44a355 linter: Mark typescript rules as recommended from upstream
(<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/27634">#27634</a>)
(Cam McHenry)</li>
<li>6b49dba linter: Mark eslint rules as recommended from upstream (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/27632">#27632</a>)
(Cam McHenry)</li>
</ul>
<h3>🐛 Bug Fixes</h3>
<ul>
<li>7c47246 linter: Check module specifiers and string values that
contain lone surrogates (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/26717">#26717</a>)
(Dunqing)</li>
<li>782ab6e linter: Search string values without UTF-8 conversion (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/26716">#26716</a>)
(Dunqing)</li>
<li>326904f linter/unicorn/no-useless-undefined: Allow
<code>undefined</code> in <code>React.useRef</code> (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/25027">#25027</a>)
(Chris Dokurno)</li>
<li>f9cf165 linter/unicorn/prefer-spread: Ignore super method calls (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/27868">#27868</a>)
(camc314)</li>
<li>00484e1 linter/eslint/prefer-const: Report destructuring assignments
inside blocks (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/27856">#27856</a>)
(camc314)</li>
<li>9c35461 linter/eslint/sort-vars: Report every out-of-order
declaration (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/27767">#27767</a>)
(camc314)</li>
<li>01e1c5e linter/unicorn/prefer-string-replace-all: Resolve regex
variables (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/27769">#27769</a>)
(camc314)</li>
<li>7f474d5 linter/eslint/no-loop-func: Detect writes after the loop (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/27754">#27754</a>)
(camc314)</li>
<li>4b7fea6 linter/eslint/block-scoped-var: Ignore declarations without
writes (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/27760">#27760</a>)
(camc314)</li>
<li>2947917 linter/eslint/no-lonely-if: Check final else blocks in
else-if chains (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/27759">#27759</a>)
(camc314)</li>
<li>fc039b5 linter/eslint/sort-imports: Check imports after other
statements (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/27749">#27749</a>)
(camc314)</li>
<li>1acba97 linter/unicorn/prefer-at: Report slice index reads once (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/27762">#27762</a>)
(camc314)</li>
<li>6190e26 linter/eslint/prefer-arrow-callback: Ignore functions used
as conditional tests (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/27533">#27533</a>)
(Anzal Husain Abidi)</li>
<li>b3cdaaf linter/eslint/sort-keys: Check computed identifier names (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/27748">#27748</a>)
(camc314)</li>
<li>170e608 linter/unicorn/prefer-native-coercion-functions: Check
object methods and function properties (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/27750">#27750</a>)
(camc314)</li>
<li>9261df7 linter/unicorn/prefer-string-raw: Check object property
values (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/27758">#27758</a>)
(camc314)</li>
<li>ddea86b linter/eslint/prefer-template: Report separate nested
concatenations (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/27764">#27764</a>)
(camc314)</li>
<li>2c67197 linter/eslint/no-unused-vars: Count conditional calls in
sequences as uses (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/27755">#27755</a>)
(camc314)</li>
<li>7f22fa0 linter/unicorn/prefer-regexp-test: Check logical operands in
boolean contexts (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/27761">#27761</a>)
(camc314)</li>
<li>3c44398 linter/eslint/class-methods-use-this: Count this in
parameter defaults (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/27756">#27756</a>)
(camc314)</li>
<li>d85923f linter/eslint/no-param-reassign: Report rest parameter
writes (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/27765">#27765</a>)
(camc314)</li>
<li>8ea47bb linter/unicorn/prefer-type-error: Handle parenthesized type
checks (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/27766">#27766</a>)
(camc314)</li>
<li>e96856c linter/unicorn/catch-error-name: Check unused
underscore-prefixed names (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/27751">#27751</a>)
(camc314)</li>
<li>d8ca399 linter/unicorn/no-array-callback-reference: Ignore callback
factory calls (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/27763">#27763</a>)
(camc314)</li>
<li>1267ab8 linter/eslint/max-params: Count rest parameters (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/27757">#27757</a>)
(camc314)</li>
<li>9ff25c3 linter/eslint/no-useless-assignment: Report dead stores to
function parameters (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/27735">#27735</a>)
(camc314)</li>
<li>ee5e730 linter/eslint/no-setter-return: Check setters in property
descriptors (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/27731">#27731</a>)
(camc314)</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.89.0] - 2026-10-19</h2>
<h3>🐛 Bug Fixes</h3>
<ul>
<li>9a424c2 oxlint,oxfmt: Correct Node.js TypeScript support range (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/27699">#27699</a>)
(camc314)</li>
</ul>
<h2>[1.88.0] - 2026-10-13</h2>
<h3>🚀 Features</h3>
<ul>
<li>3c361a7 linter/unicorn/prefer-query-selector: Add
<code>allowWithVariables</code> option (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/27346">#27346</a>)
(Mikhail Baev)</li>
</ul>
<h2>[1.86.0] - 2026-09-28</h2>
<h3>🚀 Features</h3>
<ul>
<li>9d80eed linter/react/only-export-components: Support
<code>allowCompoundComponents</code> (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/27117">#27117</a>)
(Kuroda Kayn)</li>
<li>e05b155 linter: Add typescript/no-generated-empty-object-type (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/26958">#26958</a>)
(camc314)</li>
</ul>
<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>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/oxc-project/oxc/commit/07ed78f4abde4020ca7919381ffea4da8616b78a"><code>07ed78f</code></a>
release(apps): oxlint v1.89.0 &amp;&amp; oxfmt v0.74.0 (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/27961">#27961</a>)</li>
<li><a
href="https://github.com/oxc-project/oxc/commit/9a424c209ea0df16becbf3141d63bcc293ede237"><code>9a424c2</code></a>
fix(oxlint,oxfmt): correct Node.js TypeScript support range (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/27699">#27699</a>)</li>
<li><a
href="https://github.com/oxc-project/oxc/commit/6941b4ed45ead21c45bc2164cb80a180eed85b43"><code>6941b4e</code></a>
release(apps): oxlint v1.88.0 &amp;&amp; oxfmt v0.73.0 (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/27643">#27643</a>)</li>
<li><a
href="https://github.com/oxc-project/oxc/commit/3c361a7fb5c9fbbe940e489f2e315a187a19b565"><code>3c361a7</code></a>
feat(linter/unicorn/prefer-query-selector): add
<code>allowWithVariables</code> option (...</li>
<li>See full diff in <a
href="https://github.com/oxc-project/oxc/commits/oxlint_v1.89.0/npm/oxlint">compare
view</a></li>
</ul>
</details>
<br />

Updates `vitest` from 5.0.4 to 5.0.5
<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.5</h2>
<h3>   🐞 Bug Fixes</h3>
<ul>
<li>Keep vite default <code>fs.deny</code> with api token file  -  by <a
href="https://github.com/hi-ogawa"><code>@​hi-ogawa</code></a>,
<strong>Hiroshi Ogawa</strong> and <strong>Claude Opus 5.5</strong> in
<a
href="https://redirect.github.com/vitest-dev/vitest/issues/11578">vitest-dev/vitest#11578</a>
<a href="https://github.com/vitest-dev/vitest/commit/28dc1edfb"><!-- raw
HTML omitted -->(28dc1)<!-- raw HTML omitted --></a></li>
<li>Handle quoted file names in <code>--changed</code>  -  by <a
href="https://github.com/sheremet-va"><code>@​sheremet-va</code></a> in
<a
href="https://redirect.github.com/vitest-dev/vitest/issues/11583">vitest-dev/vitest#11583</a>
<a href="https://github.com/vitest-dev/vitest/commit/14921e962"><!-- raw
HTML omitted -->(14921)<!-- raw HTML omitted --></a></li>
<li>Add <code>data</code> to the <code>import.meta.hot</code> stub  - 
by <a
href="https://github.com/sheremet-va"><code>@​sheremet-va</code></a> in
<a
href="https://redirect.github.com/vitest-dev/vitest/issues/11590">vitest-dev/vitest#11590</a>
<a href="https://github.com/vitest-dev/vitest/commit/3a8289ac3"><!-- raw
HTML omitted -->(3a828)<!-- raw HTML omitted --></a></li>
<li>Set <code>skip</code> in <code>skipIf</code>, ignore
<code>undefined</code> options  -  by <a
href="https://github.com/sheremet-va"><code>@​sheremet-va</code></a> in
<a
href="https://redirect.github.com/vitest-dev/vitest/issues/11591">vitest-dev/vitest#11591</a>
<a href="https://github.com/vitest-dev/vitest/commit/d523a15f2"><!-- raw
HTML omitted -->(d523a)<!-- raw HTML omitted --></a></li>
<li>Skip builtin loading in async leak detector  -  by <a
href="https://github.com/sheremet-va"><code>@​sheremet-va</code></a> in
<a
href="https://redirect.github.com/vitest-dev/vitest/issues/11598">vitest-dev/vitest#11598</a>
<a href="https://github.com/vitest-dev/vitest/commit/fe86a64c6"><!-- raw
HTML omitted -->(fe86a)<!-- raw HTML omitted --></a></li>
<li>Keep the import frame in native loader errors  -  by <a
href="https://github.com/sheremet-va"><code>@​sheremet-va</code></a> in
<a
href="https://redirect.github.com/vitest-dev/vitest/issues/11599">vitest-dev/vitest#11599</a>
<a href="https://github.com/vitest-dev/vitest/commit/9697b3894"><!-- raw
HTML omitted -->(9697b)<!-- raw HTML omitted --></a></li>
<li>Run hook cleanups when a later hook fails  -  by <a
href="https://github.com/sheremet-va"><code>@​sheremet-va</code></a> in
<a
href="https://redirect.github.com/vitest-dev/vitest/issues/11605">vitest-dev/vitest#11605</a>
<a href="https://github.com/vitest-dev/vitest/commit/6c5a44720"><!-- raw
HTML omitted -->(6c5a4)<!-- raw HTML omitted --></a></li>
<li>Release mocks of finished test files  -  by <a
href="https://github.com/sheremet-va"><code>@​sheremet-va</code></a> in
<a
href="https://redirect.github.com/vitest-dev/vitest/issues/11607">vitest-dev/vitest#11607</a>
<a href="https://github.com/vitest-dev/vitest/commit/e9c3d72f0"><!-- raw
HTML omitted -->(e9c3d)<!-- raw HTML omitted --></a></li>
<li>Abort all running tests on cancel  -  by <a
href="https://github.com/sheremet-va"><code>@​sheremet-va</code></a> in
<a
href="https://redirect.github.com/vitest-dev/vitest/issues/11606">vitest-dev/vitest#11606</a>
<a href="https://github.com/vitest-dev/vitest/commit/6621a72d6"><!-- raw
HTML omitted -->(6621a)<!-- raw HTML omitted --></a></li>
<li><strong>browser</strong>:
<ul>
<li>Ignore synthetic pagehide events  -  by <a
href="https://github.com/sheremet-va"><code>@​sheremet-va</code></a> in
<a
href="https://redirect.github.com/vitest-dev/vitest/issues/11582">vitest-dev/vitest#11582</a>
<a href="https://github.com/vitest-dev/vitest/commit/ca0813584"><!-- raw
HTML omitted -->(ca081)<!-- raw HTML omitted --></a></li>
<li>Run plugin buildStart on 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/11593">vitest-dev/vitest#11593</a>
<a href="https://github.com/vitest-dev/vitest/commit/ea611e44c"><!-- raw
HTML omitted -->(ea611)<!-- raw HTML omitted --></a></li>
<li>Drop late connections of closed sessions  -  by <a
href="https://github.com/sheremet-va"><code>@​sheremet-va</code></a> in
<a
href="https://redirect.github.com/vitest-dev/vitest/issues/11600">vitest-dev/vitest#11600</a>
<a href="https://github.com/vitest-dev/vitest/commit/f161e6df2"><!-- raw
HTML omitted -->(f161e)<!-- raw HTML omitted --></a></li>
<li>Wait 30s for the browser to close  -  by <a
href="https://github.com/sheremet-va"><code>@​sheremet-va</code></a> in
<a
href="https://redirect.github.com/vitest-dev/vitest/issues/11601">vitest-dev/vitest#11601</a>
<a href="https://github.com/vitest-dev/vitest/commit/6498b666a"><!-- raw
HTML omitted -->(6498b)<!-- raw HTML omitted --></a></li>
<li>Run global setup once with instances  -  by <a
href="https://github.com/sheremet-va"><code>@​sheremet-va</code></a> in
<a
href="https://redirect.github.com/vitest-dev/vitest/issues/11596">vitest-dev/vitest#11596</a>
<a href="https://github.com/vitest-dev/vitest/commit/8dd67c262"><!-- raw
HTML omitted -->(8dd67)<!-- raw HTML omitted --></a></li>
</ul>
</li>
<li><strong>reporter</strong>:
<ul>
<li>End reason
<code>&amp;[#39](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest/issues/39);failed&amp;[#39](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest/issues/39);</code>
on worker crash  -  by <a
href="https://github.com/AriPerkkio"><code>@​AriPerkkio</code></a> in <a
href="https://redirect.github.com/vitest-dev/vitest/issues/11568">vitest-dev/vitest#11568</a>
<a href="https://github.com/vitest-dev/vitest/commit/6ae8e89ff"><!-- raw
HTML omitted -->(6ae8e)<!-- raw HTML omitted --></a></li>
</ul>
</li>
</ul>
<h5>    <a
href="https://github.com/vitest-dev/vitest/compare/v5.0.4...v5.0.5">View
changes on GitHub</a></h5>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/vitest-dev/vitest/commit/7a825db43e2beca0b219d2eadaec334cb81af0c7"><code>7a825db</code></a>
chore: release v5.0.5 (<a
href="https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest/issues/11609">#11609</a>)</li>
<li><a
href="https://github.com/vitest-dev/vitest/commit/6621a72d6ed371e43f80ff327f5063693bd6b1ad"><code>6621a72</code></a>
fix: abort all running tests on cancel (<a
href="https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest/issues/11606">#11606</a>)</li>
<li><a
href="https://github.com/vitest-dev/vitest/commit/e9c3d72f0c8d6867de06a3df43ea28a05eeec444"><code>e9c3d72</code></a>
fix: release mocks of finished test files (<a
href="https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest/issues/11607">#11607</a>)</li>
<li><a
href="https://github.com/vitest-dev/vitest/commit/8dd67c2625943d824e257b87ae57a14094e90eed"><code>8dd67c2</code></a>
fix(browser): run global setup once with instances (<a
href="https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest/issues/11596">#11596</a>)</li>
<li><a
href="https://github.com/vitest-dev/vitest/commit/6c5a447203a29a0708fbc5de4fb91ea501bdc395"><code>6c5a447</code></a>
fix: run hook cleanups when a later hook fails (<a
href="https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest/issues/11605">#11605</a>)</li>
<li><a
href="https://github.com/vitest-dev/vitest/commit/9697b3894f456b85db91f3824ebe833449305c04"><code>9697b38</code></a>
fix: keep the import frame in native loader errors (<a
href="https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest/issues/11599">#11599</a>)</li>
<li><a
href="https://github.com/vitest-dev/vitest/commit/6498b666a3ae8961f6f2a8abdc89015b6fd0b3aa"><code>6498b66</code></a>
fix(browser): wait 30s for the browser to close (<a
href="https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest/issues/11601">#11601</a>)</li>
<li><a
href="https://github.com/vitest-dev/vitest/commit/f161e6df2b3c0bc84982d374094379139e380791"><code>f161e6d</code></a>
fix(browser): drop late connections of closed sessions (<a
href="https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest/issues/11600">#11600</a>)</li>
<li><a
href="https://github.com/vitest-dev/vitest/commit/fe86a64c6c6aa75481bffe5880b5c4e35ea67927"><code>fe86a64</code></a>
fix: skip builtin loading in async leak detector (<a
href="https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest/issues/11598">#11598</a>)</li>
<li><a
href="https://github.com/vitest-dev/vitest/commit/6ae8e89ff0734cf476e30186a83aebe6771aa330"><code>6ae8e89</code></a>
fix(reporter): end reason <code>'failed'</code> on worker crash (<a
href="https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest/issues/11568">#11568</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/vitest-dev/vitest/commits/v5.0.5/packages/vitest">compare
view</a></li>
</ul>
</details>
<br />


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

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

---

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

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


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-23 14:39:54 -04:00
dependabot[bot] 1c2eb4efec build(deps): bump the gh-actions group with 3 updates (#1552)
[//]: # (dependabot-start)
⚠️  **Dependabot is rebasing this PR** ⚠️ 

Rebasing might not happen immediately, so don't worry if this takes some
time.

Note: if you make any changes to this PR yourself, they will take
precedence over the rebase.

---

[//]: # (dependabot-end)

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.38.3 to 4.39.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.39.0</h2>
<ul>
<li><em>Breaking change</em>: Bump the minimum required CodeQL bundle
version to 2.20.7. <a
href="https://redirect.github.com/github/codeql-action/pull/4205">#4205</a></li>
<li>The <code>codeql-action/analyze</code> step now validates that a
correct argument for the <code>checkout_path</code> input has been
provided. The <code>checkout_path</code> input must be set to the path
where the repository that is being analyzed is checked out at on the
runner, if that path is something other than the workspace root. If the
path does not point to a repository checkout or is different from the
path that was used by <code>codeql-action/init</code>, then the CodeQL
Action now produces a warning. <a
href="https://redirect.github.com/github/codeql-action/pull/4148">#4148</a></li>
<li>Resolved an issue with third-party caching implementations that
prevented caches from being restored correctly. <a
href="https://redirect.github.com/github/codeql-action/pull/4206">#4206</a></li>
<li>The <code>codeql-action/start-proxy</code> step that is used in Code
Scanning Default Setup now finds CodeQL releases using the same logic as
<code>codeql-action/init</code>. This primarily benefits customers who
are using GitHub instances other than <code>github.com</code> where the
<code>codeql-action/start-proxy</code> step may previously have been
unable to locate recent releases and ended up using outdated versions of
the private registry authentication proxy as a result. <a
href="https://redirect.github.com/github/codeql-action/pull/4198">#4198</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.39.1 - 21 Oct 2026</h2>
<ul>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.27.3">2.27.3</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/4256">#4256</a></li>
</ul>
<h2>4.39.0 - 15 Oct 2026</h2>
<ul>
<li><em>Breaking change</em>: Bump the minimum required CodeQL bundle
version to 2.20.7. <a
href="https://redirect.github.com/github/codeql-action/pull/4205">#4205</a></li>
<li>The <code>codeql-action/analyze</code> step now validates that a
correct argument for the <code>checkout_path</code> input has been
provided. The <code>checkout_path</code> input must be set to the path
where the repository that is being analyzed is checked out at on the
runner, if that path is something other than the workspace root. If the
path does not point to a repository checkout or is different from the
path that was used by <code>codeql-action/init</code>, then the CodeQL
Action now produces a warning. <a
href="https://redirect.github.com/github/codeql-action/pull/4148">#4148</a></li>
<li>Resolved an issue with third-party caching implementations that
prevented caches from being restored correctly. <a
href="https://redirect.github.com/github/codeql-action/pull/4206">#4206</a></li>
<li>The <code>codeql-action/start-proxy</code> step that is used in Code
Scanning Default Setup now finds CodeQL releases using the same logic as
<code>codeql-action/init</code>. This primarily benefits customers who
are using GitHub instances other than <code>github.com</code> where the
<code>codeql-action/start-proxy</code> step may previously have been
unable to locate recent releases and ended up using outdated versions of
the private registry authentication proxy as a result. <a
href="https://redirect.github.com/github/codeql-action/pull/4198">#4198</a></li>
</ul>
<h2>4.38.3 - 08 Oct 2026</h2>
<ul>
<li><em>Upcoming breaking change</em>: CodeQL version 2.21.2 and earlier
were discontinued on 24 September 2026 alongside GitHub Enterprise
Server 3.17, and will be unsupported by the next minor release of the
CodeQL Action. Added a deprecation warning for customers using these
versions of CodeQL. <a
href="https://redirect.github.com/github/codeql-action/pull/4188">#4188</a></li>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.27.2">2.27.2</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/4203">#4203</a></li>
<li>Fixed a bug where the decision of whether to use a per-language
bundle did not account for custom configurations that reference queries
outside of compiled CodeQL packs. This issue was caught during internal
testing and did not affect any customer repositories. We will resume the
roll out of per-language bundles in the coming weeks. <a
href="https://redirect.github.com/github/codeql-action/pull/4184">#4184</a></li>
</ul>
<h2>4.38.2 - 24 Sept 2026</h2>
<ul>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.27.1">2.27.1</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/4160">#4160</a></li>
</ul>
<h2>4.38.1 - 18 Sept 2026</h2>
<ul>
<li>The CodeQL Action now has experimental support for CodeQL releases
for which per-language bundles are available. Per-language bundles
support analysis for a single language and are therefore smaller than
the combined bundles that allow analysis for all supported languages. As
a result, per-language bundles take up less space on disk and are faster
to download. We expect to roll this change out to everyone in the coming
weeks. <a
href="https://redirect.github.com/github/codeql-action/pull/4146">#4146</a></li>
</ul>
<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>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/github/codeql-action/commit/6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee"><code>6f2ac81</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4239">#4239</a>
from github/update-v4.39.0-d63383a73</li>
<li><a
href="https://github.com/github/codeql-action/commit/eb1c428e6e3725d2b527aec0ee5db4303c8f669d"><code>eb1c428</code></a>
Trigger workflows</li>
<li><a
href="https://github.com/github/codeql-action/commit/444333376e064d207fe1dc74e881528bf11649c1"><code>4443333</code></a>
Update changelog for v4.39.0</li>
<li><a
href="https://github.com/github/codeql-action/commit/d63383a7318b5332e86725adf62ac19528a9786a"><code>d63383a</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4233">#4233</a>
from github/mbg/start-proxy/report-version</li>
<li><a
href="https://github.com/github/codeql-action/commit/8dec0b9f88b61a76f5fc97056ee8e7dc8a1a8022"><code>8dec0b9</code></a>
Rename <code>path</code> to <code>result</code> in tests</li>
<li><a
href="https://github.com/github/codeql-action/commit/4eabea6b5e5123ae16fffacf06e86d9d3864c350"><code>4eabea6</code></a>
Merge remote-tracking branch 'origin/main' into
mbg/start-proxy/report-version</li>
<li><a
href="https://github.com/github/codeql-action/commit/57938b85687300731b9b53559017c143d849481d"><code>57938b8</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4201">#4201</a>
from github/mario-campos/automate-changenotes-assemble</li>
<li><a
href="https://github.com/github/codeql-action/commit/ee07b503f465bcf3338b6e4807a36789e3106f4b"><code>ee07b50</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4236">#4236</a>
from github/mbg/add-missing-changenotes</li>
<li><a
href="https://github.com/github/codeql-action/commit/a897ed5c2eeeb4862bf224c65f62a8b2478eb581"><code>a897ed5</code></a>
Add change note for <a
href="https://redirect.github.com/github/codeql-action/issues/4206">#4206</a></li>
<li><a
href="https://github.com/github/codeql-action/commit/c8bd0195fb2ec9584b1bd6faf9ff5e2983c63d82"><code>c8bd019</code></a>
Add change note for <a
href="https://redirect.github.com/github/codeql-action/issues/4198">#4198</a></li>
<li>Additional commits viewable in <a
href="https://github.com/github/codeql-action/compare/24c54180a607b1449ed407dd24f251e4e9147c8d...6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee">compare
view</a></li>
</ul>
</details>
<br />

Updates `github/codeql-action/autobuild` from 4.38.3 to 4.39.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.39.0</h2>
<ul>
<li><em>Breaking change</em>: Bump the minimum required CodeQL bundle
version to 2.20.7. <a
href="https://redirect.github.com/github/codeql-action/pull/4205">#4205</a></li>
<li>The <code>codeql-action/analyze</code> step now validates that a
correct argument for the <code>checkout_path</code> input has been
provided. The <code>checkout_path</code> input must be set to the path
where the repository that is being analyzed is checked out at on the
runner, if that path is something other than the workspace root. If the
path does not point to a repository checkout or is different from the
path that was used by <code>codeql-action/init</code>, then the CodeQL
Action now produces a warning. <a
href="https://redirect.github.com/github/codeql-action/pull/4148">#4148</a></li>
<li>Resolved an issue with third-party caching implementations that
prevented caches from being restored correctly. <a
href="https://redirect.github.com/github/codeql-action/pull/4206">#4206</a></li>
<li>The <code>codeql-action/start-proxy</code> step that is used in Code
Scanning Default Setup now finds CodeQL releases using the same logic as
<code>codeql-action/init</code>. This primarily benefits customers who
are using GitHub instances other than <code>github.com</code> where the
<code>codeql-action/start-proxy</code> step may previously have been
unable to locate recent releases and ended up using outdated versions of
the private registry authentication proxy as a result. <a
href="https://redirect.github.com/github/codeql-action/pull/4198">#4198</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.39.1 - 21 Oct 2026</h2>
<ul>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.27.3">2.27.3</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/4256">#4256</a></li>
</ul>
<h2>4.39.0 - 15 Oct 2026</h2>
<ul>
<li><em>Breaking change</em>: Bump the minimum required CodeQL bundle
version to 2.20.7. <a
href="https://redirect.github.com/github/codeql-action/pull/4205">#4205</a></li>
<li>The <code>codeql-action/analyze</code> step now validates that a
correct argument for the <code>checkout_path</code> input has been
provided. The <code>checkout_path</code> input must be set to the path
where the repository that is being analyzed is checked out at on the
runner, if that path is something other than the workspace root. If the
path does not point to a repository checkout or is different from the
path that was used by <code>codeql-action/init</code>, then the CodeQL
Action now produces a warning. <a
href="https://redirect.github.com/github/codeql-action/pull/4148">#4148</a></li>
<li>Resolved an issue with third-party caching implementations that
prevented caches from being restored correctly. <a
href="https://redirect.github.com/github/codeql-action/pull/4206">#4206</a></li>
<li>The <code>codeql-action/start-proxy</code> step that is used in Code
Scanning Default Setup now finds CodeQL releases using the same logic as
<code>codeql-action/init</code>. This primarily benefits customers who
are using GitHub instances other than <code>github.com</code> where the
<code>codeql-action/start-proxy</code> step may previously have been
unable to locate recent releases and ended up using outdated versions of
the private registry authentication proxy as a result. <a
href="https://redirect.github.com/github/codeql-action/pull/4198">#4198</a></li>
</ul>
<h2>4.38.3 - 08 Oct 2026</h2>
<ul>
<li><em>Upcoming breaking change</em>: CodeQL version 2.21.2 and earlier
were discontinued on 24 September 2026 alongside GitHub Enterprise
Server 3.17, and will be unsupported by the next minor release of the
CodeQL Action. Added a deprecation warning for customers using these
versions of CodeQL. <a
href="https://redirect.github.com/github/codeql-action/pull/4188">#4188</a></li>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.27.2">2.27.2</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/4203">#4203</a></li>
<li>Fixed a bug where the decision of whether to use a per-language
bundle did not account for custom configurations that reference queries
outside of compiled CodeQL packs. This issue was caught during internal
testing and did not affect any customer repositories. We will resume the
roll out of per-language bundles in the coming weeks. <a
href="https://redirect.github.com/github/codeql-action/pull/4184">#4184</a></li>
</ul>
<h2>4.38.2 - 24 Sept 2026</h2>
<ul>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.27.1">2.27.1</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/4160">#4160</a></li>
</ul>
<h2>4.38.1 - 18 Sept 2026</h2>
<ul>
<li>The CodeQL Action now has experimental support for CodeQL releases
for which per-language bundles are available. Per-language bundles
support analysis for a single language and are therefore smaller than
the combined bundles that allow analysis for all supported languages. As
a result, per-language bundles take up less space on disk and are faster
to download. We expect to roll this change out to everyone in the coming
weeks. <a
href="https://redirect.github.com/github/codeql-action/pull/4146">#4146</a></li>
</ul>
<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>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/github/codeql-action/commit/6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee"><code>6f2ac81</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4239">#4239</a>
from github/update-v4.39.0-d63383a73</li>
<li><a
href="https://github.com/github/codeql-action/commit/eb1c428e6e3725d2b527aec0ee5db4303c8f669d"><code>eb1c428</code></a>
Trigger workflows</li>
<li><a
href="https://github.com/github/codeql-action/commit/444333376e064d207fe1dc74e881528bf11649c1"><code>4443333</code></a>
Update changelog for v4.39.0</li>
<li><a
href="https://github.com/github/codeql-action/commit/d63383a7318b5332e86725adf62ac19528a9786a"><code>d63383a</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4233">#4233</a>
from github/mbg/start-proxy/report-version</li>
<li><a
href="https://github.com/github/codeql-action/commit/8dec0b9f88b61a76f5fc97056ee8e7dc8a1a8022"><code>8dec0b9</code></a>
Rename <code>path</code> to <code>result</code> in tests</li>
<li><a
href="https://github.com/github/codeql-action/commit/4eabea6b5e5123ae16fffacf06e86d9d3864c350"><code>4eabea6</code></a>
Merge remote-tracking branch 'origin/main' into
mbg/start-proxy/report-version</li>
<li><a
href="https://github.com/github/codeql-action/commit/57938b85687300731b9b53559017c143d849481d"><code>57938b8</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4201">#4201</a>
from github/mario-campos/automate-changenotes-assemble</li>
<li><a
href="https://github.com/github/codeql-action/commit/ee07b503f465bcf3338b6e4807a36789e3106f4b"><code>ee07b50</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4236">#4236</a>
from github/mbg/add-missing-changenotes</li>
<li><a
href="https://github.com/github/codeql-action/commit/a897ed5c2eeeb4862bf224c65f62a8b2478eb581"><code>a897ed5</code></a>
Add change note for <a
href="https://redirect.github.com/github/codeql-action/issues/4206">#4206</a></li>
<li><a
href="https://github.com/github/codeql-action/commit/c8bd0195fb2ec9584b1bd6faf9ff5e2983c63d82"><code>c8bd019</code></a>
Add change note for <a
href="https://redirect.github.com/github/codeql-action/issues/4198">#4198</a></li>
<li>Additional commits viewable in <a
href="https://github.com/github/codeql-action/compare/24c54180a607b1449ed407dd24f251e4e9147c8d...6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee">compare
view</a></li>
</ul>
</details>
<br />

Updates `github/codeql-action/analyze` from 4.38.3 to 4.39.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.39.0</h2>
<ul>
<li><em>Breaking change</em>: Bump the minimum required CodeQL bundle
version to 2.20.7. <a
href="https://redirect.github.com/github/codeql-action/pull/4205">#4205</a></li>
<li>The <code>codeql-action/analyze</code> step now validates that a
correct argument for the <code>checkout_path</code> input has been
provided. The <code>checkout_path</code> input must be set to the path
where the repository that is being analyzed is checked out at on the
runner, if that path is something other than the workspace root. If the
path does not point to a repository checkout or is different from the
path that was used by <code>codeql-action/init</code>, then the CodeQL
Action now produces a warning. <a
href="https://redirect.github.com/github/codeql-action/pull/4148">#4148</a></li>
<li>Resolved an issue with third-party caching implementations that
prevented caches from being restored correctly. <a
href="https://redirect.github.com/github/codeql-action/pull/4206">#4206</a></li>
<li>The <code>codeql-action/start-proxy</code> step that is used in Code
Scanning Default Setup now finds CodeQL releases using the same logic as
<code>codeql-action/init</code>. This primarily benefits customers who
are using GitHub instances other than <code>github.com</code> where the
<code>codeql-action/start-proxy</code> step may previously have been
unable to locate recent releases and ended up using outdated versions of
the private registry authentication proxy as a result. <a
href="https://redirect.github.com/github/codeql-action/pull/4198">#4198</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.39.1 - 21 Oct 2026</h2>
<ul>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.27.3">2.27.3</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/4256">#4256</a></li>
</ul>
<h2>4.39.0 - 15 Oct 2026</h2>
<ul>
<li><em>Breaking change</em>: Bump the minimum required CodeQL bundle
version to 2.20.7. <a
href="https://redirect.github.com/github/codeql-action/pull/4205">#4205</a></li>
<li>The <code>codeql-action/analyze</code> step now validates that a
correct argument for the <code>checkout_path</code> input has been
provided. The <code>checkout_path</code> input must be set to the path
where the repository that is being analyzed is checked out at on the
runner, if that path is something other than the workspace root. If the
path does not point to a repository checkout or is different from the
path that was used by <code>codeql-action/init</code>, then the CodeQL
Action now produces a warning. <a
href="https://redirect.github.com/github/codeql-action/pull/4148">#4148</a></li>
<li>Resolved an issue with third-party caching implementations that
prevented caches from being restored correctly. <a
href="https://redirect.github.com/github/codeql-action/pull/4206">#4206</a></li>
<li>The <code>codeql-action/start-proxy</code> step that is used in Code
Scanning Default Setup now finds CodeQL releases using the same logic as
<code>codeql-action/init</code>. This primarily benefits customers who
are using GitHub instances other than <code>github.com</code> where the
<code>codeql-action/start-proxy</code> step may previously have been
unable to locate recent releases and ended up using outdated versions of
the private registry authentication proxy as a result. <a
href="https://redirect.github.com/github/codeql-action/pull/4198">#4198</a></li>
</ul>
<h2>4.38.3 - 08 Oct 2026</h2>
<ul>
<li><em>Upcoming breaking change</em>: CodeQL version 2.21.2 and earlier
were discontinued on 24 September 2026 alongside GitHub Enterprise
Server 3.17, and will be unsupported by the next minor release of the
CodeQL Action. Added a deprecation warning for customers using these
versions of CodeQL. <a
href="https://redirect.github.com/github/codeql-action/pull/4188">#4188</a></li>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.27.2">2.27.2</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/4203">#4203</a></li>
<li>Fixed a bug where the decision of whether to use a per-language
bundle did not account for custom configurations that reference queries
outside of compiled CodeQL packs. This issue was caught during internal
testing and did not affect any customer repositories. We will resume the
roll out of per-language bundles in the coming weeks. <a
href="https://redirect.github.com/github/codeql-action/pull/4184">#4184</a></li>
</ul>
<h2>4.38.2 - 24 Sept 2026</h2>
<ul>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.27.1">2.27.1</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/4160">#4160</a></li>
</ul>
<h2>4.38.1 - 18 Sept 2026</h2>
<ul>
<li>The CodeQL Action now has experimental support for CodeQL releases
for which per-language bundles are available. Per-language bundles
support analysis for a single language and are therefore smaller than
the combined bundles that allow analysis for all supported languages. As
a result, per-language bundles take up less space on disk and are faster
to download. We expect to roll this change out to everyone in the coming
weeks. <a
href="https://redirect.github.com/github/codeql-action/pull/4146">#4146</a></li>
</ul>
<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>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/github/codeql-action/commit/6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee"><code>6f2ac81</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4239">#4239</a>
from github/update-v4.39.0-d63383a73</li>
<li><a
href="https://github.com/github/codeql-action/commit/eb1c428e6e3725d2b527aec0ee5db4303c8f669d"><code>eb1c428</code></a>
Trigger workflows</li>
<li><a
href="https://github.com/github/codeql-action/commit/444333376e064d207fe1dc74e881528bf11649c1"><code>4443333</code></a>
Update changelog for v4.39.0</li>
<li><a
href="https://github.com/github/codeql-action/commit/d63383a7318b5332e86725adf62ac19528a9786a"><code>d63383a</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4233">#4233</a>
from github/mbg/start-proxy/report-version</li>
<li><a
href="https://github.com/github/codeql-action/commit/8dec0b9f88b61a76f5fc97056ee8e7dc8a1a8022"><code>8dec0b9</code></a>
Rename <code>path</code> to <code>result</code> in tests</li>
<li><a
href="https://github.com/github/codeql-action/commit/4eabea6b5e5123ae16fffacf06e86d9d3864c350"><code>4eabea6</code></a>
Merge remote-tracking branch 'origin/main' into
mbg/start-proxy/report-version</li>
<li><a
href="https://github.com/github/codeql-action/commit/57938b85687300731b9b53559017c143d849481d"><code>57938b8</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4201">#4201</a>
from github/mario-campos/automate-changenotes-assemble</li>
<li><a
href="https://github.com/github/codeql-action/commit/ee07b503f465bcf3338b6e4807a36789e3106f4b"><code>ee07b50</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4236">#4236</a>
from github/mbg/add-missing-changenotes</li>
<li><a
href="https://github.com/github/codeql-action/commit/a897ed5c2eeeb4862bf224c65f62a8b2478eb581"><code>a897ed5</code></a>
Add change note for <a
href="https://redirect.github.com/github/codeql-action/issues/4206">#4206</a></li>
<li><a
href="https://github.com/github/codeql-action/commit/c8bd0195fb2ec9584b1bd6faf9ff5e2983c63d82"><code>c8bd019</code></a>
Add change note for <a
href="https://redirect.github.com/github/codeql-action/issues/4198">#4198</a></li>
<li>Additional commits viewable in <a
href="https://github.com/github/codeql-action/compare/24c54180a607b1449ed407dd24f251e4e9147c8d...6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee">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-10-23 14:39:36 -04:00
dependabot[bot] 3ff6d88681 build(deps): bump astral-sh/uv from 0.12.24 to 0.13.5 in the docker-base-image-digests group (#1550)
> [!WARNING]
> Cooldown could not be applied because no publication date was
available from the registry.
>

Bumps the docker-base-image-digests group with 1 update:
[astral-sh/uv](https://github.com/astral-sh/uv).

Updates `astral-sh/uv` from 0.12.24 to 0.13.5
<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.13.5</h2>
<h2>Release Notes</h2>
<p>Released on 2026-10-21.</p>
<p>Following <a
href="https://github.com/astral-sh/uv/releases/tag/0.13.4">uv
0.13.4</a>, which removed the affected eMudhra/emSign root from uv's own
certificate store, this release updates the latest managed CPython
builds for 3.11 and newer to <a
href="https://github.com/astral-sh/python-build-standalone/releases/tag/20261021">python-build-standalone
20261021</a>, including the CPython <a
href="https://www.python.org/downloads/release/python-3151/">3.15.1</a>,
<a
href="https://www.python.org/downloads/release/python-3149/">3.14.9</a>,
and <a
href="https://www.python.org/downloads/release/python-31317/">3.13.17</a>
security patch releases. These builds include <a
href="https://pip.pypa.io/en/stable/news/#v26-2-2">pip 26.2.2</a> both
preinstalled in the managed interpreter and bundled with
<code>ensurepip</code>, which installs pip by default when you run
<code>python -m venv</code>. Both copies use certifi 2026.10.19, which
removes the same affected root. After upgrading uv, run <code>uv python
upgrade</code> to install the updated builds. Virtual environments
pinned to an older Python patch version do not switch to the updated
interpreter automatically. Upgrade pip separately in existing virtual
environments.</p>
<p>Please note upstream CPython 3.9 and 3.10 still bundle pip releases
whose certificate bundles trust the affected eMudhra/emSign root. We
recommend migrating off of those versions and will no longer be
releasing Docker images with uv installed for EOL Python versions.</p>
<h3>Enhancements</h3>
<ul>
<li>Report incompatible self-dependencies directly in resolution errors
(<a
href="https://redirect.github.com/astral-sh/uv/pull/21845">#21845</a>)</li>
</ul>
<h3>Python</h3>
<ul>
<li>Add CPython 3.15.1, 3.14.9, and 3.13.17 (<a
href="https://redirect.github.com/astral-sh/uv/pull/22802">#22802</a>)</li>
</ul>
<h3>Preview features</h3>
<ul>
<li>Select and rank wheel variants with no required properties or
properties from PEP 850 static providers in platform-specific
resolutions with <code>wheel-variants</code> (<a
href="https://redirect.github.com/astral-sh/uv/pull/22535">#22535</a>,
<a
href="https://redirect.github.com/astral-sh/uv/pull/22536">#22536</a>,
<a
href="https://redirect.github.com/astral-sh/uv/pull/22687">#22687</a>,
<a
href="https://redirect.github.com/astral-sh/uv/pull/22688">#22688</a>)</li>
</ul>
<h3>Performance</h3>
<ul>
<li>Reduce peak memory use when resolving dependencies across multiple
environments (<a
href="https://redirect.github.com/astral-sh/uv/pull/22769">#22769</a>,
<a
href="https://redirect.github.com/astral-sh/uv/pull/22770">#22770</a>,
<a
href="https://redirect.github.com/astral-sh/uv/pull/22784">#22784</a>)</li>
<li>Speed up exports with complex transitive extras by resolving
activation across the dependency graph (<a
href="https://redirect.github.com/astral-sh/uv/pull/22751">#22751</a>)</li>
<li>Reduce uv's binary size by keeping additional error formatters out
of line (<a
href="https://redirect.github.com/astral-sh/uv/pull/22775">#22775</a>)</li>
<li>Reduce overhead when parsing custom build cache keys (<a
href="https://redirect.github.com/astral-sh/uv/pull/22749">#22749</a>)</li>
</ul>
<h3>Bug fixes</h3>
<ul>
<li>Lock packages distributed only as Android wheels without a source
distribution (<a
href="https://redirect.github.com/astral-sh/uv/pull/22806">#22806</a>)</li>
<li>Export optional dependencies correctly when transitive extras are
activated through different or incompatible environment paths (<a
href="https://redirect.github.com/astral-sh/uv/pull/22751">#22751</a>)</li>
<li>Reject invalid custom build cache keys instead of silently falling
back to the defaults (<a
href="https://redirect.github.com/astral-sh/uv/pull/22749">#22749</a>)</li>
</ul>
<h3>Security</h3>
<ul>
<li>Reject certificate chains containing compromised eMudhra
intermediate keys via <code>rustls-webpki</code> 0.103.16 (<a
href="https://redirect.github.com/astral-sh/uv/pull/22777">#22777</a>)</li>
<li>Stop publishing uv Docker images based on Python 3.9 and 3.10 images
that no longer receive upstream updates (<a
href="https://redirect.github.com/astral-sh/uv/pull/22773">#22773</a>)</li>
</ul>
<h2>Install uv 0.13.5</h2>
<h3>Install prebuilt binaries via shell script</h3>
<pre lang="sh"><code>curl --proto '=https' --tlsv1.2 -LsSf
https://releases.astral.sh/github/uv/releases/download/0.13.5/uv-installer.sh
| sh
</code></pre>
<h3>Install prebuilt binaries via powershell script</h3>
<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/astral-sh/uv/blob/main/CHANGELOG.md">astral-sh/uv's
changelog</a>.</em></p>
<blockquote>
<h2>0.13.5</h2>
<p>Released on 2026-10-21.</p>
<p>Following <a
href="https://github.com/astral-sh/uv/releases/tag/0.13.4">uv
0.13.4</a>, which removed the affected eMudhra/emSign root from uv's own
certificate store, this release updates the latest managed CPython
builds for 3.11 and newer to <a
href="https://github.com/astral-sh/python-build-standalone/releases/tag/20261021">python-build-standalone
20261021</a>, including the CPython <a
href="https://www.python.org/downloads/release/python-3151/">3.15.1</a>,
<a
href="https://www.python.org/downloads/release/python-3149/">3.14.9</a>,
and <a
href="https://www.python.org/downloads/release/python-31317/">3.13.17</a>
security patch releases. These builds include <a
href="https://pip.pypa.io/en/stable/news/#v26-2-2">pip 26.2.2</a> both
preinstalled in the managed interpreter and bundled with
<code>ensurepip</code>, which installs pip by default when you run
<code>python -m venv</code>. Both copies use certifi 2026.10.19, which
removes the same affected root. After upgrading uv, run <code>uv python
upgrade</code> to install the updated builds. Virtual environments
pinned to an older Python patch version do not switch to the updated
interpreter automatically. Upgrade pip separately in existing virtual
environments.</p>
<p>Please note upstream CPython 3.9 and 3.10 still bundle pip releases
whose certificate bundles trust the affected eMudhra/emSign root. We
recommend migrating off of those versions and will no longer be
releasing Docker images with uv installed for EOL Python versions.</p>
<h3>Enhancements</h3>
<ul>
<li>Report incompatible self-dependencies directly in resolution errors
(<a
href="https://redirect.github.com/astral-sh/uv/pull/21845">#21845</a>)</li>
</ul>
<h3>Python</h3>
<ul>
<li>Add CPython 3.15.1, 3.14.9, and 3.13.17 (<a
href="https://redirect.github.com/astral-sh/uv/pull/22802">#22802</a>)</li>
</ul>
<h3>Preview features</h3>
<ul>
<li>Select and rank wheel variants with no required properties or
properties from PEP 850 static providers in platform-specific
resolutions with <code>wheel-variants</code> (<a
href="https://redirect.github.com/astral-sh/uv/pull/22535">#22535</a>,
<a
href="https://redirect.github.com/astral-sh/uv/pull/22536">#22536</a>,
<a
href="https://redirect.github.com/astral-sh/uv/pull/22687">#22687</a>,
<a
href="https://redirect.github.com/astral-sh/uv/pull/22688">#22688</a>)</li>
</ul>
<h3>Performance</h3>
<ul>
<li>Reduce peak memory use when resolving dependencies across multiple
environments (<a
href="https://redirect.github.com/astral-sh/uv/pull/22769">#22769</a>,
<a
href="https://redirect.github.com/astral-sh/uv/pull/22770">#22770</a>,
<a
href="https://redirect.github.com/astral-sh/uv/pull/22784">#22784</a>)</li>
<li>Speed up exports with complex transitive extras by resolving
activation across the dependency graph (<a
href="https://redirect.github.com/astral-sh/uv/pull/22751">#22751</a>)</li>
<li>Reduce uv's binary size by keeping additional error formatters out
of line (<a
href="https://redirect.github.com/astral-sh/uv/pull/22775">#22775</a>)</li>
<li>Reduce overhead when parsing custom build cache keys (<a
href="https://redirect.github.com/astral-sh/uv/pull/22749">#22749</a>)</li>
</ul>
<h3>Bug fixes</h3>
<ul>
<li>Lock packages distributed only as Android wheels without a source
distribution (<a
href="https://redirect.github.com/astral-sh/uv/pull/22806">#22806</a>)</li>
<li>Export optional dependencies correctly when transitive extras are
activated through different or incompatible environment paths (<a
href="https://redirect.github.com/astral-sh/uv/pull/22751">#22751</a>)</li>
<li>Reject invalid custom build cache keys instead of silently falling
back to the defaults (<a
href="https://redirect.github.com/astral-sh/uv/pull/22749">#22749</a>)</li>
</ul>
<h3>Security</h3>
<ul>
<li>Reject certificate chains containing compromised eMudhra
intermediate keys via <code>rustls-webpki</code> 0.103.16 (<a
href="https://redirect.github.com/astral-sh/uv/pull/22777">#22777</a>)</li>
<li>Stop publishing uv Docker images based on Python 3.9 and 3.10 images
that no longer receive upstream updates (<a
href="https://redirect.github.com/astral-sh/uv/pull/22773">#22773</a>)</li>
</ul>
<h2>0.13.4</h2>
<p>Released on 2026-10-20.</p>
<h3>Preview features</h3>
<ul>
<li>Validate PEP 825 variant metadata and treat package versions with
invalid metadata as unavailable under <code>wheel-variants</code> (<a
href="https://redirect.github.com/astral-sh/uv/pull/22512">#22512</a>)</li>
</ul>
<h3>Other changes</h3>
<ul>
<li>Update the bundled Mozilla root certificates via
<code>webpki-root-certs</code> 1.0.10 (<a
href="https://redirect.github.com/astral-sh/uv/pull/22760">#22760</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/46776a95d6686ac0160c10611327ce491e6b202f"><code>46776a9</code></a>
Use checked-in Python metadata for trampoline checks (<a
href="https://redirect.github.com/astral-sh/uv/issues/22811">#22811</a>)</li>
<li><a
href="https://github.com/astral-sh/uv/commit/5babfa90b420ea12a611554f8cbbb41d433534be"><code>5babfa9</code></a>
Use checked-in Python download metadata for releases (<a
href="https://redirect.github.com/astral-sh/uv/issues/22810">#22810</a>)</li>
<li><a
href="https://github.com/astral-sh/uv/commit/06fbc976b1462d56bc83609e1c26250740e9c656"><code>06fbc97</code></a>
Reject Cargo.lock changes from forks (<a
href="https://redirect.github.com/astral-sh/uv/issues/22381">#22381</a>)</li>
<li><a
href="https://github.com/astral-sh/uv/commit/b3c01fd5745ab459863b83380dbd79621073a797"><code>b3c01fd</code></a>
Prepare 0.13.5 (<a
href="https://redirect.github.com/astral-sh/uv/issues/22809">#22809</a>)</li>
<li><a
href="https://github.com/astral-sh/uv/commit/a02d446bcddd8a00c98084709b6bdccbc9a8208f"><code>a02d446</code></a>
Update to include Python 3.15.1, 3.14.9, 3.13.17 (<a
href="https://redirect.github.com/astral-sh/uv/issues/22802">#22802</a>)</li>
<li><a
href="https://github.com/astral-sh/uv/commit/0ee565f1d755dfa5d7ba78db287ed342ec3e4d4b"><code>0ee565f</code></a>
Reproduce local-version marker rendering panic (<a
href="https://redirect.github.com/astral-sh/uv/issues/22807">#22807</a>)</li>
<li><a
href="https://github.com/astral-sh/uv/commit/e3810e1e6daf06095ac58a27dd422eb095ea4ed6"><code>e3810e1</code></a>
Infer platform markers from Android wheel tags (<a
href="https://redirect.github.com/astral-sh/uv/issues/22806">#22806</a>)</li>
<li><a
href="https://github.com/astral-sh/uv/commit/75698b1ed23dd410421de4656304077351641fa7"><code>75698b1</code></a>
Update rustup in Docker builders (<a
href="https://redirect.github.com/astral-sh/uv/issues/22803">#22803</a>)</li>
<li><a
href="https://github.com/astral-sh/uv/commit/1765e51d67f43299d8fbd37b302b773a80c8499d"><code>1765e51</code></a>
Replace actions/attest-build-provenance action with actions/attest
v4.2.2 (<a
href="https://redirect.github.com/astral-sh/uv/issues/2">#2</a>...</li>
<li><a
href="https://github.com/astral-sh/uv/commit/0abe802a1a07417f5163e57549c93c132cd7db3f"><code>0abe802</code></a>
Update Rust crate test-log to v0.2.21 (<a
href="https://redirect.github.com/astral-sh/uv/issues/19721">#19721</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/astral-sh/uv/compare/0.12.24...0.13.5">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=astral-sh/uv&package-manager=docker&previous-version=0.12.24&new-version=0.13.5)](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-10-23 14:39:23 -04:00
dependabot[bot] eb84426890 build(deps): bump werkzeug from 3.1.8 to 3.1.9 (#1553)
Bumps [werkzeug](https://github.com/pallets/werkzeug) from 3.1.8 to
3.1.9.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/pallets/werkzeug/releases">werkzeug's
releases</a>.</em></p>
<blockquote>
<h2>3.1.9</h2>
<p>This is the Werkzeug 3.1.9 security fix release, which fixes security
issues and bugs but does not otherwise change behavior and should not
result in breaking changes compared to the latest feature release.</p>
<p>PyPI: <a
href="https://pypi.org/project/Werkzeug/3.1.9/">https://pypi.org/project/Werkzeug/3.1.9/</a>
Changes: <a
href="https://werkzeug.palletsprojects.com/page/changes/#version-3-1-9">https://werkzeug.palletsprojects.com/page/changes/#version-3-1-9</a>
Milestone: <a
href="https://github.com/pallets/werkzeug/milestone/46?closed=1">https://github.com/pallets/werkzeug/milestone/46?closed=1</a></p>
<ul>
<li><code>safe_join</code> on Windows does not allow special devices
names with empty ADS markers on NTFS. <a
href="https://github.com/pallets/werkzeug/security/advisories/GHSA-g6x2-hccm-hh4m">https://github.com/pallets/werkzeug/security/advisories/GHSA-g6x2-hccm-hh4m</a></li>
<li><code>ProfilerMiddleware</code> uses <code>profiling.tracing</code>
on Python 3.15. <a
href="https://redirect.github.com/pallets/werkzeug/issues/3207">#3207</a></li>
<li><code>uri_to_iri</code> and <code>iri_to_uri</code> preserve empty
username, password, and port 0. <a
href="https://redirect.github.com/pallets/werkzeug/issues/3189">#3189</a></li>
<li>Improve performance of <code>parse_options_header</code>. <a
href="https://redirect.github.com/pallets/werkzeug/issues/3231">#3231</a></li>
<li>Improve performance of <code>parse_etags</code>. <a
href="https://redirect.github.com/pallets/werkzeug/issues/3231">#3231</a></li>
<li>Improve performance of <code>parse_cookie</code>. <a
href="https://redirect.github.com/pallets/werkzeug/issues/3231">#3231</a></li>
<li><code>get_host</code> also checks that the port is in the valid
range. <a
href="https://redirect.github.com/pallets/werkzeug/issues/3236">#3236</a></li>
<li>The <code>int</code> URL converter returns a 404 instead of 500
error when the value is longer than
<code>sys.get_int_max_str_digits()</code>. <a
href="https://redirect.github.com/pallets/werkzeug/issues/3237">#3237</a></li>
<li>Improve debugger PIN generation from cgroup data inside Podman. <a
href="https://redirect.github.com/pallets/werkzeug/issues/3245">#3245</a></li>
<li><code>Authorization</code> parsing <code>basic</code> auth disallows
non-base64 characters. <a
href="https://redirect.github.com/pallets/werkzeug/issues/3248">#3248</a></li>
<li><code>application/x-www-form-urlencoded</code> form data is no
longer limited to <code>max_form_memory_size</code>, only
<code>max_content_length</code>. <a
href="https://redirect.github.com/pallets/werkzeug/issues/3251">#3251</a></li>
<li><code>LimitedStream.readinto</code> does not resize the buffer when
it reads less than the remaining size. <a
href="https://redirect.github.com/pallets/werkzeug/issues/3253">#3253</a></li>
<li>Rules with 10 or more converters in a single part assign matched
values correctly. <a
href="https://redirect.github.com/pallets/werkzeug/issues/3254">#3254</a></li>
<li>The invalid <code>Range</code> suffix length <code>-0</code> is no
longer accepted. <a
href="https://redirect.github.com/pallets/werkzeug/issues/3255">#3255</a></li>
</ul>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/pallets/werkzeug/blob/main/CHANGES.rst">werkzeug's
changelog</a>.</em></p>
<blockquote>
<h2>Version 3.1.9</h2>
<p>Released 2026-09-27</p>
<ul>
<li><code>safe_join</code> on Windows does not allow special devices
names with empty ADS
markers on NTFS. :ghsa:<code>g6x2-hccm-hh4m</code></li>
<li><code>ProfilerMiddleware</code> uses <code>profiling.tracing</code>
on Python 3.15.
:issue:<code>3207</code></li>
<li><code>uri_to_iri</code> and <code>iri_to_uri</code> preserve empty
username, password, and
port 0. :issue:<code>3189</code></li>
<li>Improve performance of <code>parse_options_header</code>.
:pr:<code>3231</code></li>
<li>Improve performance of <code>parse_etags</code>.
:pr:<code>3231</code></li>
<li>Improve performance of <code>parse_cookie</code>.
:pr:<code>3231</code></li>
<li><code>get_host</code> also checks that the port is in the valid
range. :pr:<code>3236</code></li>
<li>The <code>int</code> URL converter returns a 404 instead of 500
error when the value
is longer than <code>sys.get_int_max_str_digits()</code>.
:issue:<code>3237</code></li>
<li>Improve debugger PIN generation from cgroup data inside Podman.
:issue:<code>3245</code></li>
<li><code>Authorization</code> parsing <code>basic</code> auth disallows
non-base64 characters.
:pr:<code>3248</code></li>
<li><code>application/x-www-form-urlencoded</code> form data is no
longer limited to
<code>max_form_memory_size</code>, only <code>max_content_length</code>.
:pr:<code>3251</code></li>
<li><code>LimitedStream.readinto</code> does not resize the buffer when
it reads less
than the remaining size. :pr:<code>3253</code></li>
<li>Rules with 10 or more converters in a single part assign matched
values
correctly. :pr:<code>3254</code></li>
<li>The invalid <code>Range</code> suffix length <code>-0</code> is no
longer accepted. :pr:<code>3255</code></li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/pallets/werkzeug/commit/6604c3b8784ecc7cf5ac0b7412d916989b4c7d45"><code>6604c3b</code></a>
release version 3.1.9</li>
<li><a
href="https://github.com/pallets/werkzeug/commit/a78b30ac0cafe53a4f252ffbf827bf6da7591341"><code>a78b30a</code></a>
update dependencies</li>
<li><a
href="https://github.com/pallets/werkzeug/commit/8d77320bcdf3a34941ec06dcf16b03c065cd21b6"><code>8d77320</code></a>
disallow special name with ads (<a
href="https://redirect.github.com/pallets/werkzeug/issues/3309">#3309</a>)</li>
<li><a
href="https://github.com/pallets/werkzeug/commit/b2a2c09730086ab2d375f0b069c821d4edf526b7"><code>b2a2c09</code></a>
disallow special name with ads</li>
<li><a
href="https://github.com/pallets/werkzeug/commit/4efac9df7ebabe13f82ab0823d5ca0109db20bc3"><code>4efac9d</code></a>
add links</li>
<li><a
href="https://github.com/pallets/werkzeug/commit/400c23e01953311bb0959586f7194191732b18bc"><code>400c23e</code></a>
Allow flit_core 4.x (<a
href="https://redirect.github.com/pallets/werkzeug/issues/3261">#3261</a>)</li>
<li><a
href="https://github.com/pallets/werkzeug/commit/7132a19c26320ae01076d2743df42b9913bbe1d7"><code>7132a19</code></a>
allow flit_core 4</li>
<li><a
href="https://github.com/pallets/werkzeug/commit/6fba87c311e55b4afcef71393a1733498b16676f"><code>6fba87c</code></a>
only accept ASCII digits</li>
<li><a
href="https://github.com/pallets/werkzeug/commit/263f5caf2e26ebd85e38f899628c22f6813c7a2f"><code>263f5ca</code></a>
disallow range -0 (<a
href="https://redirect.github.com/pallets/werkzeug/issues/3255">#3255</a>)</li>
<li><a
href="https://github.com/pallets/werkzeug/commit/0c419f951f3206c89e7c95155f0bd771b02a961f"><code>0c419f9</code></a>
disallow range -0</li>
<li>Additional commits viewable in <a
href="https://github.com/pallets/werkzeug/compare/3.1.8...3.1.9">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=werkzeug&package-manager=uv&previous-version=3.1.8&new-version=3.1.9)](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)
You can disable automated security fix PRs for this repo from the
[Security Alerts
page](https://github.com/calibrain/shelfmark/network/alerts).

</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-23 14:38:57 -04:00
splitsec2 67a969d953 ci(codeql): skip the tests directory (#1557)
CodeQL scans `tests/`, which only runs in CI and the e2e docker stack.
That is where the open `py/full-ssrf` (critical) and the two
`py/reflective-xss` alerts come from, all in the e2e mock server, plus
four `py/incomplete-url-substring-sanitization` alerts on URL asserts.
None of them can reach a running instance.

This adds `.github/codeql/codeql-config.yml` with `paths-ignore: tests`
and points the init step at it. It closes those seven and hides nothing
in `shelfmark/` or the frontend. The rest of the open alerts are
unchanged.

@calibrain I know I have ability to merge this, but wanted you to be the
one for this. It clears the test alerts and leaves the rest, and
prevents you from having to clear them manually. Just a little QoL.
2026-10-23 14:35:31 -04:00
dependabot[bot] 193351c5a0 build(deps): bump certifi from 2026.7.22 to 2026.10.19 (#1554)
Bumps [certifi](https://github.com/certifi/python-certifi) from
2026.7.22 to 2026.10.19.
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/certifi/python-certifi/commit/662df63d7906df66f1f07c0751a3dfe9da14d914"><code>662df63</code></a>
Restore unaffected emSign roots (<a
href="https://redirect.github.com/certifi/python-certifi/issues/439">#439</a>)
(<a
href="https://redirect.github.com/certifi/python-certifi/issues/440">#440</a>)</li>
<li><a
href="https://github.com/certifi/python-certifi/commit/5cb238caeeb6c30ec1a7309ad3e16a5aa7b10ce8"><code>5cb238c</code></a>
Remove emsign (<a
href="https://redirect.github.com/certifi/python-certifi/issues/437">#437</a>)</li>
<li><a
href="https://github.com/certifi/python-certifi/commit/69494a764d0a08f90838d53d4b0fefd3e84045d6"><code>69494a7</code></a>
Bump actions/download-artifact from 8.0.1 to 8.0.2 (<a
href="https://redirect.github.com/certifi/python-certifi/issues/436">#436</a>)</li>
<li><a
href="https://github.com/certifi/python-certifi/commit/029c7b0913d79e2efb73a216fc5c5e13ef958536"><code>029c7b0</code></a>
Bump actions/upload-artifact from 7.0.1 to 7.0.2 (<a
href="https://redirect.github.com/certifi/python-certifi/issues/435">#435</a>)</li>
<li><a
href="https://github.com/certifi/python-certifi/commit/9d0a8f1f3a0d6b2e38d5773db7afe1a37e4527b6"><code>9d0a8f1</code></a>
Add support for Python 3.15 (<a
href="https://redirect.github.com/certifi/python-certifi/issues/432">#432</a>)</li>
<li><a
href="https://github.com/certifi/python-certifi/commit/db472abf0a37067cb4488f036f4c19adf6b6ec3c"><code>db472ab</code></a>
Bump pypa/gh-action-pypi-publish from 1.14.1 to 1.14.2 (<a
href="https://redirect.github.com/certifi/python-certifi/issues/431">#431</a>)</li>
<li>See full diff in <a
href="https://github.com/certifi/python-certifi/compare/2026.07.22...2026.10.19">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=certifi&package-manager=uv&previous-version=2026.7.22&new-version=2026.10.19)](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)
You can disable automated security fix PRs for this repo from the
[Security Alerts
page](https://github.com/calibrain/shelfmark/network/alerts).

</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-23 02:02:35 -06:00
splitsec2 e33803cac2 build(deps): update socket.io-parser, ws and baseline-browser-mapping in the lockfiles (#1560)
Dependabot has four open npm alerts and no PRs for them, since they are
all transitive. This updates the lockfiles only, no package.json change.

| Package | From | To | Alert |
|---|---|---|---|
| socket.io-parser | 4.2.6 | 4.2.7 | zero-attachment memory exhaustion
(high) |
| ws | 8.18.3 | 8.21.3 | memory exhaustion DoS (high) and uninitialized
memory disclosure (medium) |
| engine.io-client | 6.6.4 | 6.6.7 | needed to get ws past its `~8.18`
range |
| baseline-browser-mapping | 2.9.19 | 2.11.31 | process termination on
invalid input (medium, dev only) |

I think the practical risk was low to begin with, since ws is only used
by engine.io-client under Node and the built bundle doesn't include it.
Still, it clears the alerts and the bumps are small.

Done with npm 11 so the `libc` fields in the lockfile stay as they were.
A clean `npm ci` from the new lockfile gives 0 vulnerabilities, and
`tsc`, `vitest` (241 tests) and `vite build` all pass.
2026-10-23 02:00:44 -06:00
dependabot[bot] 00c03e3058 build(deps): bump oauthlib from 3.3.1 to 4.0.0 (#1556)
Bumps [oauthlib](https://github.com/oauthlib/oauthlib) from 3.3.1 to
4.0.0.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/oauthlib/oauthlib/releases">oauthlib's
releases</a>.</em></p>
<blockquote>
<h2>4.0.0</h2>
<h2>Introduction</h2>
<p>The release 4.0.0 defines the foundation that enables AI
contributions and will improve the maintenance of oauthlib by using AI
agents, skills, code for both contributors and maintainers. It includes
devcontainer, skills and cleanup of instructions.</p>
<h2>What's Changed</h2>
<p><strong>Important</strong>: this release contains 2 breaking changes.
See CHANGELOG.rst for details:</p>
<ul>
<li>Removed JSONP support from token revocation endpoint (<a
href="https://redirect.github.com/oauthlib/oauthlib/issues/951">#951</a>)</li>
<li>Client authentication validation reorganized across grants (<a
href="https://redirect.github.com/oauthlib/oauthlib/issues/919">#919</a>,
<a
href="https://redirect.github.com/oauthlib/oauthlib/issues/920">#920</a>):
the <code>grant_type</code> parameter is now validated before client
authentication.</li>
</ul>
<ul>
<li>Replace pyenv with uv in documentation and tooling by <a
href="https://github.com/JonathanHuot"><code>@​JonathanHuot</code></a>
in <a
href="https://redirect.github.com/oauthlib/oauthlib/pull/910">oauthlib/oauthlib#910</a></li>
<li>Improve github action to publish package by <a
href="https://github.com/JonathanHuot"><code>@​JonathanHuot</code></a>
in <a
href="https://redirect.github.com/oauthlib/oauthlib/pull/915">oauthlib/oauthlib#915</a></li>
<li>Add pre-commit to run linters, formatters, etc. on code changes by
<a href="https://github.com/cclauss"><code>@​cclauss</code></a> in <a
href="https://redirect.github.com/oauthlib/oauthlib/pull/918">oauthlib/oauthlib#918</a></li>
<li>Fix client authentication for DeviceCodeGrant when getting a token
by <a href="https://github.com/hekhuisk"><code>@​hekhuisk</code></a> in
<a
href="https://redirect.github.com/oauthlib/oauthlib/pull/920">oauthlib/oauthlib#920</a></li>
<li>Add project URLs to this project's PyPI page by <a
href="https://github.com/Flimm"><code>@​Flimm</code></a> in <a
href="https://redirect.github.com/oauthlib/oauthlib/pull/921">oauthlib/oauthlib#921</a></li>
<li>Fix a typo in ServiceApplicationClient docstring. by <a
href="https://github.com/rafalkrupinski"><code>@​rafalkrupinski</code></a>
in <a
href="https://redirect.github.com/oauthlib/oauthlib/pull/923">oauthlib/oauthlib#923</a></li>
<li>Correct grammar in function help by <a
href="https://github.com/verhovsky"><code>@​verhovsky</code></a> in <a
href="https://redirect.github.com/oauthlib/oauthlib/pull/924">oauthlib/oauthlib#924</a></li>
<li>Add Python 3.14 to the testing by <a
href="https://github.com/cclauss"><code>@​cclauss</code></a> in <a
href="https://redirect.github.com/oauthlib/oauthlib/pull/925">oauthlib/oauthlib#925</a></li>
<li>Initial python/uv/tox devcontainer by <a
href="https://github.com/JonathanHuot"><code>@​JonathanHuot</code></a>
in <a
href="https://redirect.github.com/oauthlib/oauthlib/pull/930">oauthlib/oauthlib#930</a></li>
<li>Fix ruff checks about unused variables by <a
href="https://github.com/JonathanHuot"><code>@​JonathanHuot</code></a>
in <a
href="https://redirect.github.com/oauthlib/oauthlib/pull/931">oauthlib/oauthlib#931</a></li>
<li>Drop EOL Python 3.8 from CI by <a
href="https://github.com/auvipy"><code>@​auvipy</code></a> in <a
href="https://redirect.github.com/oauthlib/oauthlib/pull/932">oauthlib/oauthlib#932</a></li>
<li>Set Open Collective username to 'oauthlib' by <a
href="https://github.com/auvipy"><code>@​auvipy</code></a> in <a
href="https://redirect.github.com/oauthlib/oauthlib/pull/933">oauthlib/oauthlib#933</a></li>
<li>pre-commit autoupdate 2026_02_21 by <a
href="https://github.com/cclauss"><code>@​cclauss</code></a> in <a
href="https://redirect.github.com/oauthlib/oauthlib/pull/934">oauthlib/oauthlib#934</a></li>
<li>Remove a trailing whitespace fo fix failing pre-commit by <a
href="https://github.com/cclauss"><code>@​cclauss</code></a> in <a
href="https://redirect.github.com/oauthlib/oauthlib/pull/935">oauthlib/oauthlib#935</a></li>
<li>Fix typos discovered by typos by <a
href="https://github.com/cclauss"><code>@​cclauss</code></a> in <a
href="https://redirect.github.com/oauthlib/oauthlib/pull/938">oauthlib/oauthlib#938</a></li>
<li>Add <code>resource</code> to Request._params by <a
href="https://github.com/juannyG"><code>@​juannyG</code></a> in <a
href="https://redirect.github.com/oauthlib/oauthlib/pull/942">oauthlib/oauthlib#942</a></li>
<li>Release 3.4.0: Add OAuthLib Maintainer agent by <a
href="https://github.com/JonathanHuot"><code>@​JonathanHuot</code></a>
in <a
href="https://redirect.github.com/oauthlib/oauthlib/pull/950">oauthlib/oauthlib#950</a></li>
<li>Remove JSONP support from token revocation by <a
href="https://github.com/JonathanHuot"><code>@​JonathanHuot</code></a>
in <a
href="https://redirect.github.com/oauthlib/oauthlib/pull/951">oauthlib/oauthlib#951</a></li>
<li>Improve PKCE code comparison by <a
href="https://github.com/JonathanHuot"><code>@​JonathanHuot</code></a>
in <a
href="https://redirect.github.com/oauthlib/oauthlib/pull/963">oauthlib/oauthlib#963</a></li>
<li>Release 4.0.0: bump and update changelog by <a
href="https://github.com/JonathanHuot"><code>@​JonathanHuot</code></a>
in <a
href="https://redirect.github.com/oauthlib/oauthlib/pull/976">oauthlib/oauthlib#976</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a href="https://github.com/hekhuisk"><code>@​hekhuisk</code></a>
made their first contribution in <a
href="https://redirect.github.com/oauthlib/oauthlib/pull/920">oauthlib/oauthlib#920</a></li>
<li><a href="https://github.com/Flimm"><code>@​Flimm</code></a> made
their first contribution in <a
href="https://redirect.github.com/oauthlib/oauthlib/pull/921">oauthlib/oauthlib#921</a></li>
<li><a href="https://github.com/verhovsky"><code>@​verhovsky</code></a>
made their first contribution in <a
href="https://redirect.github.com/oauthlib/oauthlib/pull/924">oauthlib/oauthlib#924</a></li>
<li><a href="https://github.com/juannyG"><code>@​juannyG</code></a> made
their first contribution in <a
href="https://redirect.github.com/oauthlib/oauthlib/pull/942">oauthlib/oauthlib#942</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/oauthlib/oauthlib/compare/v3.3.1...v4.0.0">https://github.com/oauthlib/oauthlib/compare/v3.3.1...v4.0.0</a></p>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/oauthlib/oauthlib/blob/master/CHANGELOG.rst">oauthlib's
changelog</a>.</em></p>
<blockquote>
<h2>4.0.0 (2026-09-28):</h2>
<p>OAuth2.0 Provider:</p>
<ul>
<li><strong>Breaking</strong>: <a
href="https://redirect.github.com/oauthlib/oauthlib/issues/951">#951</a>:
Removed JSONP support from token revocation endpoint.
JSONP has been superseded by CORS for cross-origin requests.
The <code>enable_jsonp</code> parameter has been removed from
<code>RevocationEndpoint</code>
and the <code>callback</code> parameter has been removed from
<code>prepare_token_revocation_request</code>.</li>
<li><strong>Breaking</strong>: <a
href="https://redirect.github.com/oauthlib/oauthlib/issues/919">#919</a>,
<a
href="https://redirect.github.com/oauthlib/oauthlib/issues/920">#920</a>:
Fixed <code>DeviceCodeGrant.validate_token_request</code>
trying to authenticate public clients.
Client authentication validation has been reorganized and is now shared
across <code>AuthorizationCodeGrant</code>,
<code>DeviceCodeGrant</code>, <code>RefreshTokenGrant</code>
and <code>ResourceOwnerPasswordCredentialsGrant</code>: the
<code>grant_type</code> parameter
is validated before client authentication, so requests missing
<code>grant_type</code> now return <code>400 invalid_request</code>
instead of
<code>401 invalid_client</code>.</li>
<li><a
href="https://redirect.github.com/oauthlib/oauthlib/issues/963">#963</a>:
Improved PKCE code comparison</li>
</ul>
<p>Misc:</p>
<ul>
<li><a
href="https://redirect.github.com/oauthlib/oauthlib/issues/904">#904</a>:
Stop installing <code>examples</code> into
<code>site-packages</code>.</li>
<li><a
href="https://redirect.github.com/oauthlib/oauthlib/issues/930">#930</a>:
Add devcontainer, Add Python3.14, Python3.14t.</li>
<li><a
href="https://redirect.github.com/oauthlib/oauthlib/issues/931">#931</a>:
Fix ruff checks about unused variables.</li>
<li><a
href="https://redirect.github.com/oauthlib/oauthlib/issues/932">#932</a>:
Dropped EOL Python 3.8 from CI.</li>
<li><a
href="https://redirect.github.com/oauthlib/oauthlib/issues/934">#934</a>:
Pre-commit hooks autoupdate.</li>
<li><a
href="https://redirect.github.com/oauthlib/oauthlib/issues/938">#938</a>:
Fix typos discovered by typos.</li>
<li>Add OAuthLib Maintainer agent for automated issue/PR triage and
release
management.</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/oauthlib/oauthlib/commit/145a9a4690cb4d9de30d15fcc2984e34c49df741"><code>145a9a4</code></a>
Release 4.0.0: clarify changelog breaking changes and reformat
entries</li>
<li><a
href="https://github.com/oauthlib/oauthlib/commit/c8344d61492c7ae708cf378ecabab7ee6ab62812"><code>c8344d6</code></a>
Update CHANGELOG.rst</li>
<li><a
href="https://github.com/oauthlib/oauthlib/commit/e172830efd66a2dc1bb34b3bbbf8ee53036a9dac"><code>e172830</code></a>
Release 4.0.0: bump version to 4.0.0 and update changelog</li>
<li><a
href="https://github.com/oauthlib/oauthlib/commit/40b0ab56da3682c2484a4b78bbff309f8025d950"><code>40b0ab5</code></a>
Merge pull request <a
href="https://redirect.github.com/oauthlib/oauthlib/issues/963">#963</a>
from oauthlib/ft/pkcecode</li>
<li><a
href="https://github.com/oauthlib/oauthlib/commit/1b68ceaae02fe62aeaaa3468a8f8082c73830a3a"><code>1b68cea</code></a>
Merge pull request <a
href="https://redirect.github.com/oauthlib/oauthlib/issues/920">#920</a>
from hekhuisk/validate-client-authentication</li>
<li><a
href="https://github.com/oauthlib/oauthlib/commit/c951a1d09f99f14e3240973fa83c4f4287d4753d"><code>c951a1d</code></a>
Organized validate_client functions for all grant to avoid mistake in
grnat i...</li>
<li><a
href="https://github.com/oauthlib/oauthlib/commit/74664d3fe037a354e180e305135c6bab1747a6b0"><code>74664d3</code></a>
Improve PKCE code comparison</li>
<li><a
href="https://github.com/oauthlib/oauthlib/commit/9859b057ecc5d1ad42711af7d58ee471d708ea36"><code>9859b05</code></a>
Merge pull request <a
href="https://redirect.github.com/oauthlib/oauthlib/issues/950">#950</a>
from oauthlib/feature/3.4.0-maintainer-agent</li>
<li><a
href="https://github.com/oauthlib/oauthlib/commit/9bf9b974e0797d2d03cba05854f46e314c730ba6"><code>9bf9b97</code></a>
Merge branch 'master' into feature/3.4.0-maintainer-agent</li>
<li><a
href="https://github.com/oauthlib/oauthlib/commit/1ba7429ad79019289540fd7be27866d7e59f2564"><code>1ba7429</code></a>
Clarify agent instructions</li>
<li>Additional commits viewable in <a
href="https://github.com/oauthlib/oauthlib/compare/v3.3.1...v4.0.0">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=oauthlib&package-manager=uv&previous-version=3.3.1&new-version=4.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)
You can disable automated security fix PRs for this repo from the
[Security Alerts
page](https://github.com/calibrain/shelfmark/network/alerts).

</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-23 01:54:25 -06:00
splitsec2 3633dd3d97 fix: keep file links and API keys out of AllDebrid logs, fix Newznab debug log (#1559)
Two log fixes in the same area, both found while going through the open
code scanning alerts.

AllDebrid: the unlock path logged the file link in full. The last-resort
fallback appends `apikey=` to the link and returns it, and the "Failed
to download from ..." error carries that same link into
`logger.exception`, so the key could land in the log and the traceback.
The links are now logged through `_safe_url` (scheme, host and path
only). The GET unlock fallback also logged the exception text at debug
level, and requests puts the request URL in that message, which includes
`apikey=`. That one now logs the exception type only. This covers code
scanning alert 124.

Newznab: `logger.debug("Newznab API: GET %s params=%s", url, *params)`
star-expands the dict keys into the log arguments, so with debug logging
on it raises `TypeError: not all arguments converted during string
formatting` and the line never prints. Logging `params` as is would
print the API key, so it now logs the sorted parameter names.

Tests: the Newznab one formats the logged line and checks the key is
absent. The AllDebrid ones check the fallback and failure paths log no
query string and that the fallback still returns the link with the key.
All three fail without the change. Ruff and basedpyright are clean,
`tests/newznab` and `tests/prowlarr` pass.
2026-10-23 01:53:08 -06:00
splitsec2 4f97fc9bec fix: keep server paths and URLs out of an error response and a debug log (#1558)
Two small leaks from going through the open CodeQL alerts (code scanning
alerts 100 and 123).

The local download route returned `str(e)` on an OSError, and for a
missing or unreadable file that text is the file's path on the server
(`[Errno 2] No such file or directory:
'/audiobooks/Author/Title.epub'`). #1422 cuts a non-admin's download
path down to the file name everywhere else, so this put it back. The
route now answers "Could not read the file" and the detail stays in the
log.

`should_bypass_proxy` logged the whole request URL at debug when it
could not be parsed. URLs can carry credentials, so it logs only the
exception now.

Each change has a test that fails without it. The full non-e2e suite,
ruff and basedpyright pass.
2026-10-23 01:52:39 -06:00
dependabot[bot] 003df0b686 build(deps): bump cryptography from 46.0.7 to 50.0.0 (#1555)
Bumps [cryptography](https://github.com/pyca/cryptography) from 46.0.7
to 50.0.0.
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst">cryptography's
changelog</a>.</em></p>
<blockquote>
<p>50.0.0 - 2026-07-31</p>
<pre><code>
* **SECURITY ISSUE**:

:func:`~cryptography.hazmat.primitives.serialization.pkcs7.pkcs7_decrypt_der`
and its PEM and S/MIME variants no longer expose distinguishable errors
or
timing when unwrapping a ``RecipientInfo``'s ``encryptedKey``, which
could
act as a Bleichenbacher oracle for callers that decrypt untrusted
messages.
A random key is now substituted on failure, as described in :rfc:`3218`.
  Credit to **@X1AOxiang** for reporting the issue. **CVE-2026-69247**
* Deprecated Diffie-Hellman key exchange over finite fields (FFDH).
  Everything FFDH is deprecated, including the types in
``cryptography.hazmat.primitives.asymmetric.dh`` and loading FFDH keys
or
  parameters with the key loading APIs. Users should migrate to a more
  modern key exchange algorithm.
* Added ``xof()`` class methods to
  :class:`~cryptography.hazmat.primitives.hashes.SHAKE128` and
:class:`~cryptography.hazmat.primitives.hashes.SHAKE256` for
constructing
  algorithm instances configured for use with
  :class:`~cryptography.hazmat.primitives.hashes.XOFHash`.
* The :mod:`X.509 verification &lt;cryptography.x509.verification&gt;`
APIs are now
  considered stable and are subject to our API stability policy.
* Added the :doc:`/cobblestone` recipe, an implementation of the
  Cobblestone-128 and Cobblestone-256 instantiations of the `C2SP
  chunked-encryption specification
&lt;https://c2sp.org/chunked-encryption&gt;`_ for streaming
authenticated
  encryption of large messages.
* Parsing a Signed Certificate Timestamp list now rejects encodings that
carry trailing bytes after the list or after an individual SCT, instead
of
  silently ignoring them.
* Added support for using :class:`~cryptography.x509.Name` as a field
type in
  the :doc:`/hazmat/asn1/index` module.
* Loading a public key or an EC private key now rejects DER where the
``subjectPublicKey`` (or EC ``publicKey``) ``BIT STRING`` declares a
non-zero
  number of unused bits, instead of silently ignoring it.
* Parsing a CRL entry's ``InvalidityDate`` extension now rejects a
``GeneralizedTime`` that carries fractional seconds or another non-DER
form,
matching the strict encoding already required for every other X.509 time
  field.
* :func:`~cryptography.x509.ocsp.load_der_ocsp_request` and
:func:`~cryptography.x509.ocsp.load_der_ocsp_response` now reject a
request
or response whose ``version`` field is not ``v1``, the only version
defined
by RFC 6960, matching the version validation already performed when
loading
  certificates, CSRs and CRLs.
* :class:`~cryptography.hazmat.primitives.hashes.XOFHash` is now
supported
  when building against AWS-LC.
* HMAC (and therefore PBKDF2-HMAC) with SHA-3 hashes is now supported
when
  building against AWS-LC.
* Diffie-Hellman (:doc:`/hazmat/primitives/asymmetric/dh`) is now
supported
  when building against AWS-LC.
&lt;/tr&gt;&lt;/table&gt; 
</code></pre>
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/pyca/cryptography/commit/dcb7050b807b00392fa9fe2eac7cb362fcf355cc"><code>dcb7050</code></a>
Prepare for 50.0.0 release (<a
href="https://redirect.github.com/pyca/cryptography/issues/15372">#15372</a>)</li>
<li><a
href="https://github.com/pyca/cryptography/commit/53fccd93413a8d7f07d6d8999681f27b75cffa3f"><code>53fccd9</code></a>
Don't leak how PKCS#7 encryptedKey decryption failed (<a
href="https://redirect.github.com/pyca/cryptography/issues/15369">#15369</a>)</li>
<li><a
href="https://github.com/pyca/cryptography/commit/d472f978470fbefa521b86d98b2ecccbbb4d1dd8"><code>d472f97</code></a>
Add <code>from __future__ import annotations</code> to all src/ Python
files (<a
href="https://redirect.github.com/pyca/cryptography/issues/15371">#15371</a>)</li>
<li><a
href="https://github.com/pyca/cryptography/commit/908773d53829fb1466c6db364b31321c3cd8eb9a"><code>908773d</code></a>
Bump downstream dependencies in CI (<a
href="https://redirect.github.com/pyca/cryptography/issues/15368">#15368</a>)</li>
<li><a
href="https://github.com/pyca/cryptography/commit/2cc07cc948948211899bcb0cddd1fddf86e95812"><code>2cc07cc</code></a>
Bump BoringSSL, OpenSSL, AWS-LC in CI (<a
href="https://redirect.github.com/pyca/cryptography/issues/15367">#15367</a>)</li>
<li><a
href="https://github.com/pyca/cryptography/commit/c94ede9f040fa44942f7139772603419000acf66"><code>c94ede9</code></a>
chore(deps): bump ruff from 0.16.0 to 0.16.1 (<a
href="https://redirect.github.com/pyca/cryptography/issues/15366">#15366</a>)</li>
<li><a
href="https://github.com/pyca/cryptography/commit/67a8308dc9ea4cce6056e0f1438f903c208c3f35"><code>67a8308</code></a>
chore(deps): bump virtualenv from 21.7.0 to 21.7.1 (<a
href="https://redirect.github.com/pyca/cryptography/issues/15365">#15365</a>)</li>
<li><a
href="https://github.com/pyca/cryptography/commit/95018ffcdbbc510fd92fc872e3a3e80aa6e58596"><code>95018ff</code></a>
Release the GIL in one-shot AEAD encrypt/decrypt (<a
href="https://redirect.github.com/pyca/cryptography/issues/15361">#15361</a>)</li>
<li><a
href="https://github.com/pyca/cryptography/commit/6954733eaf55a0074abf88f06f7242dfca3a5d02"><code>6954733</code></a>
Release the GIL during DH and DSA parameter generation (<a
href="https://redirect.github.com/pyca/cryptography/issues/15364">#15364</a>)</li>
<li><a
href="https://github.com/pyca/cryptography/commit/6893b94c33e948f6240082461424cfb5da2dacc6"><code>6893b94</code></a>
Import _serialization instead of serialization in x509/extensions (<a
href="https://redirect.github.com/pyca/cryptography/issues/15363">#15363</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/pyca/cryptography/compare/46.0.7...50.0.0">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=cryptography&package-manager=uv&previous-version=46.0.7&new-version=50.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)
You can disable automated security fix PRs for this repo from the
[Security Alerts
page](https://github.com/calibrain/shelfmark/network/alerts).

</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-23 01:28:06 -06:00
splitsec2 86fff32297 feat(hardcover): owned books, simpler queries, dead sources and failed releases (#1535)
A request whose book turns out to be in the library is closed with the note `[auto] Already in the library.` instead of sitting in Needs Review. A request an admin handled by hand while a pass was running is skipped. A series label in front of a Calibre title no longer hides an owned book, and a Calibre title marked as another edition (full cast, dramatized, GraphicAudio) is not the book.

When no source matches, up to three simpler forms of the title are tried on each source under the same strict match. Optionally (off by default), an unmatched audiobook request is looked up on Audible US and UK through Audiobookshelf's metadata search and the request says what the store answered. It never declines anything.

A Soulseek peer whose recent transfers all failed, and an AudiobookBay post whose comments say it has no seeders, are tried after the other matches and never excluded. A failed or stalled release is kept out for AUTO_DOWNLOAD_RETRY_FAILED_RELEASE_DAYS and then tried again, and a torrent that died for one request is not offered to another. A request stuck on queued or downloading is settled from the download history. Optionally (off by default) a failed download is retried with another release within minutes.
2026-10-23 01:08:00 -06:00
splitsec2 9d5b47d17b feat(hardcover): retries, error counting and a back-off for requests nothing matches (#1534)
A synced request whose download failed, was interrupted by a restart, or was cancelled by the stall timer goes back to pending after AUTO_DOWNLOAD_RETRY_DAYS (7 by default, 0 turns it off), a few per pass, oldest first. Only what never arrived is retried, the failed release is remembered, and a request is given up on after three failed retries. Downloads cancelled by a person and rejected requests are never retried.

Errors count as errors: a search that failed in one source no longer reads as no match, a throttled Hardcover lookup is an error and not a skip, and a request that blows up doesn't abort the pass. Both summaries carry the error count.

A request for the same Hardcover book and format is skipped when the history already has a finished or running download for it. A request that finds nothing is searched once a day after three no-match passes, then every two days, up to AUTO_DOWNLOAD_BACKOFF_MAX_DAYS (3 by default, 0 turns it off).
2026-10-23 00:58:42 -06:00
splitsec2 4389548deb feat(hardcover): ebooks, audiobooks or both, and stricter matching for automatic downloads (#1533)
A shelf book can be requested as an ebook, an audiobook or both (one request per format), and each request is checked against its own library, source priority list and format guard. Ranking within a source prefers the most downloaded copy, then size, and one m4b is preferred to a set of chapter files unless it is much smaller than the other copies. Soulseek peers are ranked by free slot, queue length and speed.

Automatic downloads refuse multi-book packs, sequels and other works named in the title, study guides, abridged or dramatised versions, two-in-one titles, releases in a language the requester didn't ask for, and an archive for an audiobook request.

Matching folds accents on both sides, drops an alternate title for the search and the match, and reads small numbers the same in any form. AudiobookBay releases with no author in the listing title are judged on the authors credited on the detail page.
2026-10-23 00:41:13 -06:00
splitsec2 ea69db3f8e feat(library): Audiobookshelf library check, and the sync fails closed when a library cannot be read (#1519)
Adds an Audiobookshelf provider for the library check, next to the Calibre one. It reads the server over its REST API (title, author, ISBN and ASIN per item), answers for audiobooks, and has its settings in General with a test button that uses the unsaved form values. Read only.

The Hardcover sync and automatic downloads now fail closed when an enabled library cannot be read: the sync skips its cycle and an automatic download holds the request. A library that listed items at the last index and now lists none counts as a failed read. Badges still fail open.
2026-10-23 00:21:40 -06:00
ProcyonX1 99a6aa206b feat(metadata): add Deutsche Nationalbibliothek (DNB) metadata provider (#1547)
## feat(metadata): add Deutsche Nationalbibliothek (DNB) metadata
provider

This adds the Deutsche Nationalbibliothek (DNB) as a metadata provider.
It covers German editions far better than the existing providers.
No API key, disabled by default, and the same pattern as the other
providers.

It uses DNB's public SRU interface and parses MARC21-XML. Thats one
reason why this needs more code than the Google Books or Open Library
providers. The others are the search (DNB has no relevance ranking) and
the language filter, see below.

### Notes
- **Search box.** Search-box text goes from precise to loose until a
stage has hits: the complete title, then the title as a phrase, then
every word in a title or a person's name, last without German articles.
- **"Only show editions in my book languages"** (optional, on by
default). DNB also catalogs a lot of translations. DNB filters by the
searching user's Default Book Languages, else the system default.
- **No covers.** DNB's cover service is behind Anubis bot protection, so
the provider returns no cover URLs.
- **Proxy support** already included like Open Library since #1526


### Changes outside the provider
- DNB is in the onboarding dropdown.
- Calibre's `dnb-idn` identifier is mapped, so DNB results show library
ownership
- In the env-var docs I added only the DNB section. Regenerating the
whole file on main currently also reorders unrelated sections and drops
`SHELFMARK_API_KEY_READONLY` (and the Databáze knih section sits inside
the Moly.hu block).

### Ideas
- **Deduplication.** DNB lists every edition. Kafka's "Die Verwandlung"
has around 200 German editions of the same text. Grouping them would
touch the API, the frontend and release search.
- **Language-aware metadata search** for all providers. The other
providers ignore Default Book Languages in metadata search. The
`user_id` added here could serve them too.
2026-10-22 20:37:15 -04:00
2007384058 feat(sources): add Webshare.cz as a release source (#1543)
## What

Adds [Webshare.cz](https://webshare.cz), the Czech file host, as a
release source for ebooks and audiobooks. It follows the Libgen source:
disabled by default, with a settings tab for credentials and a **Test
Login** button that reports whether the account is VIP.

## Why

Webshare is one of the main places Czech and Slovak ebooks and
audiobooks are shared, and none of the existing sources reach it.
Together with the Databáze knih metadata provider (#1479), this lets
Universal mode find Czech and Slovak titles end to end.

## How

- **API client** (`webshare/api.py`): Webshare's XML API (`POST
/api/<endpoint>/`). Login is the salted exchange the API documents:
fetch the salt, send `SHA1(MD5_CRYPT(password, salt))` plus an MD5
digest. Python dropped the `crypt` module in 3.13, so md5-crypt is a
small pure-Python port, checked against `openssl passwd -1`. The session
token is cached per account and refreshed once when an authenticated
call fails. File-level errors (`FILE_LINK_FATAL_*`) are not retried. XML
is parsed with `defusedxml`.
- **Search** (`webshare/source.py`): Webshare matches file names only,
so the file name becomes the release title and its extension the format.
Queries are title + author, then title only, or the manual query.
Results are filtered to the configured `SUPPORTED_FORMATS` /
`SUPPORTED_AUDIOBOOK_FORMATS`, plus `zip`/`rar` for audiobooks, since
they are usually uploaded as archives. Password-protected files are
skipped. The language is guessed from tags in the name (`CZ`, `SK`,
`ENG`, ...), and the vote balance is a sortable column. API failures
raise `SourceUnavailableError`.
- **Download** (`webshare/handler.py`): resolves a time-limited link per
attempt with `file_link`, then streams it into `TMP_DIR` with progress
and cancel support. Large audiobook archives are not buffered in memory.
- **Settings**: `WEBSHARE_ENABLED`, `WEBSHARE_USERNAME`,
`WEBSHARE_PASSWORD` (a `PasswordField`, already covered by the
`_PASSWORD` redaction in `genDebug.sh`), and `WEBSHARE_MAX_RESULTS`. The
`docs/environment-variables.md` section was added.

## Testing

- `tests/webshare/`: 31 offline tests. They cover md5-crypt vectors,
password hashing, XML parsing, token reuse and re-login, non-retried
file errors, availability gating, format filtering, query fallback,
release mapping, language guessing, and the handler's streaming,
small-file rejection, link errors and cancel.
- `ruff check` and `ruff format --check` are clean. The rest of the unit
suite fails the same way with and without this change: Windows-only
tests on my machine.
- Verified live against webshare.cz: search parsing and the salt
endpoint's error path.
- Still a draft: I haven't yet verified login and download with a real
account.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: CaliBrain <calibrain@l4n.xyz>
2026-10-22 20:30:01 -04:00
48ae3dbef4 feat: optional Hardcover wishlist sync with automated downloading (1 of 5) (#1516)
This replaces #1047, which @InfiniteAvenger opened and calibrain offered
to re-review. It is Caleb's feature commit with his authorship kept,
rebased onto current main, with small fixes, tests and a docs page on
top. Everything is opt-in and off by default, so nothing changes for
anyone who doesn't turn it on.

What it adds is a **Settings, Hardcover Sync** tab with three parts that
can be switched on separately:

- **Shelf sync**: a Hardcover shelf (Want to Read by default) is pulled
into requests on a schedule, or with a Sync now button. Requests carry
the Hardcover metadata and cover.
- **Automatic downloads**: synced requests are approved and a release is
picked from a source priority list. A strict title, author and format
match (plus a seeder minimum for torrents) decides, so anything without
a confident match stays pending for manual review.
- **Skip books you already own**: sync and automatic downloads ask
whichever libraries are enabled under Library Check (Calibre today) and
skip a book that is already there.

On top of Caleb's commit I made the request owner a lookup instead of
assuming user id 1, sent synced cover URLs through the proxy when the
request is created, got ruff and basedpyright clean, and added tests and
`docs/hardcover-sync.md`. His commit also had its own Audiobookshelf
settings and a Test library connection button, written before the
library check was split into providers. The button would have raised a
TypeError against the current `test_connection`, so those settings are
gone from this tab and an Audiobookshelf provider comes as a separate
PR, which is the part of #1047 that is missing here (for now
anyways...).

I've run this daily for about a couple of weeks. Ruff, basedpyright and
the backend suite pass (4426 passed).

This is 1 of 5. As calibrain suggested, the rest are stacked on this
one, each saying which number it is.

Closes
https://github.com/calibrain/shelfmark/pull/1047#issuecomment-6302071588

---------

Co-authored-by: InfiniteAvenger <calebewest02@gmail.com>
Co-authored-by: CaliBrain <calibrain@l4n.xyz>
2026-10-22 20:27:47 -04:00
splitsec2andCaliBrain 72b5d04939 perf: cut repeated work on the status and activity paths (#1545)
Two small cuts to work that repeats on every poll or request.

The delivery-state sync behind /api/status and the activity snapshot
listed every fulfilled request (SELECT * plus a JSON parse of book_data
and release_data per row) before it looked at the queue. That history is
never purged, so an idle instance paid for it on every poll. It now
returns straight away when no queue bucket holds a task, since nothing
could map to a request then. Results and emitted updates are unchanged.
The activity snapshot gains less from this, because its status merges
download history rows, so it is usually not empty.

In proxy auth mode the before-request hook looked the user up by name
and by session id on every API call, cover images included, each on a
new SQLite connection. UserDB.get_user_cached answers repeat lookups
from memory for up to five seconds, bounded at 256 entries. create_user,
update_user and delete_user drop the cache as soon as they commit, and a
lookup that overlaps a write is not stored, so a role change or deletion
made in the app applies on the next request. Changes made outside that
UserDB instance (another process, or the separate instances some helpers
open) show within the five seconds. Only the middleware uses the cached
path; every other get_user caller is unchanged.

Each change has a test that failed before it: one counts list_requests
calls for an empty queue, the others count get_user calls across
repeated lookups and middleware runs, check expiry with a patched clock,
and check that writes and caller mutations do not leave a stale row
behind. The full suite passes (4432 passed, 48 skipped, e2e excluded),
as do ruff and basedpyright on the changed files.

I also looked at throttling the full status_update broadcast when only
the progress message changes during torrent and usenet polling, and left
it out. Those handlers poll every 2 seconds and
DOWNLOAD_PROGRESS_UPDATE_INTERVAL defaults to 1 second, so a throttle on
that interval would skip nothing at the default setting. A longer window
would slow the speed and ETA text in the UI, and without a trailing
flush it would hide the last message of a quick burst, such as "Waiting
for bot response" arriving just after "Requesting file from bot", for
the whole wait that follows.

---------

Co-authored-by: CaliBrain <calibrain@l4n.xyz>
2026-10-22 20:27:07 -04:00
splitsec2andCaliBrain 237ddd53a2 fix(redaction): a non-admin's status message loses the links it quotes (#1541)
#1422 hid `source_id` and the download path from non-admins but left the
status message alone. A failed add stores the client's exception text
there, for example `401 Client Error: Unauthorized for url: <full url>`,
and for a requests error that URL carries the path and query: an indexer
key, a tracker passkey, a peer name. It reached the owner through
`/api/status`, `/api/activity` and the `status_update` socket event.

`redact_download_payload` now replaces every link in `status_message`
with `(link removed)`. That covers the live queue, the socket push and
the history snapshot, since all three go through it. Messages without a
link are unchanged and admins still see the full text.

Tests cover the payload, a whole status snapshot, and messages that must
stay as they are. They fail on main and pass here.

Found in a Claude pass over 1.5.0.

---------

Co-authored-by: CaliBrain <calibrain@l4n.xyz>
2026-10-22 20:26:47 -04:00
splitsec2 a430341a93 perf: cut repeated work on search and cover paths (#1544)
I went through what Shelfmark does per search and per cover and measured
the pieces that showed up. Four of them are cheap to fix, so this is one
PR. Shelfmark runs on a single gevent worker, so any of these blocks
every other request while it runs, and I'm assuming a lot of people run
it on small hardware (a Pi, say).

Cover cache. Every cache hit rewrote the whole `cache_index.json` just
to bump the entry's last-access time. On my box that is 3.7 ms of CPU
per hit at 500 entries and about 100 ms at 15,000 (2.2 MB written each
time), and a results page asks for around 40 covers. The access time now
stays in memory and the index is written at most every 30 seconds, or
whenever something else saves it. A crash inside that window loses some
access times, which only changes eviction order.

Settings reload. `config.refresh()` rebuilt the cache by reading and
parsing a tab's JSON file once per field, so about 250 parses per
refresh, 100 ms of CPU, and the metadata search, providers and config
routes each trigger it. It now reads each file once per rebuild (several
tabs share `settings.json`, so the memo is keyed on the file path).

Library match. When the library check is on, each search result is
compared against every library entry, and the title was split into words
again for every entry. The words are now computed once per result. I
measured 33 ms per result at 5,000 library entries and 106 ms at 20,000,
which is a second or more per page of 40 results. The matching itself is
unchanged.

Cover URL probe. `is_covers_cache_enabled()` creates and deletes a file
in the config directory to check it is writable, and it runs for every
cover, queue task and history row. The answer is remembered for 60
seconds. The probe is 0.045 ms on its own, but it is called a few
hundred times per request in the worst case.

Each change has a test that fails without it (call counts on the index
saves, file opens during a refresh, tokenisation calls, and probe
calls). Ruff, basedpyright on the changed files and the non-e2e suite
pass. The timings are from a dev machine with an empty config, so the
real numbers depend on cache and library size.
2026-10-22 20:24:57 -04:00
splitsec2 2668705bfa perf: reuse connections and cache Anna's Archive download counts (#1546)
Two small changes that stop Shelfmark from opening a new HTTPS
connection for requests that go to the same host over and over.

Anna's Archive download counts. Every search result gets its count from
`/dyn/md5/inline_info/<md5>`, and each of those was a bare
`requests.get`, so a search with 50 results opened 50 TCP and TLS
connections to the mirror. `search_books` also runs once per title
variant, and the variants of one book mostly find the same files, so the
same md5 was looked up again for every variant that found it, and again
when the release list was reopened. The lookups now share one
`requests.Session`, and a successful count is kept for five minutes in a
bounded `CacheService` (the same class the metadata cache uses, as its
own instance, so it does not depend on the metadata cache toggle). A
lookup that fails or comes back without a count is not cached, so it is
retried on the next search just like today. The URL, headers and 5
second timeout are unchanged.

Debrid clients. Real-Debrid, AllDebrid, Debrid-Link and TorBox also used
the bare `requests` helpers. The download handler polls `get_status`
every two seconds for each active job, so every poll was a fresh TLS
handshake with the debrid API. Each of those modules now keeps one
`requests.Session` and sends its API calls through it, the way the
qBittorrent, Deluge, Prowlarr and Newznab clients already do. Headers,
timeouts, `verify` and error handling are still passed per call exactly
as before. The existing tests that patched
`<module>.requests.get/post/request` now patch
`<module>._session.<verb>` instead; nothing else in them changed.

How I checked it: each change has a test that fails on main and passes
here. They count the requests that reach `requests.Session.request` and
the sessions built while doing it, with no network. On main, three polls
of each debrid client build three sessions, and two overlapping batches
of AA results look `bbb` up twice. There are also tests for the count
cache expiring (with a faked clock) and for a failed lookup not being
cached. Full suite: 4432 passed, 48 skipped. ruff and basedpyright are
clean on the changed files.

I also looked at caching `/api/releases` on the server and left it out.
The release modal already caches each book's results per source and
content type in the browser for five minutes (longer for IRC and slskd
through `cache_ttl_seconds`), and that cache survives closing and
reopening the modal, so reopening it does not run a new search today. A
server cache would only help after a page reload, and it would quietly
undo the modal's "apply filters" and manual search refetches, which drop
the browser cache on purpose, unless the frontend also learned to send a
bypass. That did not seem worth it for this PR.
2026-10-22 20:24:40 -04:00
splitsec2 08fdb7ea87 fix(startup): the reconcile guard catches the same errors the rest of main.py does (#1540)
The start-up reconcile runs at import, before the coordinator starts,
inside a try that named five exception types and left out `TypeError`.
`main.py` already defines `_IMPORT_OPERATIONAL_ERRORS` for this shape
and the other module-level guards use it, so this switches to it. An
unexpected `TypeError` from the reconcile would otherwise fail the
import of `shelfmark.main`, and the worker would fail to boot on every
restart.

I didn't use `except Exception` because BLE001 is on and nothing in
main.py does.

The test reloads main with the reconcile raising `TypeError`, and with
`OSError` as the control (that one already passes on main). Full non-e2e
suite passes, ruff and basedpyright are clean.

Found in a Claude pass over 1.5.0.
2026-10-22 20:22:21 -04:00
splitsec2 f560b31ee4 fix(db): wait up to 30 s for a busy writer instead of SQLite's 5 s default (#1539)
The users, download history and activity view-state databases open their
connections with SQLite's default busy timeout of 5 seconds. A start-up
migration, the restart reconcile, or any statement that overlaps a
backup or a second process raised "database is locked" after 5 seconds,
and the reconcile finished only part of its work.

One shared `DB_BUSY_TIMEOUT_SECONDS` (30) in `core/utils.py`, used by
all three. The test reads SQLite's `busy_timeout` pragma back from each
connection, so it doesn't need a real lock fight.

I left `UserDB.initialize()` without a guard on purpose. With 30 seconds
the wait covers a backup, and swallowing a schema failure there would
hide a worker that can't run.

Found in a Claude pass over 1.5.0.
2026-10-22 20:21:49 -04:00
splitsec2 2d9491e063 docs(settings): say that the per-source limit replaces Max Concurrent Downloads (#1542)
With Max Concurrent Downloads Per Source above 0 the download pool is
sized per source and a task is admitted when its own source is below the
limit. Max Concurrent Downloads isn't read at all in that mode, so 3 per
source across 3 busy sources runs 9 at once. Neither description said
so.

The per-source text now says it replaces the global limit and what the
total can be, and the global one says it isn't used while the per-source
limit is set. Text only, no behaviour change.

I changed the two rows in `docs/environment-variables.md` by hand.
Running `scripts/generate_env_docs.py` on main today also drops the
`SHELFMARK_API_KEY_READONLY` entries and changes the slskd anchor, so
the file looks hand-edited since it was last generated, and I didn't
want to fold that into this. The two rows match what the generator
produces for them.

Found in a Claude pass over 1.5.0.
2026-10-22 20:20:56 -04:00
splitsec2 e27fed1d40 fix(queue): a late progress update no longer reopens a finished task (#1538)
#1429 made the first terminal status win, but the guard only refused
terminal to terminal changes. A worker's poll that reports DOWNLOADING
after a cancel still overwrote CANCELLED (the poll loop calls the status
callback before it waits on the cancel flag), and the COMPLETE or ERROR
that followed was then accepted as a first terminal status, so the
terminal hook ran a second time.

This refuses any status change away from a terminal status. `add()` and
`enqueue_existing()` reset the status themselves, so retries are
unaffected.

The new test cancels a downloading task, sends DOWNLOADING and then
COMPLETE, and expects CANCELLED and one hook call. It fails on main and
passes here. Full non-e2e suite passes, ruff and basedpyright are clean.

Found in a Claude pass over 1.5.0, same as my other small fixes from it.
2026-10-22 20:19:16 -04:00
splitsec2 395c331b4a fix(search): give each provider its own slice of the search budget (#1537)
Fixes #1531.

All Direct Download providers share the one search budget
(`RELEASE_SEARCH_TIMEOUT`, 300s). Anna's Archive runs first, and when
its bypass keeps failing the retries use up the whole budget. OceanofPDF
then sees an expired deadline, skips its queries, and the search raises
Anna's Archive's error even though OceanofPDF never got a turn.

I didn't split the budget evenly, since the deadline module itself says
a cold-start Anna's Archive solve can legitimately take 60 to 120s and a
150s or 37s slice would break setups that work today. Instead
`search_deadline.unit_deadline(waiting=n)` opens a child deadline for
one unit of work. It gets the parent's remaining time minus 30s for each
unit still waiting, never less than 30s and never more than the parent
has left. A single provider still gets everything, a fast provider's
unused time rolls on to the next, and a stuck one stops at its own limit
with the reserve intact.

It's used in the provider loop in `DirectDownloadSource.search`, and in
the source loop in `/api/releases` since it's the same shape and four
lines. With several sources enabled the first one now gets 30s less per
source behind it, which is still well above a cold solve for the usual
two or three.

Tests: a stuck first provider no longer starves the second (fails on
main, passes here), a lone provider gets the full budget, and a child
never outlives its parent. Two existing tests in
`test_releases_api_budget.py` tripped the deadline in force from inside
the source, which is now the child, so they use a short request budget
instead. ruff, basedpyright on the changed files and the non-e2e suite
are clean.

OceanofPDF is inactive on my own instance (no mirror URL set), so I
haven't reproduced this live. It's verified by reading the code and by
the tests. I had Claude do the pass on this one.
2026-10-22 20:17:44 -04:00
splitsec2 8e425da527 refactor: one default and one reader for the Supported Book Formats list (#1532)
The default for Supported Book Formats (`epub, mobi, azw3, fb2, djvu,
cbz, cbr`) was written out in five places: the settings registry,
`postprocess/policy.py`, `irc/parser.py`, Prowlarr's `source.py` (added
in #1490) and slskd's `source.py` (added in #1428). Both of those two
arrived in 1.5.0, and both are mine, so this is cleaning up after myself
as much as anything.

The setting was also read three different ways. Post-processing split on
commas only. slskd split on commas and spaces and stripped a leading
dot. IRC split on commas and kept the dot. Prowlarr split on commas and
kept `""` as a format when the value was an empty string. So `.epub` or
`epub mobi` worked in one source and not another.

This puts `DEFAULT_EBOOK_FORMATS` and `normalize_format_list` in
`core/utils.py` next to `AUDIOBOOK_FORMATS`, and the registry,
post-processing, IRC, Prowlarr and slskd all use them. I took the most
lenient reading of the three (comma or space split, leading dot dropped,
empties ignored, default when nothing is left), so no existing config
gets stricter. I put the reader in `core/utils.py` and not in
`policy.py` because the release sources can't import `policy` without
pulling in the whole post-processing package.

A config with a sensible list reads exactly as before. The only
differences are for malformed values: an emptied setting or an empty
string now falls back to the default in every place, where IRC used to
filter everything out and Prowlarr treated `""` as a format.

The audiobook default is a separate story and I left it alone: the
registry and slskd default to every processable audiobook format plus
the archives, while post-processing and IRC fall back to `m4b, mp3`.
They only differ when the setting is unset or emptied, but they do
differ, and I'd rather ask which one you want than pick one here.

Ruff, basedpyright on the changed files and the backend suite pass (4400
passed, e2e not run). New tests cover the reader and the two fallbacks.
2026-10-22 15:32:00 -04:00
Amalkrishna A SandCaliBrain a11a63a29e feat(notifications): add {User}/{Email} placeholders to per-user routes (#1499)
Closes #1130.

Per-user notification route URLs can now use `{Email}` and `{User}`,
filled in from each user's account at send time, the same way `{User}`
already works in destination paths. One default route like
`mailtos://user:pass@smtp.example.com?to={Email}` now reaches every user
at their own address, with no per-user setup.

- Values are URL-encoded (`alice+books@example.com` keeps its `+`);
placeholders are case-insensitive.
- A route that needs a value the user doesn't have (e.g. no email on
file) is skipped for that user; other routes still send.
- The user lookup only runs when a route contains a placeholder.
- The personal "Test notification" button expands the placeholders too,
so testing a `{Email}` route works.
- Added to the routes setting help, `docs/users-and-requests.md`, and
tests.

@calibrain ready for review.

---------

Co-authored-by: CaliBrain <calibrain@l4n.xyz>
2026-10-22 15:29:34 -04:00
dependabot[bot] 182e80f894 build(deps): bump publicsuffixlist from 1.1.0.20261017 to 1.1.0.20261018 (#1527)
Bumps [publicsuffixlist](https://github.com/ko-zu/psl) from
1.1.0.20261017 to 1.1.0.20261018.
<details>
<summary>Commits</summary>
<ul>
<li>See full diff in <a
href="https://github.com/ko-zu/psl/commits">compare view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=publicsuffixlist&package-manager=uv&previous-version=1.1.0.20261017&new-version=1.1.0.20261018)](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-10-22 03:21:05 -04:00
CaliBrain c5af6b9ae6 ci(jules): review the whole PR diff, not just the latest push (#1529)
jules-pr-reviewer v0.0.2 reviewed only before...head on synchronize.
After
"Update branch" merged main into fork PRs, that range was main's
commits, so
of their own changes. v0.0.3 of the calibrain fork always reviews the
PR's
full diff. Also correct the fork workflow comment: jules-forks has no
required reviewers by design, so runs don't wait for approval.
2026-10-22 03:20:45 -04:00
c43da4537d fix(network): apply HTTP proxy to Open Library and Prowlarr requests (#1526)
Fixes #1524

Open Library metadata searches and Prowlarr API calls used
`get_ssl_verify` but never `get_proxies`, so the HTTP proxy set in
Settings → Network was not applied to their HTTPS requests. Both now
pass the configured proxies. Relying on `requests`' `trust_env` with
only `HTTP_PROXY` set leaves the same gap for `https://` destinations.

Verification (Linux, `uv run --frozen pytest`):
- New tests `tests/metadata/test_openlibrary_proxy.py` and
`tests/prowlarr/test_api_proxy.py`: 4 passed. With the source change
reverted they fail (2 failed, 2 errors).
- `tests/metadata` + `tests/prowlarr`: 949 passed, 42 skipped.
- The full suite in my environment showed 61 failures and 26 errors
outside these areas. I did not compare them against `main`, so I'm not
claiming the full suite is green here; CI will show it.
- `ruff check` and `ruff format --check` on the changed files pass.

Prepared with AI assistance; reviewed and tested by me before
submitting.

Co-authored-by: dbdefault <246079841+dbdefault@users.noreply.github.com>
Co-authored-by: CaliBrain <calibrain@l4n.xyz>
2026-10-22 02:47:43 -04:00
CaliBrain 9d5bcf0c58 ci(jules): review fork PRs through a gated pull_request_target workflow (#1528)
Fork PRs get no secrets under pull_request and Dependabot runs get only
Dependabot secrets, so JULES_API_KEY was empty and every such run failed
at
config. The same-repo workflow now skips them, and a new
pull_request_target
workflow reviews fork PRs behind maintainer approval on the jules-forks
environment, without checking out PR code. Both use calibrain's fork of
jules-pr-reviewer, since upstream refuses pull_request_target.
2026-10-22 02:40:50 -04:00
CaliBrain d887942630 fix(search): name the proxy when a gateway cuts a search off (#1522)
A reverse proxy whose read timeout is shorter than the release search
budget
(nginx defaults to 60s) answers a slow search with its own 504 page.
describeSearchFailure found no server message there and fell back to
"Unable to reach download source. Network may be restricted or mirrors
blocked.", which sent users off changing mirrors (discussion #1520).

- 504 and Cloudflare's 524 now say the proxy stopped waiting and that
its
  read timeout should be at least the release search timeout.
- A 502/503 with a non-JSON body keeps fetchJSON's "Server unavailable"
  wording instead of the mirrors line.
- docs: the root-path nginx example sets proxy_read_timeout and
  proxy_send_timeout like the subpath ones, and a new troubleshooting
  section covers searches that time out behind a proxy.
2026-10-22 01:40:00 -04:00
eab8f7bc9a Add Advanced Jules PR Reviewer action (#1523)
This PR adds the Advanced Jules PR Reviewer workflow to automatically
review PRs. It is configured to run on forks automatically as requested
by setting `skip_forks: false`. JULES_API_KEY and GITHUB_TOKEN are
mapped correctly as per documentation.

---
*PR created automatically by Jules for task
[10601196597817548410](https://jules.google.com/task/10601196597817548410)
started by @calibrain*

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
Co-authored-by: calibrain <191882101+calibrain@users.noreply.github.com>
2026-10-21 14:25:27 -04:00
303 changed files with 36477 additions and 5229 deletions

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
name: Shelfmark CodeQL configuration
# Test code only runs in CI and the e2e docker stack (mock servers, URL asserts), so its
# findings are noise. Skipping it drops those alerts without hiding anything in the app.
paths-ignore:
- tests
@@ -34,7 +34,7 @@ jobs:
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Log in to the Container registry
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
@@ -77,7 +77,7 @@ jobs:
labels: ${{ steps.meta.outputs.labels }}
annotations: ${{ steps.meta.outputs.annotations }}
# Runners are ephemeral, so without this every layer is rebuilt on every
# run: the pinned Chromium install, the dependency syncs, the driver
# run: the Chromium install, the dependency syncs, the driver
# setup. Scoped per target so the two images do not evict each other. A cache
# export error (ignore-error defaults to false) should not fail a finished publish.
cache-from: type=gha,scope=${{ matrix.target }}
@@ -104,7 +104,7 @@ jobs:
LEGACY_NAME: calibre-web-automated-book-downloader
steps:
- name: Log in to registry
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
+3 -3
View File
@@ -16,7 +16,7 @@ jobs:
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Install uv and Python
uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3
with:
version: "0.11.3"
python-version: "3.14"
@@ -42,7 +42,7 @@ jobs:
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Install uv and Python
uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3
with:
version: "0.11.3"
python-version: "3.14"
@@ -62,7 +62,7 @@ jobs:
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Install uv and Python
uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3
with:
version: "0.11.3"
python-version: "3.14"
+4 -3
View File
@@ -25,14 +25,15 @@ jobs:
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Initialize CodeQL
uses: github/codeql-action/init@24c54180a607b1449ed407dd24f251e4e9147c8d # v3
uses: github/codeql-action/init@1fc58d04a4d4374f921bdac573c3811c240f9c26 # v3
with:
languages: ${{ matrix.language }}
config-file: ./.github/codeql/codeql-config.yml
- name: Autobuild
uses: github/codeql-action/autobuild@24c54180a607b1449ed407dd24f251e4e9147c8d # v3
uses: github/codeql-action/autobuild@1fc58d04a4d4374f921bdac573c3811c240f9c26 # v3
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@24c54180a607b1449ed407dd24f251e4e9147c8d # v3
uses: github/codeql-action/analyze@1fc58d04a4d4374f921bdac573c3811c240f9c26 # v3
with:
category: "/language:${{ matrix.language }}"
+2 -2
View File
@@ -72,7 +72,7 @@ jobs:
- name: Checkout
uses: actions/checkout@v7
- name: Install uv and Python
uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3
with:
python-version: "3.14"
enable-cache: true
@@ -97,7 +97,7 @@ jobs:
- name: Checkout
uses: actions/checkout@v7
- name: Install uv and Python
uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3
with:
python-version: "3.14"
enable-cache: true
+11 -29
View File
@@ -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
# 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.
FROM ghcr.io/astral-sh/uv:0.12.24@sha256:3af4716e991d6956a41e573eab705d0ee08500cd829ed30293eb8472f372c65a AS uv
FROM ghcr.io/astral-sh/uv:0.13.7@sha256:96f40d3904aad8d0bd010539001d36c8e2188b981e1334471a90c4a1d93bb490 AS uv
# Use python-slim as the base image
FROM python:3.14.8-slim@sha256:a2b82f3c48559aa0a8446d9af49826b6e2b2016f4cd2afabfe6013ec53729170 AS base
FROM python:3.14.9-slim@sha256:35caba83af2fe6da9a5d922f9b0156f21d1246a48278c58a08bd253303ba42e8 AS base
# Set shell to bash with pipefail option
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
@@ -148,39 +148,16 @@ ENTRYPOINT ["/usr/bin/dumb-init", "--"]
FROM base AS shelfmark
# --- Chromium (PINNED to 155.0.8059.79) ---
# Debian's chromium 150.0.7871.46-1~deb13u1 security update (trixie-security,
# 2026-07-05) stopped opening the DevTools remote-debugging TCP port that the
# SeleniumBase Pure-CDP driver connects through (/json/version), so every
# internal bypass died with "Pure CDP browser startup failed" and 149.0.7827.196
# was pinned (#1105). Later Debian builds open the port again: 155.0.8059.79
# starts Pure CDP with the bypasser's own arguments. Still installed from
# snapshot.debian.org, so the build stays reproducible once trixie-security
# moves past this version. Bump CHROMIUM_VERSION and CHROMIUM_SNAPSHOT together,
# and check that the bypasser still starts before relaxing the pin.
# Chrome 144+ requires --enable-unsafe-swiftshader for WebGL in Docker.
# This flag is set in internal_bypasser.py _get_browser_args()
ARG CHROMIUM_VERSION=155.0.8059.79-1~deb13u1
ARG CHROMIUM_SNAPSHOT=20261016T000000Z
RUN echo "deb [check-valid-until=no] https://snapshot.debian.org/archive/debian-security/${CHROMIUM_SNAPSHOT}/ trixie-security main" \
> /etc/apt/sources.list.d/chromium-pin-snapshot.list && \
apt-get update -o Acquire::Retries=5 && \
# --- Chromium (Debian's current build from trixie-security) ---
RUN apt-get update -o Acquire::Retries=5 && \
apt-get install -y --no-install-recommends -o Acquire::Retries=5 \
# For dumb display
xvfb \
# For screen recording
ffmpeg \
chromium=${CHROMIUM_VERSION} \
chromium-common=${CHROMIUM_VERSION} \
chromium \
# For tkinter (pyautogui)
python3-tk \
# For RAR extraction
unrar-free && \
# Keep apt from "upgrading" chromium past the pin inside derived images
printf 'Package: chromium chromium-common\nPin: version %s\nPin-Priority: 1001\n' "${CHROMIUM_VERSION}" \
> /etc/apt/preferences.d/chromium-pin && \
rm /etc/apt/sources.list.d/chromium-pin-snapshot.list && \
# Create symlink so rarfile library can find unrar
ln -sf /usr/bin/unrar-free /usr/bin/unrar && \
# Cleanup APT cache
@@ -191,7 +168,12 @@ RUN echo "deb [check-valid-until=no] https://snapshot.debian.org/archive/debian-
# Install the browser automation stack used by the full image
RUN --mount=type=cache,target=/root/.cache/uv \
--mount=from=uv,source=/uv,target=/usr/local/bin/uv \
uv sync --locked --no-default-groups --extra browser
uv sync --locked --no-default-groups --extra browser && \
# Selenium ships prebuilt selenium-manager binaries (about 22MB) for every
# platform. They only run when selenium has to locate a webdriver, and the
# bypasser drives the browser through SeleniumBase's Pure-CDP driver, which
# needs none. Removed in this layer so the image does not carry them.
find /app/.venv/lib -name 'selenium-manager*' -delete
# Deterministically resolve the Xlib namespace collision.
# pyautogui/mouseinfo pull the stale `python3-xlib` (0.15, 2014), while the
+1 -1
View File
@@ -75,7 +75,7 @@ curl -s -H "Authorization: Bearer $SHELFMARK_API_KEY" https://shelfmark.example.
Set `SHELFMARK_API_KEY_READONLY` to a second secret for dashboards and monitors. It can read one endpoint,
`GET /api/stats`, and nothing else. Writes and every other path return 403, and the key never gets a session.
The admin key reaches `/api/stats` too.
The admin key reaches `/api/stats` too, and so does a browser session signed in as an admin. A refused request gets `{"error": "This API key is read-only"}`.
```bash
curl -s -H "X-Api-Key: $SHELFMARK_API_KEY_READONLY" https://shelfmark.example.com/api/stats
+9 -6
View File
@@ -41,8 +41,8 @@ services:
```
Notes:
- Point `/books` to your library ingest folder (Calibre-Web, Booklore, Audiobookshelf, etc) for automatic import.
- If you set Books Output Mode to Booklore (API), books are uploaded via API instead of written to `/books`. Audiobooks still use a destination folder.
- Point `/books` to your library ingest folder (Calibre-Web, Grimmory, Audiobookshelf, etc) for automatic import.
- If you set Output Mode to Grimmory (API), books are uploaded via API instead of written to `/books`. Audiobooks still use a destination folder.
- Ensure `PUID`/`PGID` (or legacy `UID`/`GID`) match the owner of the host directories.
- For non-root mode, start the container as `1000:1000`.
- On Kubernetes, set `runAsUser: 1000`, `runAsGroup: 1000`, and `runAsNonRoot: true` together.
@@ -95,13 +95,16 @@ If the files are copied or synced into Shelfmark on a delay, increase **Complete
in Settings -> Advanced. The default is 60 seconds; seedbox or remote-sync setups may need a value
longer than the sync interval.
With qBittorrent, Shelfmark also reads the torrent's own file list and imports only once every file
the torrent downloads is there at its full size, so a sync that copies files in batches and pauses
between them does not cause a partial import. The same wait applies, and files the torrent skips
are not counted.
## File Processing Options
### Transfer Method (Torrent / Usenet Only)
### Hardlink Torrents (Torrent / Usenet Only)
Available methods:
- Copy (default). Works everywhere.
- Hardlink. Preserves seeding without duplicating files.
Imported files are copied by default, which works everywhere. Turn on **Hardlink Book Torrents** (and **Hardlink Audiobook Torrents** for audiobooks) to hardlink instead, which preserves seeding without duplicating files.
Hardlink requirements and behavior:
- Source and destination must be on the same filesystem.
+9 -7
View File
@@ -1,6 +1,6 @@
# Custom Scripts
Shelfmark can run an executable you provide after a download task completes successfully. The script runs after the selected output has finished (for example: transfer to the folder destination, or upload to Booklore).
Shelfmark can run an executable you provide after a download task completes successfully. The script runs after the selected output has finished (for example: transfer to the folder destination, upload to Grimmory, or send by email).
## Quick Start (Recommended)
@@ -71,7 +71,8 @@ Shelfmark chooses a "best single path" for the task:
What the target path refers to depends on the output mode:
- Folder output (`output.mode=folder`, `phase=post_transfer`): the final imported file or folder inside your destination.
- Booklore output (`output.mode=booklore`, `phase=post_upload`): the local file or folder that was uploaded (the destination is remote).
- Grimmory output (`output.mode=booklore`, `phase=post_upload`): the local file or folder that was uploaded (the destination is remote).
- Email output (`output.mode=email`, `phase=post_email`): the file that was sent, or the folder holding the files when there were several.
By default, `$1` is an absolute path inside the Shelfmark container (or on your host, if you are not using Docker).
@@ -79,12 +80,12 @@ By default, `$1` is an absolute path inside the Shelfmark container (or on your
Configure in: Settings -> Advanced -> Custom Script JSON Payload
When enabled, Shelfmark sends a versioned JSON payload to your script via stdin (and still passes `$1`). This is the recommended way to write robust scripts, especially for multi-file imports (audiobooks) and output-specific context (like Booklore).
When enabled, Shelfmark sends a versioned JSON payload to your script via stdin (and still passes `$1`). This is the recommended way to write robust scripts, especially for multi-file imports (audiobooks) and output-specific context (like Grimmory).
- The JSON payload always includes absolute paths in `paths.*`, even if you set Custom Script Path Mode to `relative` for `$1`.
- `output.mode` tells you which output ran.
- `output.details` is output-specific. For Booklore output, `output.details.booklore` includes connection details such as `base_url`, `library_id`, and `path_id`.
- `phase` indicates when the script is running. Current values: `post_transfer` (folder output), `post_upload` (Booklore output).
- `output.details` is output-specific. For Grimmory output (the mode value is still `booklore`), `output.details.booklore` includes connection details such as `base_url`, `library_id`, and `path_id`. For email output, `output.details.email` has `to`, `label`, `host`, `port`, and `security`.
- `phase` indicates when the script is running. Current values: `post_transfer` (folder output), `post_upload` (Grimmory output), `post_email` (email output).
- `transfer` is only included for outputs that do a local transfer (for example the folder output).
If JSON payload is disabled, stdin is empty (EOF). Don't `cat` stdin unless you've enabled the payload.
@@ -171,7 +172,7 @@ This setting controls what gets passed as `$1`:
- `absolute` (default): pass an absolute path.
- `relative`: pass a path relative to the output's "destination root", and run the script with `$PWD` set to that root.
For folder output, the destination root is your configured destination folder. For Booklore output, it's the local upload folder.
For folder output, the destination root is your configured destination folder. For Grimmory output, it's the local upload folder. For email output, it's the folder holding the files that were sent.
Example (folder destination is `/data/library/books`, and the imported file ended up in `Isaac Asimov/Foundation/Foundation.epub`):
@@ -192,4 +193,5 @@ Note: if the target is the destination folder itself, `relative` mode may pass `
## Notes And Caveats
- **Hardlinks and torrents:** if you use hardlinking to keep seeding, avoid scripts that modify file contents, since hardlinked files share data with the seeding copy.
- **Booklore output mode:** scripts run after upload. `$1` will point at the local uploaded file (or staging folder).
- **Grimmory output mode:** scripts run after upload. `$1` will point at the local uploaded file (or staging folder).
- **Email output mode:** scripts run after the message is sent. `$1` will point at the file that was sent (or the folder holding the files).
+6 -1
View File
@@ -1,3 +1,8 @@
# Developer Documentation
TODO
Guides for adding to Shelfmark.
- [Release Sources Plugin Guide](release-sources-plugin-guide.md): add a source that searches for releases and a handler that downloads them
- [Plugin Settings Guide](plugin-settings.md): add settings tabs and fields for a plugin to the Settings UI
- [Adding Routes](routes.md): where a new API route goes, and how a route module gets what it needs
- [Metadata Providers](../../shelfmark/metadata_providers/README.md): add a metadata provider
+124 -22
View File
@@ -137,9 +137,11 @@ SelectField(
)
```
`SelectField` also takes `filter_by_field`, the key of another field whose value filters this field's options through each option's `childOf` property. `options` can be a list or a function that returns one, evaluated when the schema is built.
### MultiSelectField
Multi-choice selection from a list of options.
Multi-choice selection from a list of options. `variant` is `"pills"` (default) or `"dropdown"` for a checkbox dropdown.
```python
MultiSelectField(
@@ -156,6 +158,56 @@ MultiSelectField(
)
```
### TagListField
Editable list of free-form strings, shown as tags. The value is a list of strings.
```python
TagListField(
key="MY_PLUGIN_MIRROR_URLS",
label="Mirror URLs",
description="Type a URL and press Enter to add it",
placeholder="https://mirror.example",
normalize_urls=True, # default: entries are cleaned up as URLs
)
```
### OrderableListField
A list of options that can be toggled on and off and reordered. The value is a list of `{"id": ..., "enabled": ...}` objects in priority order.
```python
OrderableListField(
key="MY_PLUGIN_SOURCE_PRIORITY",
label="Source priority",
options=[
{"id": "a", "label": "Source A"},
{"id": "b", "label": "Source B", "isLocked": True, "disabledReason": "Needs an API key"},
],
default=[{"id": "a", "enabled": True}, {"id": "b", "enabled": False}],
)
```
An option can set `isLocked` (the toggle is disabled), `isPinned` (it cannot be reordered), `description` and `section`.
### TableField
Editable table of structured rows. The value is a list of objects, one per row.
```python
TableField(
key="MY_PLUGIN_RULES",
label="Rules",
columns=[
{"key": "name", "label": "Name", "type": "text", "placeholder": "Rule name"},
{"key": "mode", "label": "Mode", "type": "select", "options": [...]},
],
add_label="Add rule",
empty_message="No rules yet",
default=[],
)
```
### ActionButton
Button that executes a callback function. Does not store a value.
@@ -179,6 +231,16 @@ def my_callback_fn():
return {"success": False, "message": f"Failed: {str(e)}"}
```
A callback that declares a `current_values` parameter receives the form's current, unsaved values as a dict, so a "Test connection" button can test what the user just typed instead of what was last saved:
```python
def my_callback_fn(current_values: dict):
api_key = current_values.get("MY_PLUGIN_API_KEY", "")
...
```
The decorator calls your settings function as soon as the module is imported, so define the callback above `@register_settings`, not below it.
### HeadingField
Display-only section heading with optional link. Does not store a value.
@@ -235,6 +297,10 @@ All field types support these common properties:
| `description` | `str` | `""` | Help text shown below the field |
| `default` | `Any` | `None` | Default value if not set |
| `required` | `bool` | `False` | Whether the field must have a value |
| `env_var` | `str` | `None` | Environment variable name to use instead of `key` |
| `env_supported` | `bool` | `True` | Set `False` for a UI-only setting that cannot come from an environment variable |
| `user_overridable` | `bool` | `False` | Let an admin set a per-user override of this field |
| `universal_only` | `bool` | `False` | Only show the field in Universal search mode |
| `disabled` | `bool` | `False` | Disable the field (greyed out) |
| `disabled_reason` | `str` | `""` | Explanation shown when disabled |
| `requires_restart` | `bool` | `False` | Whether changes require container restart |
@@ -309,16 +375,34 @@ def plugin_a_settings():
**Existing groups:**
- `direct_download` (order=20): For download-related settings
- `metadata_providers` (order=50): For metadata provider plugins
- `metadata_providers` (order=12): For metadata provider plugins
## Value Resolution Priority
Settings values are resolved in this order (highest priority first):
1. **Config File** - Stored in `CONFIG_DIR/plugins/<tab_name>.json`
2. **Field Default** - Value specified in the field definition
1. **Environment variable** - The field's key, or `env_var` when set. Skipped for a field with `env_supported=False`. A value that comes from an environment variable is shown locked in the UI
2. **Config File** - Stored in `CONFIG_DIR/plugins/<tab_name>.json`
3. **Field Default** - Value specified in the field definition
The `general` tab uses `CONFIG_DIR/settings.json` instead of the plugins subdirectory.
The `general` and `search_mode` tabs use `CONFIG_DIR/settings.json` instead of the plugins subdirectory.
## Validating or Transforming on Save
`register_on_save(tab_name, handler)` runs a function each time the tab is saved. The handler receives a copy of the values being saved and returns a dict. Return `{"error": True, "message": "..."}` to refuse the save and show the message, or `{"values": {...}}` to save changed values.
```python
from shelfmark.core.settings_registry import register_on_save
def _validate_my_plugin(values: dict) -> dict:
if values.get("MY_PLUGIN_TIMEOUT", 30) < 5:
return {"error": True, "message": "Timeout must be at least 5 seconds"}
return {"values": values}
register_on_save("my_plugin", _validate_my_plugin)
```
## Reading Setting Values
@@ -339,7 +423,7 @@ all_settings = config.get_all()
```
The config singleton:
- Automatically resolves values from config files with field defaults as fallback
- Resolves values in the order above, with the field default as the last fallback
- Caches values for performance
- Refreshes automatically when settings are updated via the UI
@@ -350,9 +434,12 @@ Here's a complete example for a metadata provider plugin:
```python
# shelfmark/metadata_providers/my_provider.py
from shelfmark.metadata_providers.base import (
from shelfmark.metadata_providers import (
BookMetadata,
MetadataProvider,
MetadataSearchOptions,
register_provider,
register_provider_kwargs,
)
from shelfmark.core.settings_registry import (
register_settings,
@@ -426,6 +513,12 @@ def my_provider_settings():
]
@register_provider_kwargs("my_provider")
def _my_provider_kwargs() -> dict:
"""Keyword arguments for MyProvider(...), read from settings each time one is created."""
return {"api_key": config.get("MY_PROVIDER_API_KEY", "")}
@register_provider("my_provider")
class MyProvider(MetadataProvider):
"""My Provider metadata implementation."""
@@ -434,22 +527,28 @@ class MyProvider(MetadataProvider):
display_name = "My Provider"
requires_auth = True
def __init__(self, api_key: str = None):
self.api_key = api_key or config.get("MY_PROVIDER_API_KEY", "")
def __init__(self, api_key: str = ""):
self.api_key = api_key
self.base_url = config.get("MY_PROVIDER_BASE_URL", "https://api.myprovider.com/v1")
def is_available(self) -> bool:
return bool(self.api_key)
def search(self, query: str):
def search(self, options: MetadataSearchOptions) -> list[BookMetadata]:
# Implementation...
pass
return []
def get_book(self, book_id: str):
def get_book(self, book_id: str) -> BookMetadata | None:
# Implementation...
pass
return None
def search_by_isbn(self, isbn: str) -> BookMetadata | None:
# Implementation...
return None
```
All four methods above are abstract, so a provider that leaves one out cannot be instantiated. The provider module also has to be imported once so its decorators run: add it to the list of imports at the bottom of `shelfmark/metadata_providers/__init__.py`.
## Complete Example: Release Source
Here's a complete example for a release source plugin:
@@ -457,11 +556,11 @@ Here's a complete example for a release source plugin:
```python
# shelfmark/release_sources/my_source.py
from shelfmark.release_sources.base import (
ReleaseSource,
from shelfmark.release_sources import (
DownloadHandler,
register_source,
ReleaseSource,
register_handler,
register_source,
)
from shelfmark.core.settings_registry import (
register_settings,
@@ -559,22 +658,25 @@ class MySource(ReleaseSource):
def is_available(self) -> bool:
return self.enabled
def search(self, book):
def search(self, book, plan, *, expand_search=False, content_type="ebook"):
# Implementation...
pass
return []
@register_handler("my_source")
class MySourceHandler(DownloadHandler):
"""Handler for downloading from My Source."""
name = "my_source"
def download(self, release, output_path):
def download(self, task, cancel_flag, progress_callback, status_callback):
# Implementation...
pass
return None
def cancel(self, task_id):
return False
```
The full source and handler interfaces, and how to register the module, are in the [Release Sources Plugin Guide](release-sources-plugin-guide.md).
## Best Practices
1. **Use descriptive keys**: Keys should be uppercase and prefixed with your plugin name (e.g., `MY_PLUGIN_API_KEY`)
+252 -116
View File
@@ -54,12 +54,12 @@ This separation allows:
### Plugin Lifecycle
1. **Registration** (import time): Decorators register your classes in the global registry
2. **Discovery**: `list_available_sources()` checks `is_available()` on each source
3. **Search**: User selects source, `search(book_metadata)` returns releases
2. **Discovery**: `list_available_sources()` lists every registered source and reports `enabled` from its `is_available()`
3. **Search**: User selects source, `search(book, plan, ...)` returns releases
4. **Queue**: User selects release, it becomes a `DownloadTask` in the queue
5. **Download**: Orchestrator calls `handler.download()` with callbacks
6. **Progress**: Handler reports via `progress_callback` and `status_callback`
7. **Completion**: Handler returns file path or `None` on failure
7. **Completion**: Handler returns a file path (or a `HandoffResult` for an external handoff) and `None` on failure
### Source vs Handler
@@ -85,6 +85,7 @@ from typing import Callable, List, Optional
from threading import Event
from shelfmark.release_sources import (
HandoffResult,
Release,
ReleaseSource,
DownloadHandler,
@@ -93,6 +94,7 @@ from shelfmark.release_sources import (
)
from shelfmark.metadata_providers import BookMetadata
from shelfmark.core.models import DownloadTask
from shelfmark.core.search_plan import ReleaseSearchPlan
@register_source("my_source")
@@ -100,7 +102,14 @@ class MySource(ReleaseSource):
name = "my_source"
display_name = "My Source"
def search(self, book: BookMetadata) -> List[Release]:
def search(
self,
book: BookMetadata,
plan: ReleaseSearchPlan,
*,
expand_search: bool = False,
content_type: str = "ebook",
) -> List[Release]:
# Your search logic here
return []
@@ -116,7 +125,7 @@ class MyHandler(DownloadHandler):
cancel_flag: Event,
progress_callback: Callable[[float], None],
status_callback: Callable[[str, Optional[str]], None],
) -> Optional[str]:
) -> str | HandoffResult | None:
# Your download logic here
return None # Return file path on success
@@ -124,13 +133,18 @@ class MyHandler(DownloadHandler):
return False # Cancellation via cancel_flag
```
Register the import in `shelfmark/release_sources/__init__.py`:
Add the module to `_BUILTIN_SOURCE_MODULES` in `shelfmark/release_sources/__init__.py`:
```python
# At the bottom of the file
from shelfmark.release_sources import my_source # noqa: F401, E402
_BUILTIN_SOURCE_MODULES = (
"shelfmark.release_sources.audiobookbay",
# ...
"shelfmark.release_sources.my_source",
)
```
The built-in sources are packages (`jackett/`, `webshare/`, `slskd/` and so on) with the source, handler and settings in separate modules that the package `__init__.py` imports. A single module is fine for a small source.
---
## ReleaseSource Interface
@@ -139,7 +153,8 @@ The `ReleaseSource` abstract base class defines the search interface:
```python
from abc import ABC, abstractmethod
from typing import List
from typing import ClassVar, List
from shelfmark.core.search_plan import ReleaseSearchPlan
from shelfmark.metadata_providers import BookMetadata
from shelfmark.release_sources import Release, ReleaseColumnConfig
@@ -149,18 +164,29 @@ class ReleaseSource(ABC):
name: str # Internal identifier: "direct_download", "prowlarr"
display_name: str # User-facing name: "Direct Download", "Prowlarr"
supported_content_types: ClassVar[list[str]] = ["ebook", "audiobook"]
can_be_default: bool = True # Whether it can be picked as the default source in settings
@abstractmethod
def search(self, book: BookMetadata) -> List[Release]:
def search(
self,
book: BookMetadata,
plan: ReleaseSearchPlan,
*,
expand_search: bool = False,
content_type: str = "ebook",
) -> List[Release]:
"""Search for releases of a book.
Args:
book: Book metadata including title, authors, ISBN, language
plan: Pre-computed search inputs shared by all sources (see below)
expand_search: True when the user asked for a wider search
content_type: "ebook" or "audiobook"
Returns:
List of Release objects found from this source
"""
pass
@abstractmethod
def is_available(self) -> bool:
@@ -169,10 +195,8 @@ class ReleaseSource(ABC):
Returns:
True if source can be used for searching
"""
pass
@classmethod
def get_column_config(cls) -> ReleaseColumnConfig:
def get_column_config(self) -> ReleaseColumnConfig:
"""Get the column configuration for this source's release list UI.
Override to customize how releases are displayed in the modal.
@@ -181,17 +205,39 @@ class ReleaseSource(ABC):
return _default_column_config()
```
Optional hooks, all with working defaults:
| Method | Purpose |
|--------|---------|
| `search_results_are_releases()` | Return `True` when the source's browse results are already concrete releases. Request policy then treats "request book" as "request release" for it |
| `get_record(record_id, *, fetch_download_count=True)` | Resolve a source-native record for browse flows. Raises `NotImplementedError` by default |
| `get_destination_override(task)` | Return a source-specific destination `Path` for a queued download, or `None` |
### The search plan
`plan` is a `ReleaseSearchPlan` (`shelfmark/core/search_plan.py`) that Shelfmark builds once per search, so each source does not redo the same work:
| Field | Description |
|-------|-------------|
| `languages` | Language codes to filter by, or `None` |
| `isbn_candidates` | ISBNs to try, best first |
| `author` | Author string to search with |
| `title_variants`, `grouped_title_variants` | Title queries, best first. `plan.primary_query` is the first one |
| `manual_query` | Text the user typed into the search box, when they did |
| `indexers` | Indexer names for indexer-based sources |
| `source_filters` | Filters from the source's own search form |
### Search Method
The `search` method receives complete book metadata and returns standardized releases:
The `search` method receives the book metadata and the search plan, and returns standardized releases:
```python
def search(self, book: BookMetadata) -> List[Release]:
def search(self, book, plan, *, expand_search=False, content_type="ebook") -> List[Release]:
results = []
# Strategy 1: Search by ISBN (most accurate)
if book.isbn_13:
api_results = self.api.search_isbn(book.isbn_13)
if plan.isbn_candidates:
api_results = self.api.search_isbn(plan.isbn_candidates[0])
for r in api_results:
results.append(
Release(
@@ -204,9 +250,9 @@ def search(self, book: BookMetadata) -> List[Release]:
)
)
# Strategy 2: Fallback to title + author search
# Strategy 2: Fallback to the planned title query
if not results:
query = f"{book.title} {book.authors[0] if book.authors else ''}"
query = plan.manual_query or plan.primary_query
api_results = self.api.search_text(query)
for r in api_results:
results.append(Release(...))
@@ -242,6 +288,7 @@ from abc import ABC, abstractmethod
from typing import Callable, Optional
from threading import Event
from shelfmark.core.models import DownloadTask
from shelfmark.release_sources import HandoffResult
class DownloadHandler(ABC):
@@ -254,7 +301,7 @@ class DownloadHandler(ABC):
cancel_flag: Event,
progress_callback: Callable[[float], None],
status_callback: Callable[[str, Optional[str]], None],
) -> Optional[str]:
) -> str | HandoffResult | None:
"""Execute download and return path to downloaded file.
Args:
@@ -264,7 +311,9 @@ class DownloadHandler(ABC):
status_callback: Call with (status, optional_message)
Returns:
Absolute path to downloaded file, or None if failed/cancelled
Absolute path to downloaded file, a HandoffResult when the download
was handed to an external client and finished without a book payload,
or None if failed/cancelled
"""
pass
@@ -303,6 +352,15 @@ def list_files(self, release_data: dict[str, Any]) -> list[PackFile] | None:
Prowlarr parses the `.torrent` it already fetches (magnet-only releases return
`None`), and AudiobookBay reads the file table off its detail page.
### Optional: Other Handler Hooks
| Method | Purpose |
|--------|---------|
| `post_process_cleanup(task, *, success)` | Runs after Shelfmark has post-processed the download. External clients use it to clean up on their side only once the files are safely imported |
| `build_retry_resolution_fields(release_data)` | Return private queue-time fields the handler needs to retry after a restart |
| `list_authors(release_data)` | Return authors read from a source page, for sources whose search results can leave the author out. `None` when there is nothing more to say |
| `seed_complaints(release_data)` | How many comments on the release's page say nobody is seeding it, for sources with no seeder count of their own. `None` when there is no such signal |
### Download Method Parameters
| Parameter | Type | Description |
@@ -318,12 +376,13 @@ Prowlarr parses the `.torrent` it already fetches (magnet-only releases return
|--------|-------------|-------------|
| `"queued"` | Waiting in queue | Set by orchestrator, rarely needed in handler |
| `"resolving"` | Pre-download phase | Fetching metadata, extracting URLs, connecting |
| `"locating"` | Waiting for the finished files to show up | Set by the handler with `status_callback` once an external client or peer reports done, as the torrent, usenet and Soulseek handlers do |
| `"downloading"` | Active download | File transfer in progress |
| `"complete"` | Successfully finished | Set by orchestrator when handler returns a file path |
| `"error"` | Failed | Any unrecoverable error (handler should set this) |
| `"cancelled"` | User cancelled | Set by orchestrator when `cancel_flag` is triggered |
**Note**: The orchestrator automatically sets `"complete"` when your handler returns a valid file path, and `"cancelled"` when cancellation is detected. Your handler should primarily use `"resolving"`, `"downloading"`, and `"error"`.
**Note**: The orchestrator automatically sets `"complete"` when your handler returns a valid file path, and `"cancelled"` when cancellation is detected. Your handler should primarily use `"resolving"`, `"downloading"`, and `"error"`, plus `"locating"` if it waits on an external client.
### Typical Download Flow
@@ -391,19 +450,32 @@ class Release:
title: str # Display title
format: Optional[str] = None # File format: "epub", "mobi", "pdf"
language: Optional[str] = None # ISO 639-1 code: "en", "de", "fr"
size: Optional[str] = None # Human-readable: "5.2 MB"
size_bytes: Optional[int] = None # For sorting
download_url: Optional[str] = None # Direct download URL if available
info_url: Optional[str] = None # Link to tracker/info page
protocol: Optional[str] = None # "http", "torrent", "usenet"
protocol: Optional[ReleaseProtocol] = None # See below
indexer: Optional[str] = None # Display name: "Direct Download", "My Indexer"
seeders: Optional[int] = None # For torrents
peers: Optional[str] = None # For torrents: "seeders/leechers" display string
content_type: Optional[str] = None # "ebook" or "audiobook", kept from the search
extra: Dict = field(default_factory=dict) # Source-specific metadata
```
`ReleaseProtocol` is a string enum:
| Member | Value | Used for |
|--------|-------|----------|
| `HTTP` | `"http"` | Direct HTTP download |
| `TORRENT` | `"torrent"` | BitTorrent |
| `NZB` | `"nzb"` | Usenet NZB |
| `DCC` | `"dcc"` | IRC DCC |
| `SOULSEEK` | `"soulseek"` | Soulseek peer transfer (via slskd) |
#### Using the `extra` Field
Store source-specific data in `extra` that doesn't fit standard fields:
@@ -440,22 +512,36 @@ class DownloadTask:
# Display info (from Release.extra or Release fields)
author: Optional[str] = None
year: Optional[str] = None
format: Optional[str] = None
size: Optional[str] = None
preview: Optional[str] = None # Cover image URL
content_type: Optional[str] = None
source_url: Optional[str] = None # Original release URL, for source-specific handlers
# Library naming templates
series_name: Optional[str] = None
series_position: Optional[float] = None
subtitle: Optional[str] = None
narrator: Optional[str] = None
language: Optional[str] = None
# Runtime state (managed by orchestrator)
priority: int = 0
added_time: float = field(default_factory=time.time)
progress: float = 0.0
status: str = "queued"
status: QueueStatus = QueueStatus.QUEUED
status_message: Optional[str] = None
download_path: Optional[str] = None
# ... plus retry fields (retry_*), the output selection (output_mode, output_args),
# multi-book pack fields (multi_book, book_plan, ignored_files) and user fields
# (user_id, username, request_id). See shelfmark/core/models.py for the full class.
```
### BookMetadata
Book information passed to `search()`:
Book information passed to `search()` (the class lives in `shelfmark/metadata_providers/__init__.py`):
```python
@dataclass
@@ -475,7 +561,20 @@ class BookMetadata:
language: Optional[str] = None # ISO 639-1 code: "en", "de", "fr"
genres: List[str] = field(default_factory=list)
source_url: Optional[str] = None
subtitle: Optional[str] = None
search_title: Optional[str] = None # Cleaner title for search queries, when the provider has one
search_author: Optional[str] = None # Cleaner author for search queries
cover_aspect: Optional[str] = None # "portrait" or "square"
display_fields: List[DisplayField] = field(default_factory=list)
# Series info, when the book is part of a series
series_id: Optional[str] = None
series_name: Optional[str] = None
series_position: Optional[float] = None # 3, or 1.5 for a novella
series_count: Optional[int] = None
# Localized titles for searches in other languages: {"de": "Der Titel"}
titles_by_language: Dict[str, str] = field(default_factory=dict)
```
---
@@ -507,34 +606,39 @@ from shelfmark.release_sources import (
# Get a source instance by name
source = get_source("my_source")
releases = source.search(book_metadata)
releases = source.search(book_metadata, plan, expand_search=False, content_type="ebook")
# Get a handler instance by name
handler = get_handler("my_source")
file_path = handler.download(task, cancel_flag, progress_cb, status_cb)
# List all sources where is_available() returns True
available = list_available_sources()
# Returns: [{"name": "direct_download", "display_name": "Direct Download"}, ...]
# List every registered source, with an `enabled` flag taken from is_available()
sources = list_available_sources()
# Returns: [{"name": "direct_download", "display_name": "Direct Download", "enabled": True,
# "supported_content_types": ["ebook", "audiobook"],
# "browse_results_are_releases": False, "can_be_default": True}, ...]
```
### Import Registration
**Critical**: Your plugin must be imported at module load time. Add to `release_sources/__init__.py`:
**Critical**: Your plugin must be imported for its decorators to run. Built-in sources are listed in `_BUILTIN_SOURCE_MODULES` in `release_sources/__init__.py`, and `_ensure_builtin_sources_registered()` imports each one the first time a source or handler is looked up:
```python
# At the bottom of __init__.py
from shelfmark.release_sources import direct_download # noqa: F401, E402
from shelfmark.release_sources import my_source # noqa: F401, E402
_BUILTIN_SOURCE_MODULES = (
"shelfmark.release_sources.audiobookbay",
"shelfmark.release_sources.direct_download",
# ...
"shelfmark.release_sources.my_source", # add your module here
)
```
The `noqa` comments suppress linter warnings about unused imports.
Use the dotted path of the module (or package) that imports your source, handler and settings.
---
## Settings Integration
Register plugin settings using the settings registry decorator:
Register plugin settings using the settings registry decorator. The decorator calls your function as soon as the module is imported, so define anything the fields refer to, such as a button callback, above it:
```python
from shelfmark.core.settings_registry import (
@@ -551,12 +655,39 @@ from shelfmark.core.settings_registry import (
)
def test_my_source_connection(current_values: dict):
"""Test connection callback - returns result dict.
`current_values` holds the form's unsaved values, so the button tests what the user just
typed. A password the user has not touched may not be sent, so fall back to the saved one.
"""
try:
api_key = current_values.get("MY_SOURCE_API_KEY") or get_setting_value(
PasswordField(key="MY_SOURCE_API_KEY", label=""), "my_source"
)
if not api_key:
return {"success": False, "message": "API key not configured"}
# Test the connection
response = requests.get(
"https://api.mysource.com/ping", headers={"Authorization": f"Bearer {api_key}"}
)
if response.status_code == 200:
return {"success": True, "message": "Connected successfully!"}
else:
return {"success": False, "message": f"API returned {response.status_code}"}
except Exception as e:
return {"success": False, "message": str(e)}
@register_settings(
name="my_source", # Unique identifier
display_name="My Source", # Shown in settings UI
icon="globe", # Icon name (optional)
order=60, # Sort order in sidebar
group="sources", # Grouping in sidebar
)
def my_source_settings():
"""Register settings for My Source plugin."""
@@ -600,31 +731,10 @@ def my_source_settings():
style="primary",
),
]
def test_my_source_connection():
"""Test connection callback - returns result dict."""
try:
# Get current API key
api_key = get_setting_value(PasswordField(key="MY_SOURCE_API_KEY", label=""), "my_source")
if not api_key:
return {"success": False, "message": "API key not configured"}
# Test the connection
response = requests.get(
"https://api.mysource.com/ping", headers={"Authorization": f"Bearer {api_key}"}
)
if response.status_code == 200:
return {"success": True, "message": "Connected successfully!"}
else:
return {"success": False, "message": f"API returned {response.status_code}"}
except Exception as e:
return {"success": False, "message": str(e)}
```
A source's settings tab is normally top level, as Jackett and Webshare.cz are. The optional `group=` argument files a tab under a sidebar group registered with `register_group()`. The only groups today are `direct_download` and `metadata_providers`.
### Field Types Reference
| Field Type | Purpose | Key Properties |
@@ -688,8 +798,7 @@ from shelfmark.release_sources import (
)
@classmethod
def get_column_config(cls) -> ReleaseColumnConfig:
def get_column_config(self) -> ReleaseColumnConfig:
return ReleaseColumnConfig(
columns=[
ColumnSchema(
@@ -724,7 +833,7 @@ def get_column_config(cls) -> ReleaseColumnConfig:
ColumnSchema(
key="seeders",
label="Seeders",
render_type=ColumnRenderType.SEEDERS,
render_type=ColumnRenderType.NUMBER,
align=ColumnAlign.CENTER,
width="60px",
hide_mobile=True,
@@ -744,13 +853,28 @@ def get_column_config(cls) -> ReleaseColumnConfig:
|----------|------|-------------|
| `key` | `str` | Data path: `"format"`, `"extra.language"`, `"extra.seeders"` |
| `label` | `str` | Accessibility label |
| `render_type` | `ColumnRenderType` | `TEXT`, `BADGE`, `SIZE`, `NUMBER`, `SEEDERS` |
| `render_type` | `ColumnRenderType` | `TEXT`, `BADGE`, `TAGS`, `SIZE`, `NUMBER`, `PEERS`, `INDEXER_PROTOCOL`, `FLAG_ICON`, `FORMAT_CONTENT_TYPE` |
| `align` | `ColumnAlign` | `LEFT`, `CENTER`, `RIGHT` |
| `width` | `str` | CSS width: `"80px"`, `"minmax(0, 2fr)"` |
| `hide_mobile` | `bool` | Hide on small screens |
| `color_hint` | `ColumnColorHint` | For `BADGE` type coloring |
| `fallback` | `str` | Shown when data missing (default: `"-"`) |
| `uppercase` | `bool` | Force uppercase text |
| `sortable` | `bool` | Offer this column in the sort dropdown (opt-in) |
| `sort_key` | `str` | Field to sort by, defaults to `key` |
| `setting_key` | `str` | Name of a boolean setting that shows or hides the column |
| `content_types` | `tuple[str, ...]` | Only show the column for these content types (`"ebook"`, `"audiobook"`) |
`ReleaseColumnConfig` takes more than `columns`, `grid_template` and `leading_cell`:
| Property | Description |
|----------|-------------|
| `supported_filters` | Which filters the source supports: `["format", "language", "indexer"]` |
| `extra_sort_options` | `SortOption(label, sort_key, default_direction)` entries for sorts not tied to a column |
| `action_button` | `SourceActionButton(label, action)` that replaces the default "expand search" button |
| `cache_ttl_seconds` | How long results are cached (default 5 minutes) |
| `online_servers` | IRC: nicks of the servers currently online |
| `available_indexers`, `default_indexers` | Indexer sources: every enabled indexer, and the ones selected in settings |
### Color Hints
@@ -841,16 +965,20 @@ error → "All download sources failed"
### In Search Methods
Return empty list on errors, log for debugging:
Return an empty list when the search simply found nothing or failed in a way the user cannot act on, and log for debugging. When the source is configured but unreachable, raise `SourceUnavailableError` with a message written for the user, so the UI can say why there are no results instead of showing an empty list:
```python
def search(self, book: BookMetadata) -> List[Release]:
from shelfmark.release_sources import SourceUnavailableError
def search(self, book, plan, *, expand_search=False, content_type="ebook") -> List[Release]:
try:
results = self.api.search(book.title)
results = self.api.search(plan.primary_query)
return [self._to_release(r) for r in results]
except ConnectionError as e:
logger.warning(f"Connection error searching {self.name}: {e}")
return []
msg = f"{self.display_name} is not reachable"
raise SourceUnavailableError(msg) from e
except AuthenticationError:
logger.error(f"{self.name} API key invalid")
return []
@@ -928,6 +1056,7 @@ from typing import Callable, Dict, List, Optional
from threading import Event
from shelfmark.release_sources import (
HandoffResult,
Release,
ReleaseSource,
DownloadHandler,
@@ -943,6 +1072,7 @@ from shelfmark.release_sources import (
)
from shelfmark.metadata_providers import BookMetadata
from shelfmark.core.models import DownloadTask
from shelfmark.core.search_plan import ReleaseSearchPlan
from shelfmark.core.logger import setup_logger
from shelfmark.core.settings_registry import (
register_settings,
@@ -963,12 +1093,48 @@ logger = setup_logger(__name__)
# ---------------------------------------------------------------------------
def _test_booknet_connection(current_values: Dict) -> Dict:
"""Test BookNet API connection using the form's unsaved values, then the saved ones."""
try:
saved = load_config_file("booknet")
api_key = current_values.get("BOOKNET_API_KEY") or saved.get("BOOKNET_API_KEY")
base_url = (
current_values.get("BOOKNET_BASE_URL")
or saved.get("BOOKNET_BASE_URL")
or "https://api.booknet.example.com"
)
if not api_key:
return {"success": False, "message": "API key not configured"}
response = requests.get(
f"{base_url}/v1/ping",
headers={"Authorization": f"Bearer {api_key}"},
timeout=10,
)
if response.status_code == 200:
data = response.json()
return {
"success": True,
"message": f"Connected! {data.get('books_available', 0):,} books available",
}
elif response.status_code == 401:
return {"success": False, "message": "Invalid API key"}
else:
return {"success": False, "message": f"API error: {response.status_code}"}
except requests.Timeout:
return {"success": False, "message": "Connection timeout"}
except Exception as e:
return {"success": False, "message": str(e)}
@register_settings(
name="booknet",
display_name="BookNet",
icon="book",
order=55,
group="sources",
)
def booknet_settings():
"""Register BookNet settings."""
@@ -1016,39 +1182,6 @@ def booknet_settings():
]
def _test_booknet_connection() -> Dict:
"""Test BookNet API connection."""
try:
config = load_config_file("booknet")
api_key = config.get("BOOKNET_API_KEY")
base_url = config.get("BOOKNET_BASE_URL", "https://api.booknet.example.com")
if not api_key:
return {"success": False, "message": "API key not configured"}
response = requests.get(
f"{base_url}/v1/ping",
headers={"Authorization": f"Bearer {api_key}"},
timeout=10,
)
if response.status_code == 200:
data = response.json()
return {
"success": True,
"message": f"Connected! {data.get('books_available', 0):,} books available",
}
elif response.status_code == 401:
return {"success": False, "message": "Invalid API key"}
else:
return {"success": False, "message": f"API error: {response.status_code}"}
except requests.Timeout:
return {"success": False, "message": "Connection timeout"}
except Exception as e:
return {"success": False, "message": str(e)}
# ---------------------------------------------------------------------------
# Release Source Implementation
# ---------------------------------------------------------------------------
@@ -1068,7 +1201,14 @@ class BookNetSource(ReleaseSource):
self.timeout = config.get("BOOKNET_TIMEOUT", 30)
self.prefer_epub = config.get("BOOKNET_PREFER_EPUB", True)
def search(self, book: BookMetadata) -> List[Release]:
def search(
self,
book: BookMetadata,
plan: ReleaseSearchPlan,
*,
expand_search: bool = False,
content_type: str = "ebook",
) -> List[Release]:
"""Search BookNet for releases."""
if not self.is_available():
return []
@@ -1077,8 +1217,8 @@ class BookNetSource(ReleaseSource):
try:
# Strategy 1: Search by ISBN (most accurate)
if book.isbn_13:
releases = self._search_isbn(book.isbn_13)
if plan.isbn_candidates:
releases = self._search_isbn(plan.isbn_candidates[0])
# Strategy 2: Fallback to title + author
if not releases:
@@ -1149,8 +1289,7 @@ class BookNetSource(ReleaseSource):
"""Check if BookNet is configured."""
return bool(self.api_key)
@classmethod
def get_column_config(cls) -> ReleaseColumnConfig:
def get_column_config(self) -> ReleaseColumnConfig:
"""Custom column layout for BookNet releases."""
return ReleaseColumnConfig(
columns=[
@@ -1217,7 +1356,7 @@ class BookNetHandler(DownloadHandler):
cancel_flag: Event,
progress_callback: Callable[[float], None],
status_callback: Callable[[str, Optional[str]], None],
) -> Optional[str]:
) -> str | HandoffResult | None:
"""Execute download from BookNet."""
try:
# Phase 1: Get download URL
@@ -1364,10 +1503,10 @@ def is_available(self) -> bool:
return False
def search(self, book: BookMetadata) -> List[Release]:
"""Return empty list on errors, don't raise."""
def search(self, book, plan, *, expand_search=False, content_type="ebook") -> List[Release]:
"""Return empty list on errors, don't raise (SourceUnavailableError aside)."""
try:
return self._do_search(book)
return self._do_search(book, plan)
except Exception as e:
logger.warning(f"Search failed: {e}")
return []
@@ -1459,14 +1598,11 @@ logger.error("Failures") # Unrecoverable errors
When creating a new plugin:
1. Create `shelfmark/release_sources/my_plugin.py`
1. Create `shelfmark/release_sources/my_plugin.py` (or a `my_plugin/` package, as the built-in sources are)
2. Implement `ReleaseSource` subclass with `@register_source("my_plugin")`
3. Implement `DownloadHandler` subclass with `@register_handler("my_plugin")`
4. Add settings with `@register_settings("my_plugin", ...)`
5. Add import to `shelfmark/release_sources/__init__.py`:
```python
from shelfmark.release_sources import my_plugin # noqa: F401, E402
```
5. Add `"shelfmark.release_sources.my_plugin"` to `_BUILTIN_SOURCE_MODULES` in `shelfmark/release_sources/__init__.py`
6. Test `is_available()` returns `True` when configured
7. Test search returns valid `Release` objects
8. Test download handles cancellation and errors gracefully
+3
View File
@@ -0,0 +1,3 @@
# Adding Routes
New API routes go in their own module under `shelfmark/core/`, named for the group (`stats_routes.py`, `settings_routes.py`), as a `register_<group>_routes(app, ...)` function that `main.py` calls once. Anything the routes need that lives in `main.py` or is shared with another group, such as `login_required`, the auth mode or the user database, is passed in as a parameter, so a route module never imports `main`. When a test patches one of those, pass a callable that is evaluated per request, the way `register_stats_routes` does. Helpers that belong to one group stay in its module. Please don't add `@app.route` handlers to `main.py` itself: two PRs that each add a route there land in the same stretch of the file and conflict, where two route modules only share the one register call. `shelfmark/core/stats_routes.py` is the smallest example to copy. A few older routes are still in `main.py` and are being moved out one group at a time, see [Discussion #1536](https://github.com/calibrain/shelfmark/discussions/1536).
+549 -60
View File
@@ -12,21 +12,25 @@ This document lists all configuration options that can be set via environment va
- [Search Mode](#search-mode)
- [Downloads](#downloads)
- [Security](#security)
- [Hardcover Sync](#hardcover-sync)
- [Network](#network)
- [Advanced](#advanced)
- [Prowlarr](#prowlarr)
- [Jackett](#jackett)
- [Newznab](#newznab)
- [Soulseek (slskd)](#soulseek-slskd)
- [AudiobookBay](#audiobookbay)
- [Libgen Search](#libgen-search)
- [Webshare.cz](#websharecz)
- [IRC](#irc)
- [Download Clients](#download-clients)
- [Metadata Providers](#metadata-providers)
- [Hardcover](#metadata-providers-hardcover)
- [Open Library](#metadata-providers-open-library)
- [Google Books](#metadata-providers-google-books)
- [Moly.hu](#metadata-providers-moly.hu)
- [Moly.hu](#metadata-providers-molyhu)
- [Databáze knih](#metadata-providers-databáze-knih)
- [DNB](#metadata-providers-dnb)
- [Direct Download](#direct-download)
- [Download Sources](#direct-download-download-sources)
- [Cloudflare Bypass](#direct-download-cloudflare-bypass)
@@ -52,8 +56,9 @@ These environment variables are used at startup before the settings system loads
| `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` |
| `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` |
| `SHELFMARK_API_KEY_READONLY` | Optional read-only API key. Can only GET `/api/stats`; never an admin. Unset = off. | string | `unset` |
| `SHELFMARK_API_KEY_READONLY` | Optional second API key for dashboards and monitors. Send it like SHELFMARK_API_KEY. It can only GET /api/stats (counters only, no titles or user names). Every other request, and any write, is refused with 403, and it never gets a session. Unset = off. | string | `unset` |
| `OIDC_AUTO_REDIRECT` | Automatically redirect to the OIDC provider instead of showing the login page. | boolean | `false` |
| `UMASK` | File creation mask for the app process, read by entrypoint.sh. The default 0022 gives files 644 and directories 755. | string (octal) | `0022` |
| `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` |
@@ -146,7 +151,7 @@ Optional static API key. When set, requests carrying it as 'Authorization: Beare
#### `SHELFMARK_API_KEY_READONLY`
Optional second API key for dashboards. Send it like `SHELFMARK_API_KEY`. It can only `GET /api/stats` (counters only, no titles or user names). Every other request, and any write, is refused with 403, and it never gets a session. Unset = off.
Optional second API key for dashboards and monitors. Send it like SHELFMARK_API_KEY. It can only GET /api/stats (counters only, no titles or user names). Every other request, and any write, is refused with 403, and it never gets a session. Unset = off.
- **Type:** string
- **Default:** `unset`
@@ -158,6 +163,13 @@ Automatically redirect to the OIDC provider instead of showing the login page.
- **Type:** boolean
- **Default:** `false`
#### `UMASK`
File creation mask for the app process, read by entrypoint.sh. The default 0022 gives files 644 and directories 755.
- **Type:** string (octal)
- **Default:** `0022`
#### `DOCKERMODE`
Indicates the application is running inside a Docker container.
@@ -176,7 +188,7 @@ Show the onboarding wizard on first run. Set to false to skip (useful for epheme
## Egress / VPN Routing
These startup-only variables are consumed by `entrypoint.sh` / `wireguard.sh` to select and configure the WireGuard transparent-egress kill-switch. `USING_WIREGUARD` and [`USING_TOR`](#using_tor) (documented under Network) are mutually exclusive; both require root startup.
These startup-only variables are consumed by `entrypoint.sh` / `wireguard.sh` to select and configure the WireGuard transparent-egress kill-switch. `USING_WIREGUARD` and [`USING_TOR`](#using_tor) (documented under Network) are mutually exclusive; both require root startup. `TOR_CHECK_INTERVAL` and `TOR_MAX_FAILURES` tune the Tor watchdog in `tor.sh`.
| Variable | Description | Type | Default |
|----------|-------------|------|---------|
@@ -190,6 +202,8 @@ These startup-only variables are consumed by `entrypoint.sh` / `wireguard.sh` to
| `WIREGUARD_ALLOW_IPV6_LEAK` | Escape hatch: continue startup even when an IPv6 kill-switch cannot be installed AND IPv6 cannot be disabled. Only set when the container has no IPv6 connectivity, as IPv6 egress may otherwise bypass the tunnel. | boolean | `false` |
| `WIREGUARD_ALLOW_WEBUI_OFFTUNNEL` | When false (default) the kill-switch is strictly fail-closed: the only off-tunnel egress permitted is loopback, the tunnel device and the LAN allowlist. Set true only if a NON-LAN client (e.g. a public reverse proxy on a different segment) must reach the WebUI; it permits app-server REPLY packets (--sport FLASK_PORT, conntrack REPLY) to leave off-tunnel. Server replies only, never client-initiated egress, so it cannot leak outbound browsing/downloads or the real IP for outbound requests, but it is still an off-tunnel path while the tunnel is down, hence opt-in. LAN WebUI clients never need it (covered by LAN_NETWORK). | boolean | `false` |
| `WIREGUARD_STALE_AFTER` | Seconds since the last WireGuard handshake before the healthcheck bounces the tunnel. | number | `180` |
| `TOR_CHECK_INTERVAL` | Seconds between Tor watchdog probes. The watchdog resets the container when Tor stops being usable, so the restart policy rebuilds the Tor setup. | integer (seconds) | `30` |
| `TOR_MAX_FAILURES` | Consecutive failed Tor watchdog probes before the container is reset. 2 tolerates one failed probe. | integer | `2` |
<details>
<summary>Detailed descriptions</summary>
@@ -264,6 +278,20 @@ Seconds since the last WireGuard handshake before the healthcheck bounces the tu
- **Type:** number
- **Default:** `180`
#### `TOR_CHECK_INTERVAL`
Seconds between Tor watchdog probes. The watchdog resets the container when Tor stops being usable, so the restart policy rebuilds the Tor setup.
- **Type:** integer (seconds)
- **Default:** `30`
#### `TOR_MAX_FAILURES`
Consecutive failed Tor watchdog probes before the container is reset. 2 tolerates one failed probe.
- **Type:** integer
- **Default:** `2`
</details>
## General
@@ -275,6 +303,10 @@ Seconds since the last WireGuard handshake before the healthcheck bounces the tu
| `AUDIOBOOK_LIBRARY_URL` | Adds a separate navigation button for your audiobook library (Audiobookshelf, Plex, etc). When both URLs are set, icons are shown instead of text. | string | _none_ |
| `LIBRARY_CHECK_CALIBRE_ENABLED` | Read the Calibre metadata.db and mark search results you already own, so you do not download a second copy. Read only, nothing is written to the library. | boolean | `false` |
| `CALIBRE_LIBRARY_DB_PATH` | Path to metadata.db as seen from inside the Shelfmark container. Mount the Calibre library folder read-only, e.g. /path/to/calibre-library:/calibre-library:ro. | string | `/calibre-library/metadata.db` |
| `LIBRARY_CHECK_AUDIOBOOKSHELF_ENABLED` | Ask your Audiobookshelf server which audiobooks you already own, so synced and automatic downloads skip them. Read only, nothing is written to Audiobookshelf. | boolean | `false` |
| `AUDIOBOOKSHELF_URL` | Base URL reachable from the Shelfmark container (not the public URL). Often the host LAN IP, e.g. http://10.0.0.91:13378. | string | _none_ |
| `AUDIOBOOKSHELF_TOKEN` | API token from Audiobookshelf (Settings > Users > your user > API Token). | string (secret) | _none_ |
| `AUDIOBOOKSHELF_LIBRARY_IDS` | Comma-separated library IDs to check. Leave blank for all book libraries. | string | _none_ |
| `SUPPORTED_FORMATS` | Book formats to include in search results. ZIP/RAR archives are extracted automatically and book files are used if found. | string (comma-separated) | `epub,mobi,azw3,fb2,djvu,cbz,cbr` |
| `SUPPORTED_AUDIOBOOK_FORMATS` | Audiobook formats to include in search results. ZIP/RAR archives are extracted automatically and audiobook files are used if found. | string (comma-separated) | `m4b,mp3,m4a,mp4,flac,ogg,wma,aac,wav,opus,zip,rar` |
@@ -326,6 +358,42 @@ Path to metadata.db as seen from inside the Shelfmark container. Mount the Calib
- **Type:** string
- **Default:** `/calibre-library/metadata.db`
#### `LIBRARY_CHECK_AUDIOBOOKSHELF_ENABLED`
**Mark audiobooks already in your Audiobookshelf library**
Ask your Audiobookshelf server which audiobooks you already own, so synced and automatic downloads skip them. Read only, nothing is written to Audiobookshelf.
- **Type:** boolean
- **Default:** `false`
#### `AUDIOBOOKSHELF_URL`
**Audiobookshelf URL**
Base URL reachable from the Shelfmark container (not the public URL). Often the host LAN IP, e.g. http://10.0.0.91:13378.
- **Type:** string
- **Default:** _none_
#### `AUDIOBOOKSHELF_TOKEN`
**Audiobookshelf API Token**
API token from Audiobookshelf (Settings > Users > your user > API Token).
- **Type:** string (secret)
- **Default:** _none_
#### `AUDIOBOOKSHELF_LIBRARY_IDS`
**Audiobookshelf Library IDs (optional)**
Comma-separated library IDs to check. Leave blank for all book libraries.
- **Type:** string
- **Default:** _none_
#### `SUPPORTED_FORMATS`
**Supported Book Formats**
@@ -549,8 +617,8 @@ Audiobooks only. AudiobookBay lists it for most releases. In Prowlarr results it
| `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` |
| `DOWNLOAD_TO_BROWSER_CONTENT_TYPES` | Automatically download completed files to your browser for the selected content types. | string (comma-separated) | _empty list_ |
| `MAX_CONCURRENT_DOWNLOADS` | Maximum number of simultaneous downloads. | number | `3` |
| `MAX_CONCURRENT_DOWNLOADS_PER_SOURCE` | Simultaneous downloads allowed from each source (Soulseek, AudiobookBay, Direct Download and so on). A source at its limit does not hold the others back, so a Soulseek download waiting in a peer's queue cannot stop a torrent from starting. 0 uses the single limit above for all sources together. | number | `0` |
| `MAX_CONCURRENT_DOWNLOADS` | Maximum number of simultaneous downloads. Not used while Max Concurrent Downloads Per Source is above 0. | number | `3` |
| `MAX_CONCURRENT_DOWNLOADS_PER_SOURCE` | Simultaneous downloads allowed from each source (Soulseek, AudiobookBay, Direct Download and so on). A source at its limit does not hold the others back, so a Soulseek download waiting in a peer's queue cannot stop a torrent from starting. Above 0 this replaces Max Concurrent Downloads: the total can be this number for every source that has downloads at once. 0 uses the single limit above for all sources together. | number | `0` |
| `STATUS_TIMEOUT` | How long to keep completed/failed downloads in the queue display. | number | `3600` |
<details>
@@ -854,7 +922,7 @@ Automatically download completed files to your browser for the selected content
**Max Concurrent Downloads**
Maximum number of simultaneous downloads.
Maximum number of simultaneous downloads. Not used while Max Concurrent Downloads Per Source is above 0.
- **Type:** number
- **Default:** `3`
@@ -865,7 +933,7 @@ Maximum number of simultaneous downloads.
**Max Concurrent Downloads Per Source**
Simultaneous downloads allowed from each source (Soulseek, AudiobookBay, Direct Download and so on). A source at its limit does not hold the others back, so a Soulseek download waiting in a peer's queue cannot stop a torrent from starting. 0 uses the single limit above for all sources together.
Simultaneous downloads allowed from each source (Soulseek, AudiobookBay, Direct Download and so on). A source at its limit does not hold the others back, so a Soulseek download waiting in a peer's queue cannot stop a torrent from starting. Above 0 this replaces Max Concurrent Downloads: the total can be this number for every source that has downloads at once. 0 uses the single limit above for all sources together.
- **Type:** number
- **Default:** `0`
@@ -1049,6 +1117,197 @@ Custom label for the OIDC sign-in button on the login page.
</details>
## Hardcover Sync
| Variable | Description | Type | Default |
|----------|-------------|------|---------|
| `HARDCOVER_SYNC_ENABLED` | Periodically sync the selected Hardcover shelves into requests. | boolean | `false` |
| `HARDCOVER_SYNC_TOKEN` | Bearer token for your Hardcover account. Leave blank to reuse the token from the Hardcover metadata provider. | string (secret) | _none_ |
| `HARDCOVER_SYNC_STATUSES` | Which Hardcover reading shelf to pull from. | string (choice) | `1` |
| `HARDCOVER_SYNC_CONTENT_TYPE` | Content type for synced requests. "Ebooks and audiobooks" creates one request of each per shelf book; every request is checked against its own library and downloaded with its own format guard and source priority. | string (choice) | `audiobook` |
| `HARDCOVER_SYNC_INTERVAL` | How often the scheduled sync runs (minimum 1 minute). | number | `6` |
| `HARDCOVER_SYNC_INTERVAL_UNIT` | Whether the sync interval is measured in minutes or hours. | string (choice) | `hours` |
| `AUTO_DOWNLOAD_ENABLED` | Auto-approve and auto-download matching releases for Hardcover requests. Another user's request is only approved this way where their request policy already lets them download directly; the rest wait for an admin. | boolean | `false` |
| `AUTO_DOWNLOAD_SOURCE_PRIORITY` | Drag to set which release sources are tried first for audiobook requests. The first source with a strict match wins. Disabled sources are skipped. | JSON array | _empty list_ |
| `AUTO_DOWNLOAD_EBOOK_SOURCE_PRIORITY` | Drag to set which release sources are tried first for ebook requests. The first source with a strict match wins. Disabled sources are skipped. | JSON array | _empty list_ |
| `AUTO_DOWNLOAD_MIN_SEEDERS` | Skip torrent releases with fewer seeders than this. | number | `1` |
| `AUTO_DOWNLOAD_RETRY_DAYS` | A synced request whose download failed, or was interrupted (a restart, say), goes back to pending after this many days and is tried again, a few per pass. A release that failed is not picked again, and a request is given up on after three failed retries. A download the stall timer cancelled counts as failed; one cancelled by a person, and rejected requests, are never retried. 0 turns retries off. | number | `7` |
| `AUTO_DOWNLOAD_FAILURE_RETRY_MINUTES` | A download that failed, or that was given up on (stalled, no seeders), goes back to pending this many minutes later and another release is chosen, without waiting for the retry days above or the next sync cycle. The release that failed is not picked again, and a request is given up on after three failed attempts. 0 turns this off. | number | `0` |
| `AUTO_DOWNLOAD_RETRY_FAILED_RELEASE_DAYS` | A release that failed or stalled is kept out of the search for this many days, then it may be picked again. A torrent that stalled can be revived by new seeds, so a failure should not shut a book out for good. A request is still given up on after three failed attempts. | number | `7` |
| `AUTO_DOWNLOAD_BACKOFF_MAX_DAYS` | A request that no source has a match for is searched every cycle at first. After three passes in a row with no match it is searched once a day, then every two days, and at most this often apart. A match, or Sync now, starts it over. 0 searches every unmatched request every cycle. | number | `3` |
| `AUTO_DOWNLOAD_EXISTENCE_CHECK_ENABLED` | An audiobook request that stays unmatched is looked up on Audible (US and UK) through your Audiobookshelf server. Either way it stays pending and its reason says what the store answered (with the ASIN when listed), for you to decline or keep; it is checked again after a week. A lookup that fails decides nothing. Needs the Audiobookshelf URL and token below. | boolean | `false` |
| `AUTO_DOWNLOAD_EXISTENCE_CHECK_AFTER_PASSES` | How many consecutive passes with no strict match a request goes through before the store is asked. A pass that matched, or a reopen, starts the count over. | number | `3` |
| `AUTO_DOWNLOAD_AUDIOBOOK_MIN_SIZE_PERCENT` | A copy of an audiobook smaller than this share of the largest copy found for the same book is probably a lower-bitrate copy. It is tried after the others, and a single m4b that small is not preferred over a fuller set of chapter files. 0 turns the check off. | number | `67` |
<details>
<summary>Detailed descriptions</summary>
#### `HARDCOVER_SYNC_ENABLED`
**Enable scheduled sync**
Periodically sync the selected Hardcover shelves into requests.
- **Type:** boolean
- **Default:** `false`
#### `HARDCOVER_SYNC_TOKEN`
**Hardcover API Token**
Bearer token for your Hardcover account. Leave blank to reuse the token from the Hardcover metadata provider.
- **Type:** string (secret)
- **Default:** _none_
#### `HARDCOVER_SYNC_STATUSES`
**Shelves to sync**
Which Hardcover reading shelf to pull from.
- **Type:** string (choice)
- **Default:** `1`
- **Options:** `1` (Want to Read), `2` (Currently Reading), `3` (Read), `5` (Did Not Finish)
#### `HARDCOVER_SYNC_CONTENT_TYPE`
**Request as**
Content type for synced requests. "Ebooks and audiobooks" creates one request of each per shelf book; every request is checked against its own library and downloaded with its own format guard and source priority.
- **Type:** string (choice)
- **Default:** `audiobook`
- **Options:** `audiobook` (Audiobooks), `ebook` (Ebooks), `both` (Ebooks and audiobooks)
#### `HARDCOVER_SYNC_INTERVAL`
**Sync interval**
How often the scheduled sync runs (minimum 1 minute).
- **Type:** number
- **Default:** `6`
- **Constraints:** min: 1, max: 10000
#### `HARDCOVER_SYNC_INTERVAL_UNIT`
**Interval unit**
Whether the sync interval is measured in minutes or hours.
- **Type:** string (choice)
- **Default:** `hours`
- **Options:** `minutes` (Minutes), `hours` (Hours)
#### `AUTO_DOWNLOAD_ENABLED`
**Enable automatic downloads**
Auto-approve and auto-download matching releases for Hardcover requests. Another user's request is only approved this way where their request policy already lets them download directly; the rest wait for an admin.
- **Type:** boolean
- **Default:** `false`
#### `AUTO_DOWNLOAD_SOURCE_PRIORITY`
**Audiobook source priority**
Drag to set which release sources are tried first for audiobook requests. The first source with a strict match wins. Disabled sources are skipped.
- **Type:** JSON array
- **Default:** _empty list_
#### `AUTO_DOWNLOAD_EBOOK_SOURCE_PRIORITY`
**Ebook source priority**
Drag to set which release sources are tried first for ebook requests. The first source with a strict match wins. Disabled sources are skipped.
- **Type:** JSON array
- **Default:** _empty list_
#### `AUTO_DOWNLOAD_MIN_SEEDERS`
**Minimum seeders (torrents)**
Skip torrent releases with fewer seeders than this.
- **Type:** number
- **Default:** `1`
- **Constraints:** min: 0, max: 1000
#### `AUTO_DOWNLOAD_RETRY_DAYS`
**Retry failed downloads after (days)**
A synced request whose download failed, or was interrupted (a restart, say), goes back to pending after this many days and is tried again, a few per pass. A release that failed is not picked again, and a request is given up on after three failed retries. A download the stall timer cancelled counts as failed; one cancelled by a person, and rejected requests, are never retried. 0 turns retries off.
- **Type:** number
- **Default:** `7`
- **Constraints:** min: 0, max: 365
#### `AUTO_DOWNLOAD_FAILURE_RETRY_MINUTES`
**Try another release after a failed download (minutes)**
A download that failed, or that was given up on (stalled, no seeders), goes back to pending this many minutes later and another release is chosen, without waiting for the retry days above or the next sync cycle. The release that failed is not picked again, and a request is given up on after three failed attempts. 0 turns this off.
- **Type:** number
- **Default:** `0`
- **Constraints:** min: 0, max: 1440
#### `AUTO_DOWNLOAD_RETRY_FAILED_RELEASE_DAYS`
**Try a failed release again after (days)**
A release that failed or stalled is kept out of the search for this many days, then it may be picked again. A torrent that stalled can be revived by new seeds, so a failure should not shut a book out for good. A request is still given up on after three failed attempts.
- **Type:** number
- **Default:** `7`
- **Constraints:** min: 1, max: 365
#### `AUTO_DOWNLOAD_BACKOFF_MAX_DAYS`
**Longest wait between searches for an unmatched request (days)**
A request that no source has a match for is searched every cycle at first. After three passes in a row with no match it is searched once a day, then every two days, and at most this often apart. A match, or Sync now, starts it over. 0 searches every unmatched request every cycle.
- **Type:** number
- **Default:** `3`
- **Constraints:** min: 0, max: 30
#### `AUTO_DOWNLOAD_EXISTENCE_CHECK_ENABLED`
**Mark audiobooks no store sells**
An audiobook request that stays unmatched is looked up on Audible (US and UK) through your Audiobookshelf server. Either way it stays pending and its reason says what the store answered (with the ASIN when listed), for you to decline or keep; it is checked again after a week. A lookup that fails decides nothing. Needs the Audiobookshelf URL and token below.
- **Type:** boolean
- **Default:** `false`
#### `AUTO_DOWNLOAD_EXISTENCE_CHECK_AFTER_PASSES`
**Look the book up after (unmatched passes)**
How many consecutive passes with no strict match a request goes through before the store is asked. A pass that matched, or a reopen, starts the count over.
- **Type:** number
- **Default:** `3`
- **Constraints:** min: 1, max: 100
#### `AUTO_DOWNLOAD_AUDIOBOOK_MIN_SIZE_PERCENT`
**Smallest acceptable audiobook copy (%)**
A copy of an audiobook smaller than this share of the largest copy found for the same book is probably a lower-bitrate copy. It is tried after the others, and a single m4b that small is not preferred over a fuller set of chapter files. 0 turns the check off.
- **Type:** number
- **Default:** `67`
- **Constraints:** min: 0, max: 100
</details>
## Network
| Variable | Description | Type | Default |
@@ -1460,6 +1719,100 @@ The mam_id value MyAnonamouse shows when you create a session. MAM locks each se
</details>
## Jackett
| Variable | Description | Type | Default |
|----------|-------------|------|---------|
| `JACKETT_ENABLED` | Enable searching for books via Jackett indexers | boolean | `false` |
| `JACKETT_URL` | Base URL of your Jackett instance | string | _none_ |
| `JACKETT_API_KEY` | Shown at the top right of the Jackett dashboard | string (secret) | _none_ |
| `JACKETT_INDEXERS` | Select which indexers to search. Leave empty to search all configured indexers. | string (comma-separated) | _empty list_ |
| `JACKETT_INDEXER_TIMEOUT` | How long to wait for Jackett to answer a search. Searching all indexers waits for the slowest one, and indexers behind FlareSolverr can need 90 seconds or more on a cold Cloudflare challenge. | number | `90` |
| `JACKETT_EBOOK_MAX_SIZE_MB` | Hide ebook results larger than this. 0 shows everything, and a result with no reported size is always shown. Audiobook searches are not limited. | number | `0` |
| `JACKETT_AUTO_EXPAND` | Automatically retry search without category filtering if no results are found | boolean | `false` |
| `JACKETT_HIDE_UNSUPPORTED_FORMATS` | Hide a result whose title ends in a file extension that is not in Supported Book Formats, such as a .doc or .rtf file. Titles without an extension, archives and audiobook searches are never hidden. | boolean | `true` |
<details>
<summary>Detailed descriptions</summary>
#### `JACKETT_ENABLED`
**Enable Jackett source**
Enable searching for books via Jackett indexers
- **Type:** boolean
- **Default:** `false`
#### `JACKETT_URL`
**Jackett URL**
Base URL of your Jackett instance
- **Type:** string
- **Default:** _none_
- **Required:** Yes
#### `JACKETT_API_KEY`
**API Key**
Shown at the top right of the Jackett dashboard
- **Type:** string (secret)
- **Default:** _none_
- **Required:** Yes
#### `JACKETT_INDEXERS`
**Indexers to Search**
Select which indexers to search. Leave empty to search all configured indexers.
- **Type:** string (comma-separated)
- **Default:** _empty list_
#### `JACKETT_INDEXER_TIMEOUT`
**Indexer Search Timeout (seconds)**
How long to wait for Jackett to answer a search. Searching all indexers waits for the slowest one, and indexers behind FlareSolverr can need 90 seconds or more on a cold Cloudflare challenge.
- **Type:** number
- **Default:** `90`
- **Constraints:** min: 5, max: 300
#### `JACKETT_EBOOK_MAX_SIZE_MB`
**Maximum Ebook Size (MB)**
Hide ebook results larger than this. 0 shows everything, and a result with no reported size is always shown. Audiobook searches are not limited.
- **Type:** number
- **Default:** `0`
- **Constraints:** min: 0, max: 100000
#### `JACKETT_AUTO_EXPAND`
**Auto-expand search on no results**
Automatically retry search without category filtering if no results are found
- **Type:** boolean
- **Default:** `false`
#### `JACKETT_HIDE_UNSUPPORTED_FORMATS`
**Hide results in formats you did not enable**
Hide a result whose title ends in a file extension that is not in Supported Book Formats, such as a .doc or .rtf file. Titles without an extension, archives and audiobook searches are never hidden.
- **Type:** boolean
- **Default:** `true`
</details>
## Newznab
| Variable | Description | Type | Default |
@@ -1551,9 +1904,12 @@ Automatically retry search without category filtering if no results are found
| `SLSKD_ISOLATE_DOWNLOADS` | Download into a folder of its own inside slskd's downloads folder (needs slskd 0.26 or newer). Other apps using the same slskd cannot mix files with it or lose track of it when they clear slskd's list of finished transfers, and Shelfmark confirms a download by the files on disk. | boolean | `true` |
| `SLSKD_DESTINATION_PREFIX` | The folder, inside slskd's downloads folder, that holds one folder per download. It must not climb out of slskd's downloads folder. | string | `shelfmark` |
| `SLSKD_KEEP_COMPLETED` | Leave the finished files (and their transfer records) in slskd after Shelfmark copies them to your library, so they stay shared with other Soulseek users. Turn off to delete them once imported. | boolean | `true` |
| `SLSKD_FINISH_LEFTOVERS` | A restart can land after the last file arrives and before it is copied to your library. Checked every 15 minutes, independent of the Hardcover sync, a download folder that holds every file asked for, whose download never completed and is still wanted, is imported once without downloading anything again (never one someone cancelled). If that import fails, the folder stays until you retry the download, or, with clean-up on, until it is a day old and its request has the book or was given up on. A transfer Shelfmark queued that no download is watching any more is picked up again if its request still needs it, a few per check, and cancelled otherwise, or when it cannot be picked up. Transfers queued by other apps or by hand in slskd are never touched, including the same file queued again after Shelfmark's download of it ended. | boolean | `true` |
| `SLSKD_CLEAN_LEFTOVERS_DAYS` | Remove the folder of a failed or cancelled download, or one nothing records, once the download is this old, or one day old when its request already has the book or was given up on. A folder that holds every file asked for is never removed while the download is still wanted (you started it yourself, or its request is still open): it is imported once, and after that it waits for you to retry the download. The folder of a running download is never removed. 0 keeps every leftover folder. Folders already imported follow Keep finished files in slskd, not this setting. | number | `0` |
| `SLSKD_SEARCH_TIMEOUT` | How long a Soulseek search collects peer responses. Longer waits find more peers but make every search slower. | number | `15` |
| `SLSKD_RESPONSE_LIMIT` | Stop a search early once this many peers have answered | number | `100` |
| `SLSKD_QUEUE_TIMEOUT_MINUTES` | Give up when the peer has not started sending within this long. Set to 0 to wait indefinitely. | number | `60` |
| `SLSKD_CHAT_AUTOPROVE` | Some uploaders block downloads until you type a word in a private message ("please type 'download' in this chat"). Shelfmark replies with the word the message asks for, at most once per user every 12 hours, and logs each reply. It reads every unread private message in slskd, not only those from the peer of a running download, and marks a conversation it answered as read in slskd. Checked while a download is running. | boolean | `false` |
| `SLSKD_REMOVE_COMPLETED` | Clear the transfer from slskd's download list after Shelfmark imports it. Applies when each download does not get its own folder. | boolean | `true` |
<details>
@@ -1622,6 +1978,25 @@ Leave the finished files (and their transfer records) in slskd after Shelfmark c
- **Type:** boolean
- **Default:** `true`
#### `SLSKD_FINISH_LEFTOVERS`
**Import finished folders an interruption left behind**
A restart can land after the last file arrives and before it is copied to your library. Checked every 15 minutes, independent of the Hardcover sync, a download folder that holds every file asked for, whose download never completed and is still wanted, is imported once without downloading anything again (never one someone cancelled). If that import fails, the folder stays until you retry the download, or, with clean-up on, until it is a day old and its request has the book or was given up on. A transfer Shelfmark queued that no download is watching any more is picked up again if its request still needs it, a few per check, and cancelled otherwise, or when it cannot be picked up. Transfers queued by other apps or by hand in slskd are never touched, including the same file queued again after Shelfmark's download of it ended.
- **Type:** boolean
- **Default:** `true`
#### `SLSKD_CLEAN_LEFTOVERS_DAYS`
**Remove leftover download folders after (days)**
Remove the folder of a failed or cancelled download, or one nothing records, once the download is this old, or one day old when its request already has the book or was given up on. A folder that holds every file asked for is never removed while the download is still wanted (you started it yourself, or its request is still open): it is imported once, and after that it waits for you to retry the download. The folder of a running download is never removed. 0 keeps every leftover folder. Folders already imported follow Keep finished files in slskd, not this setting.
- **Type:** number
- **Default:** `0`
- **Constraints:** min: 0, max: 365
#### `SLSKD_SEARCH_TIMEOUT`
**Search Wait (seconds)**
@@ -1652,6 +2027,15 @@ Give up when the peer has not started sending within this long. Set to 0 to wait
- **Default:** `60`
- **Constraints:** min: 0, max: 1440
#### `SLSKD_CHAT_AUTOPROVE`
**Answer anti-leech chat challenges**
Some uploaders block downloads until you type a word in a private message ("please type 'download' in this chat"). Shelfmark replies with the word the message asks for, at most once per user every 12 hours, and logs each reply. It reads every unread private message in slskd, not only those from the peer of a running download, and marks a conversation it answered as read in slskd. Checked while a download is running.
- **Type:** boolean
- **Default:** `false`
#### `SLSKD_REMOVE_COMPLETED`
**Remove completed transfers from slskd**
@@ -1779,6 +2163,59 @@ Maximum number of results to request per search (1-100).
</details>
## Webshare.cz
| Variable | Description | Type | Default |
|----------|-------------|------|---------|
| `WEBSHARE_ENABLED` | Search Webshare.cz by file name and download matching ebooks and audiobooks. Requires a Webshare account; a VIP account is recommended for full download speed. | boolean | `false` |
| `WEBSHARE_USERNAME` | Your Webshare.cz login. | string | _none_ |
| `WEBSHARE_PASSWORD` | Your Webshare.cz password. | string (secret) | _none_ |
| `WEBSHARE_MAX_RESULTS` | Maximum number of files to request per search (1-200). Results are then filtered to your supported ebook or audiobook formats. | number | `50` |
<details>
<summary>Detailed descriptions</summary>
#### `WEBSHARE_ENABLED`
**Enable Webshare.cz**
Search Webshare.cz by file name and download matching ebooks and audiobooks. Requires a Webshare account; a VIP account is recommended for full download speed.
- **Type:** boolean
- **Default:** `false`
#### `WEBSHARE_USERNAME`
**Username or Email**
Your Webshare.cz login.
- **Type:** string
- **Default:** _none_
- **Required:** Yes
#### `WEBSHARE_PASSWORD`
**Password**
Your Webshare.cz password.
- **Type:** string (secret)
- **Default:** _none_
- **Required:** Yes
#### `WEBSHARE_MAX_RESULTS`
**Max Results**
Maximum number of files to request per search (1-200). Results are then filtered to your supported ebook or audiobook formats.
- **Type:** number
- **Default:** `50`
- **Constraints:** min: 1, max: 200
</details>
## IRC
| Variable | Description | Type | Default |
@@ -1894,49 +2331,50 @@ How long to keep cached search results before they expire.
| `DEBRIDLINK_API_KEY` | Debrid-Link API Key from your Debrid-Link 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_DOWNLOAD_DIR_BOOK` | Server-side directory where book torrents are downloaded (optional, uses qBittorrent default if not specified) | string | _none_ |
| `QBITTORRENT_DOWNLOAD_DIR_AUDIOBOOK` | Server-side directory where audiobook torrents are downloaded (optional, uses qBittorrent default if not specified) | string | _none_ |
| `QBITTORRENT_URL` | Web UI URL of your qBittorrent instance | string | _none_ |
| `QBITTORRENT_USERNAME` | qBittorrent Web UI username | string | _none_ |
| `QBITTORRENT_PASSWORD` | qBittorrent Web UI password | string (secret) | _none_ |
| `QBITTORRENT_API_KEY` | Found in qBittorrent: Options > Web UI > API Key (qBittorrent 5.2.0+). Used instead of the username and password when set. | string (secret) | _none_ |
| `QBITTORRENT_CATEGORY` | Category to assign to book downloads in qBittorrent | string | `books` |
| `QBITTORRENT_CATEGORY_AUDIOBOOK` | Category for audiobook downloads. Leave empty to use the book category. | string | _empty string_ |
| `TRANSMISSION_SEPARATE_DOWNLOAD_DIRS` | Download books and audiobooks to different directories instead of one shared directory. | boolean | `false` |
| `QBITTORRENT_SEPARATE_DOWNLOAD_DIRS` | Download books and audiobooks to different directories instead of one shared directory. | boolean | `false` |
| `TRANSMISSION_DOWNLOAD_DIR_BOOK` | Server-side directory where book torrents are downloaded (optional, uses Transmission default if not specified) | string | _none_ |
| `TRANSMISSION_DOWNLOAD_DIR_AUDIOBOOK` | Server-side directory where audiobook torrents are downloaded (optional, uses Transmission default if not specified) | string | _none_ |
| `QBITTORRENT_DOWNLOAD_DIR` | Server-side directory where torrents are downloaded (optional, uses qBittorrent default if not specified) | string | _none_ |
| `QBITTORRENT_DOWNLOAD_DIR_BOOK` | Server-side directory where book torrents are downloaded (optional, uses qBittorrent default if not specified) | string | _none_ |
| `QBITTORRENT_DOWNLOAD_DIR_AUDIOBOOK` | Server-side directory where audiobook torrents are downloaded (optional, uses qBittorrent default if not specified) | string | _none_ |
| `QBITTORRENT_TAG` | Tag(s) to assign to qBittorrent downloads. Leave empty for no tags. | string (comma-separated) | _empty list_ |
| `TRANSMISSION_URL` | URL of your Transmission instance (use https:// for TLS) | string | _none_ |
| `TRANSMISSION_URL` | URL of your Transmission instance (use https:// for TLS). Without a port, https uses 443 and http uses 9091, so add :9091 to an https URL served on 9091 | string | _none_ |
| `TRANSMISSION_USERNAME` | Transmission RPC username (if authentication enabled) | string | _none_ |
| `TRANSMISSION_PASSWORD` | Transmission RPC password | string (secret) | _none_ |
| `DELUGE_SEPARATE_DOWNLOAD_DIRS` | Download books and audiobooks to different directories instead of one shared directory. | boolean | `false` |
| `TRANSMISSION_CATEGORY` | Label to assign to book downloads in Transmission | string | `books` |
| `DELUGE_DOWNLOAD_DIR_BOOK` | Server-side directory where book torrents are downloaded (optional, uses Deluge default if not specified) | string | _none_ |
| `DELUGE_DOWNLOAD_DIR_AUDIOBOOK` | Server-side directory where audiobook torrents are downloaded (optional, uses Deluge default if not specified) | string | _none_ |
| `TRANSMISSION_CATEGORY_AUDIOBOOK` | Label for audiobook downloads. Leave empty to use the book label. | string | _empty string_ |
| `TRANSMISSION_SEPARATE_DOWNLOAD_DIRS` | Download books and audiobooks to different directories instead of one shared directory. | boolean | `false` |
| `TRANSMISSION_DOWNLOAD_DIR` | Server-side directory where torrents are downloaded (optional, uses Transmission default if not specified) | string | _none_ |
| `TRANSMISSION_DOWNLOAD_DIR_BOOK` | Server-side directory where book torrents are downloaded (optional, uses Transmission default if not specified) | string | _none_ |
| `TRANSMISSION_DOWNLOAD_DIR_AUDIOBOOK` | Server-side directory where audiobook torrents are downloaded (optional, uses Transmission default if not specified) | string | _none_ |
| `DELUGE_HOST` | Hostname/IP or full URL of your Deluge Web UI (deluge-web) | string | `localhost` |
| `DELUGE_PORT` | Deluge Web UI port (default: 8112) | string | `8112` |
| `DELUGE_PASSWORD` | Deluge Web UI password (default: deluge) | string (secret) | _none_ |
| `RTORRENT_SEPARATE_DOWNLOAD_DIRS` | Download books and audiobooks to different directories instead of one shared directory. | boolean | `false` |
| `DELUGE_CATEGORY` | Label to assign to book downloads in Deluge | string | `books` |
| `RTORRENT_DOWNLOAD_DIR_BOOK` | Server-side directory where book torrents are downloaded (optional, uses rTorrent default if not specified) | string | _none_ |
| `RTORRENT_DOWNLOAD_DIR_AUDIOBOOK` | Server-side directory where audiobook torrents are downloaded (optional, uses rTorrent default if not specified) | string | _none_ |
| `DELUGE_CATEGORY_AUDIOBOOK` | Label for audiobook downloads. Leave empty to use the book label. | string | _empty string_ |
| `DELUGE_SEPARATE_DOWNLOAD_DIRS` | Download books and audiobooks to different directories instead of one shared directory. | boolean | `false` |
| `DELUGE_DOWNLOAD_DIR` | Server-side directory where torrents are downloaded (optional, uses Deluge default if not specified) | string | _none_ |
| `DELUGE_DOWNLOAD_DIR_BOOK` | Server-side directory where book torrents are downloaded (optional, uses Deluge default if not specified) | string | _none_ |
| `DELUGE_DOWNLOAD_DIR_AUDIOBOOK` | Server-side directory where audiobook torrents are downloaded (optional, uses Deluge default if not specified) | string | _none_ |
| `RTORRENT_URL` | XML-RPC URL of your rTorrent instance | string | _none_ |
| `RTORRENT_USERNAME` | HTTP Basic auth username (if authentication enabled) | string | _none_ |
| `RTORRENT_PASSWORD` | HTTP Basic auth password | string (secret) | _none_ |
| `RTORRENT_LABEL` | Label to assign to ebook downloads in rTorrent | string | `cwabd` |
| `RTORRENT_AUDIOBOOK_LABEL` | Label to assign to audiobook downloads in rTorrent (falls back to Book Label if not set) | string | _none_ |
| `RTORRENT_SEPARATE_DOWNLOAD_DIRS` | Download books and audiobooks to different directories instead of one shared directory. | boolean | `false` |
| `RTORRENT_DOWNLOAD_DIR` | Server-side directory where torrents are downloaded (optional, uses rTorrent default if not specified) | string | _none_ |
| `RTORRENT_DOWNLOAD_DIR_BOOK` | Server-side directory where book torrents are downloaded (optional, uses rTorrent default if not specified) | string | _none_ |
| `RTORRENT_DOWNLOAD_DIR_AUDIOBOOK` | Server-side directory where audiobook torrents are downloaded (optional, uses rTorrent default if not specified) | string | _none_ |
| `PROWLARR_TORRENT_ACTION` | After a successful import, Remove keeps downloaded files in qBittorrent, Transmission, and Deluge; Remove & Delete Files also deletes them. rTorrent cannot delete download data, so this action leaves its torrent untouched. Blackhole does not support removal. Debrid clients delete temporary local files for either Remove action. | string (choice) | `keep` |
| `TORRENT_NO_SEEDERS_WAIT` | Give up on a torrent when its client sees no seed at all for this long, even while it is still downloading, or fewer seeds than the minimum below while nothing is downloading. The torrent is removed and the download marked failed, so another release can be tried. The card shows the seed count and a countdown while it waits. 0 keeps the default behaviour (only the stall timer ends it). Only qBittorrent reports seed counts. | number | `0` |
| `TORRENT_MIN_SEEDERS` | Seeds the client must see for a motionless torrent to keep waiting. 0 also turns the no-seed check off. | number | `1` |
| `TORRENT_MIN_SEEDERS` | Seeds the client must see for a motionless torrent to keep waiting. 0 also turns the no-seed check off. Only qBittorrent reports seed counts. | number | `1` |
| `PROWLARR_TORRENT_POST_IMPORT_CATEGORY` | Category or label to assign after a successful import | string | _empty string_ |
| `TORRENT_STALL_TIMEOUT_MINUTES` | How long a torrent can go without any progress before Shelfmark cancels it. Applies from when the torrent starts and restarts each time it moves. Raise it for slow swarms or magnets that take a while to find peers, or lower it to give up on dead torrents sooner. | number | `15` |
| `STALE_TORRENT_HOURS` | Remove a torrent Shelfmark added when it is stalled or cannot fetch its metadata, has had no activity for this many hours, and no running download owns it. Its partial data goes with it. Only torrents Shelfmark itself added are ever considered, whatever category they are in; torrents still downloading, queued, or finished are never touched. 3 to 72 hours (a value outside that is held to it); 0 keeps them. Checked every 15 minutes, independent of the Hardcover sync; only qBittorrent is supported. | number | `6` |
| `PROWLARR_USENET_CLIENT` | Choose which usenet client to use | string (choice) | _empty string_ |
| `NZBGET_URL` | URL of your NZBGet instance | string | _none_ |
| `NZBGET_USERNAME` | NZBGet control username | string | `nzbget` |
@@ -2053,6 +2491,14 @@ Category to assign to book downloads in qBittorrent
- **Default:** `books`
#### `QBITTORRENT_CATEGORY_AUDIOBOOK`
**Audiobook Category**
Category for audiobook downloads. Leave empty to use the book category.
- **Type:** string
- **Default:** _empty string_
#### `QBITTORRENT_SEPARATE_DOWNLOAD_DIRS`
**Separate Book and Audiobook Directories**
@@ -2062,16 +2508,7 @@ Download books and audiobooks to different directories instead of one shared dir
- **Type:** boolean
- **Default:** `false`
**Audiobook Category**
Category for audiobook downloads. Leave empty to use the book category.
- **Type:** string
- **Default:** _empty string_
#### `QBITTORRENT_DOWNLOAD_DIR`
#### `QBITTORRENT_DOWNLOAD_DIR_BOOK`
**Download Directory**
@@ -2080,6 +2517,7 @@ Server-side directory where torrents are downloaded (optional, uses qBittorrent
- **Type:** string
- **Default:** _none_
#### `QBITTORRENT_DOWNLOAD_DIR_BOOK`
**Book Download Directory**
@@ -2110,7 +2548,7 @@ Tag(s) to assign to qBittorrent downloads. Leave empty for no tags.
**Transmission URL**
URL of your Transmission instance (use https:// for TLS)
URL of your Transmission instance (use https:// for TLS). Without a port, https uses 443 and http uses 9091, so add :9091 to an https URL served on 9091
- **Type:** string
- **Default:** _none_
@@ -2143,6 +2581,14 @@ Label to assign to book downloads in Transmission
- **Default:** `books`
#### `TRANSMISSION_CATEGORY_AUDIOBOOK`
**Audiobook Label**
Label for audiobook downloads. Leave empty to use the book label.
- **Type:** string
- **Default:** _empty string_
#### `TRANSMISSION_SEPARATE_DOWNLOAD_DIRS`
**Separate Book and Audiobook Directories**
@@ -2152,16 +2598,7 @@ Download books and audiobooks to different directories instead of one shared dir
- **Type:** boolean
- **Default:** `false`
**Audiobook Label**
Label for audiobook downloads. Leave empty to use the book label.
- **Type:** string
- **Default:** _empty string_
#### `TRANSMISSION_DOWNLOAD_DIR`
#### `TRANSMISSION_DOWNLOAD_DIR_BOOK`
**Download Directory**
@@ -2170,6 +2607,7 @@ Server-side directory where torrents are downloaded (optional, uses Transmission
- **Type:** string
- **Default:** _none_
#### `TRANSMISSION_DOWNLOAD_DIR_BOOK`
**Book Download Directory**
@@ -2224,6 +2662,14 @@ Label to assign to book downloads in Deluge
- **Default:** `books`
#### `DELUGE_CATEGORY_AUDIOBOOK`
**Audiobook Label**
Label for audiobook downloads. Leave empty to use the book label.
- **Type:** string
- **Default:** _empty string_
#### `DELUGE_SEPARATE_DOWNLOAD_DIRS`
**Separate Book and Audiobook Directories**
@@ -2233,16 +2679,7 @@ Download books and audiobooks to different directories instead of one shared dir
- **Type:** boolean
- **Default:** `false`
**Audiobook Label**
Label for audiobook downloads. Leave empty to use the book label.
- **Type:** string
- **Default:** _empty string_
#### `DELUGE_DOWNLOAD_DIR`
#### `DELUGE_DOWNLOAD_DIR_BOOK`
**Download Directory**
@@ -2251,6 +2688,7 @@ Server-side directory where torrents are downloaded (optional, uses Deluge defau
- **Type:** string
- **Default:** _none_
#### `DELUGE_DOWNLOAD_DIR_BOOK`
**Book Download Directory**
@@ -2305,6 +2743,14 @@ Label to assign to ebook downloads in rTorrent
- **Default:** `cwabd`
#### `RTORRENT_AUDIOBOOK_LABEL`
**Audiobook Label**
Label to assign to audiobook downloads in rTorrent (falls back to Book Label if not set)
- **Type:** string
- **Default:** _none_
#### `RTORRENT_SEPARATE_DOWNLOAD_DIRS`
**Separate Book and Audiobook Directories**
@@ -2314,16 +2760,7 @@ Download books and audiobooks to different directories instead of one shared dir
- **Type:** boolean
- **Default:** `false`
**Audiobook Label**
Label to assign to audiobook downloads in rTorrent (falls back to Book Label if not set)
- **Type:** string
- **Default:** _none_
#### `RTORRENT_DOWNLOAD_DIR`
#### `RTORRENT_DOWNLOAD_DIR_BOOK`
**Download Directory**
@@ -2332,6 +2769,7 @@ Server-side directory where torrents are downloaded (optional, uses rTorrent def
- **Type:** string
- **Default:** _none_
#### `RTORRENT_DOWNLOAD_DIR_BOOK`
**Book Download Directory**
@@ -2373,7 +2811,7 @@ Give up on a torrent when its client sees no seed at all for this long, even whi
**Minimum Seeders**
Seeds the client must see for a motionless torrent to keep waiting. 0 also turns the no-seed check off.
Seeds the client must see for a motionless torrent to keep waiting. 0 also turns the no-seed check off. Only qBittorrent reports seed counts.
- **Type:** number
- **Default:** `1`
@@ -2398,6 +2836,16 @@ How long a torrent can go without any progress before Shelfmark cancels it. Appl
- **Default:** `15`
- **Constraints:** min: 5, max: 60
#### `STALE_TORRENT_HOURS`
**Remove dead torrents after (hours)**
Remove a torrent Shelfmark added when it is stalled or cannot fetch its metadata, has had no activity for this many hours, and no running download owns it. Its partial data goes with it. Only torrents Shelfmark itself added are ever considered, whatever category they are in; torrents still downloading, queued, or finished are never touched. 3 to 72 hours (a value outside that is held to it); 0 keeps them. Checked every 15 minutes, independent of the Hardcover sync; only qBittorrent is supported.
- **Type:** number
- **Default:** `6`
- **Constraints:** min: 0, max: 72
#### `PROWLARR_USENET_CLIENT`
**Usenet Client**
@@ -2660,6 +3108,14 @@ Default sort order for Google Books search results.
#### `MOLY_ENABLED`
**Enable Moly.hu**
Enable Moly.hu as a metadata provider for book searches
- **Type:** boolean
- **Default:** `false`
</details>
### Metadata Providers: Databáze knih
| Variable | Description | Type | Default |
@@ -2680,12 +3136,45 @@ Enable Databáze knih as a metadata provider for book searches
</details>
### Metadata Providers: DNB
Enable Moly.hu as a metadata provider for book searches
| Variable | Description | Type | Default |
|----------|-------------|------|---------|
| `DNB_ENABLED` | Enable DNB as a metadata provider for book searches | boolean | `false` |
| `DNB_DEFAULT_SORT` | Default sort order for DNB search results. | string (choice) | `relevance` |
| `DNB_USE_BOOK_LANGUAGES` | Filter DNB results by Default Book Languages: each user's own setting, else the system default. DNB also catalogs translations into many other languages. | boolean | `true` |
<details>
<summary>Detailed descriptions</summary>
#### `DNB_ENABLED`
**Enable DNB**
Enable DNB as a metadata provider for book searches
- **Type:** boolean
- **Default:** `false`
#### `DNB_DEFAULT_SORT`
**Default Sort Order**
Default sort order for DNB search results.
- **Type:** string (choice)
- **Default:** `relevance`
- **Options:** `relevance` (Most relevant), `newest` (Newest), `oldest` (Oldest)
#### `DNB_USE_BOOK_LANGUAGES`
**Only show editions in my book languages**
Filter DNB results by Default Book Languages: each user's own setting, else the system default. DNB also catalogs translations into many other languages.
- **Type:** boolean
- **Default:** `true`
</details>
## Direct Download
+117
View File
@@ -0,0 +1,117 @@
# Hardcover Sync
Configure in **Settings → Hardcover Sync**.
Hardcover Sync pulls books from your [Hardcover](https://hardcover.app) reading shelves into Shelfmark as requests, optionally downloads them without an admin picking a release, and can skip books you already own in a library you have set up under [Library Check](library-check.md). Every part is opt-in and off by default; the tab does nothing until you enable it.
## Capabilities
### Shelf sync
On a schedule, Shelfmark reads the selected Hardcover shelf (by default **Want to Read**) through the Hardcover metadata provider and creates a **pending** request for each book that is not already known. Requests carry Hardcover's metadata, including the cover, and are owned by the lowest-id admin account.
A book is skipped when:
- a request with the same Hardcover book id already exists, in any status
- a request for the same Hardcover book and format, by any user, has a download in the history that finished or is still running (an unfinished download older than a day no longer counts as running)
- the library check (below) says you already own it
- a pending request with the same title, author, and content type already exists
### Automatic downloads
When enabled, each pending request that came from Hardcover is searched across your release sources in the priority order configured for its content type (audiobook requests use the audiobook list, ebook requests the ebook list). The first source that yields a **strict match** wins, and the best candidate from that source is queued through the normal request fulfilment path, so it appears in the activity view and download history exactly like a request an admin approved.
Automatic downloads act for an admin, so they only approve what the request's owner could have downloaded without asking. Synced requests belong to an admin and are always searched. A request another user made is searched only on the release sources where their request policy (including any per-user override) resolves to **Download**, and only while requests are turned on for them; with requests off they download directly anyway. A request whose policy needs approval, or blocks that content type, is left pending for an admin, untouched, like any other request awaiting review. A failed download is only reopened for a retry on the same terms.
A release is a strict match only when all of the following hold:
- at least 85% of the significant words in the book's title appear in the release title. Numbers from 1 to 20 count as the same word however they are written, so *Part II*, *Part Two* and *Part 2* agree, while *Part One* never stands in for *Part Two*;
- the author's surname appears in the release title, indexer name or author field (a book with no author metadata matches on title alone). Some AudiobookBay listings carry no author in their title (*The Mummy or Ramses the Damned*). When a source turns up no strict match, the detail pages of up to three such releases that pass every other rule are read, and the author they credit (a narrator does not count) is checked like any other. A page that credits nobody, or someone else, leaves the release rejected;
- the release title doesn't name a different book that contains the requested one. A "Dune" request won't take *Dune Messiah* or *Children of Dune*, and a bare number after the title (*Mistborn 2*, *Mistborn Two*, *Dune Chronicles Book II*) names a sequel. A title with a number of its own (*Ready Player One*) takes no other number after it, 1 included. Only the parts of the release name that hold the title are judged, so narrators, uploader tags and the book's own series, author and subtitle words don't count against it; a part that is just the series name (*Alex Cross 25*) does not vouch for a one-word title, so a "Cross" request won't take *Alex Cross: Cross Justice*. Two slash-separated names that both hold the title (*Dune / Dune Messiah*) are two books;
- the release is not a derivative of the work: *summary*, *analysis*, *study guide*, *workbook*, *abridged*, *dramatized*, *adaptation* or *graphic novel* anywhere in the name disqualifies it, unless the requested title itself carries the word. *Unabridged* is a full recording and is fine;
- the release is not a multi-book pack: ranges like `1-24` or `Books 1 to 17`, or words like *complete*, *collection*, *box set*, *omnibus*, *trilogy*, *megapack*, *+ short stories*, unless the requested title itself carries them. "Complete and Unabridged" describes one recording and doesn't count. The library check uses the same word list;
- the format fits the request — an audiobook request needs an audiobook signal (`m4b`/`mp3`, a release from an audiobook-only source, or "audiobook"/"unabridged" in the title) and is never an ebook-only file; an ebook request needs one of your **Supported Book Formats** (or an `.epub`-style marker in the title) and no audiobook signal;
- for torrents that report a seeder count, it meets **Minimum seeders** — sources that report none (AudiobookBay) are not judged on seeders.
AudiobookBay requires every word of a query, and its posts for one volume of a multi-part book often spell the part differently from the request ("Part Two" for "Part II"), so a query carrying the part finds only the packs that list it. When no row a query returns carries the requested title, the source tries the next query, ending with the title minus its trailing *, Part N* or *Volume N*. The strict match above still decides which part was found.
When no source yields a strict match for the request's own search title, up to three simpler titles are tried in turn, each across the sources in priority order and all within the request's search budget: the title without a subtitle after `:` or ` - `, the title without an alternate title (*The Hobbit, or There and Back Again* becomes *The Hobbit*), and the title with its numbers written as digits (*Part II* becomes *Part 2*). The first one that yields a strict match wins, and the log names it. A simpler title is matched by exactly the rules above, so a release has to name the shorter title, the right author, and no sequel, edition, derivative or pack. The subtitle is only dropped when the book's metadata says it is one (a series name is known, or the book's subtitle is what follows the separator), and never when what is left is only the series or author name: *Star Wars: Thrawn* is not searched as *Star Wars*.
Among matching releases from the winning source, format ranks first (`m4b` over `mp3` for audiobooks; `epub` over `azw3` over `mobi` for ebooks), then the copy other people chose — more downloads (direct-download sources report them), then more seeders — then larger size. The exception is an audiobook copy far smaller than the others for the same book, which is probably a lower-bitrate copy: it is tried after the others (see **Smallest acceptable audiobook copy** below). If no source produces a confident match the request stays **pending** for manual review; nothing is guessed.
A request whose book turns out to be in your library already is closed, not left pending: it is marked fulfilled with the admin note `[auto] Already in the library.`, so it stops being looked up every pass. Before this it sat in Needs Review indefinitely. **Clear Completed** clears these too.
Hardcover allows a short burst of lookups and then answers 429. Each pass paces its book lookups and backs off when told to, following `Retry-After`. A lookup that still fails counts as an error for that request, not as "book not found", and the request is tried again next pass.
A download that fails, or is interrupted (a restart while it was queued, say), does not stay that way forever. Once the request has sat there for **Retry failed downloads after (days)** (7 by default), it goes back to pending and the next pass searches again. Each pass reopens at most five, oldest first, so a backlog is spread out instead of hitting your sources at once. An interrupted download may pick the same release again, since a torrent already in your client is joined instead of added twice. A download that itself failed, or that Shelfmark's stall timer cancelled, has its release remembered and skipped next time (not when the download client could not be reached or refused Shelfmark's login: that says nothing about the release), and the request is given up on after three failed retries. Requests you or an admin cancelled are never retried, and neither are rejected ones: both are decisions. Set the value to 0 to turn retries off.
A request that no source has a match for is not searched every cycle for ever. After three passes in a row with no match it is searched once a day, then every two days, and no further apart than **Longest wait between searches for an unmatched request (days)** (3 by default, 0 searches every unmatched request every cycle). A match, a search that failed (which says nothing about the book) or **Sync now** starts it over.
**Trying another release sooner.** Set **Try another release after a failed download (minutes)** (off by default) and a download that fails, or that is given up on (stalled, or no seeders when **Give Up With No Seeders** is on), does not wait for the days above. Between sync cycles Shelfmark looks for such requests, reopens them after that many minutes and searches again, for those requests only. The release that failed is skipped, and a request is still given up on after three failed attempts.
**Sources known to be dead are tried last.** When choosing among matching releases, a Soulseek peer whose transfers failed in the last three days and who delivered nothing in that time comes after the others. AudiobookBay shows no seed counts, so the comments on its five best posts are read, and a post where comments say it has no seeders comes after the others. Nothing is excluded: if a dead-looking release is the only match, it is still tried.
### Audiobooks no store sells
Some requested books were never recorded, so no source will ever match them and the request waits in Needs Review for good. With **Mark audiobooks no store sells** on, an audiobook request that has gone **Look the book up after (unmatched passes)** consecutive passes (3 by default) without a strict match is looked up on Audible's US and UK stores, through the metadata search of the Audiobookshelf server set up for the library check (its URL and token are needed; the library check itself can stay off).
- **On neither store:** the request stays pending and its failure reason reads `Not found on Audible (US/UK) after <N> searches, <date>`. It is not declined: Audible search misses real recordings (novellas sold only in a collection, books retitled before release, older or fan recordings), so the call is left to you. The store is asked again a week later.
- **On a store:** the request stays pending and its failure reason reads `Exists on Audible (<ASIN>), not on our sources yet`. The store is asked again a week later, not every pass.
- **Lookup failed:** nothing changes and the next pass asks again. A failed lookup never marks a request. Audiobookshelf answers a failed Audible search with an empty list, which looks like "no such book", so before marking a request as not found Shelfmark also looks up a book both stores are known to sell (*The Hobbit*). If that comes back missing too, the answer is treated as a failure.
A store result counts as the book when its title matches the request's once both are normalised (accents, punctuation, "Part II"/"Part Two"/"Part 2", a subtitle after `:` or `, or`, bracketed notes like "(Unabridged)" and a leading series label are all ignored, and a title that begins the other with at least two words also counts) and the request author's surname is in its author field. The matching leans towards "found": a wrong "found" only leaves the request pending. The pass counter is reset when a source matches the book and when the request is rejected, so a reopened request counts afresh. Store lookups come out of the request's search time; when too little is left, the lookup waits for the next pass. Ebook requests are not checked.
### Library check
Both shelf sync and automatic downloads ask the libraries you have enabled under [Library Check](library-check.md) whether the book is already there, for the content type being requested, and skip it if so. There is no separate switch on this tab. A library that is not enabled, or that does not hold the requested content type, never causes a skip.
The check **fails closed** here: if an enabled library cannot be read and has not been read before, the sync cycle is skipped and an automatic download is held, and the request stays pending until the next cycle. Skipping is the safe side, because carrying on would request or download books the library already holds. Badges in search results still fail open.
## Settings
| Key | Label | Default | Notes |
|-----|-------|---------|-------|
| `HARDCOVER_SYNC_ENABLED` | Enable scheduled sync | Off | Runs shelf sync on the interval below |
| `HARDCOVER_SYNC_TOKEN` | Hardcover API Token | — | Leave blank to reuse the Hardcover metadata provider's token |
| `HARDCOVER_SYNC_STATUSES` | Shelves to sync | Want to Read | Hardcover reading status to pull from |
| `HARDCOVER_SYNC_CONTENT_TYPE` | Request as | Audiobooks | `Audiobooks`, `Ebooks`, or `Ebooks and audiobooks` — the last creates one request of each per shelf book, each checked against its own library |
| `HARDCOVER_SYNC_INTERVAL` | Sync interval | 6 | Combined with the unit below |
| `HARDCOVER_SYNC_INTERVAL_UNIT` | Interval unit | Hours | Minutes or hours. The effective interval never drops below 60 seconds |
| `AUTO_DOWNLOAD_ENABLED` | Enable automatic downloads | Off | Auto-approve and queue strict matches for pending Hardcover requests: synced ones, and a user's own only on the sources where their request policy already lets them download directly; the rest wait for an admin. The download belongs to the user who asked, and admins see every user's requests |
| `AUTO_DOWNLOAD_SOURCE_PRIORITY` | Audiobook source priority | All usable sources | Drag to order; used for audiobook requests. Sources you disable here, or that are not configured, are skipped |
| `AUTO_DOWNLOAD_EBOOK_SOURCE_PRIORITY` | Ebook source priority | All usable sources | Same, for ebook requests |
| `AUTO_DOWNLOAD_MIN_SEEDERS` | Minimum seeders (torrents) | 1 | Torrent releases below this are ignored |
| `AUTO_DOWNLOAD_AUDIOBOOK_MIN_SIZE_PERCENT` | Smallest acceptable audiobook copy (%) | 67 | A copy smaller than this share of the largest copy found for the same book is tried after the others, and a single m4b that small is not preferred over a fuller set of chapter files. 0 turns the check off |
| `AUTO_DOWNLOAD_RETRY_DAYS` | Retry failed downloads after (days) | 7 | A failed or interrupted download goes back to pending after this long. 0 turns retries off |
| `AUTO_DOWNLOAD_BACKOFF_MAX_DAYS` | Longest wait between searches for an unmatched request (days) | 3 | 0 searches every unmatched request every cycle |
| `AUTO_DOWNLOAD_FAILURE_RETRY_MINUTES` | Try another release after a failed download (minutes) | 0 (off) | Reopens failed or given-up downloads and picks another release this long after, between sync cycles |
| `AUTO_DOWNLOAD_RETRY_FAILED_RELEASE_DAYS` | Try a failed release again after (days) | 7 | A release that failed or stalled is kept out of the search for this long, then may be picked again; a request is still given up on after three failed attempts |
| `AUTO_DOWNLOAD_EXISTENCE_CHECK_ENABLED` | Mark audiobooks no store sells | Off | See [Audiobooks no store sells](#audiobooks-no-store-sells). Needs the Audiobookshelf URL and token |
| `AUTO_DOWNLOAD_EXISTENCE_CHECK_AFTER_PASSES` | Look the book up after (unmatched passes) | 3 | Consecutive passes without a strict match before the stores are asked |
One action button sits at the bottom of the tab:
- **Sync now** runs a sync and automatic-download pass immediately using the saved settings. Sync runs even when scheduled sync is off; automatic downloads still require their own toggle.
## Request Flow
1. Shelf sync creates a **pending** request per new Hardcover book, with cover, year, and subtitle where Hardcover has them.
2. If automatic downloads are enabled, the request is searched source by source and the first strict match is queued through the normal fulfil path. The request becomes **fulfilled** and its delivery state is tracked like any other.
3. If no source yields a confident match, the request stays **pending** in the admin queue for manual review, exactly as if a user had requested the book.
Only requests whose provider is Hardcover are considered for automatic downloads, and requests that have already been dispatched are left alone.
## Scheduler
The scheduler starts with the app and waits **60 seconds** before its first cycle. Each cycle runs a shelf sync (when scheduled sync is enabled) followed by an automatic-download pass (when that is enabled), then sleeps for the configured interval. Intervals shorter than 60 seconds are raised to 60 seconds so a typo cannot hammer the Hardcover API. If the interval cannot be read at all, the scheduler logs the error and tries again after six hours instead of stopping. When neither toggle is on the scheduler idles.
Only one run happens at a time. If a scheduled cycle is in progress, **Sync now** reports that a sync is already running.
## Troubleshooting
**Sync adds nothing and the log says "no Hardcover token configured"** — set **Hardcover API Token** on this tab, or configure the token on the Hardcover metadata provider. A sync without a usable token counts as an error and creates no requests.
**Requests appear but nothing downloads** — check **Enable automatic downloads** is on, that the source-priority list for the request's content type contains at least one configured source, and look for `no strict match` lines in the log. A request from another user is only searched on the sources where their policy allows a direct download (or, with requests turned off for them, on every source); with no such source it stays pending for an admin and is not searched. Lowering **Minimum seeders** helps on quiet trackers; loosening the title or author match is deliberately not an option.
**Books you own keep being added** — enable the library check for the format you request (see [Library Check](library-check.md)) and use its test button. A library that cannot be read shows up in the log as a skipped cycle.
**Too many requests at once** — the first sync of a large shelf creates one request per book, and the per-user pending limit from **Users & Requests** does not apply to synced requests. Start with a shelf you are happy to see in the queue in full.
+5
View File
@@ -13,6 +13,7 @@ Use the guides below to set up the app, connect your library tools, and understa
## Core Guides
- [Users & Requests](users-and-requests.md)
- [Hardcover Sync](hardcover-sync.md)
- [Reverse Proxy](reverse-proxy.md)
- [OIDC](oidc.md)
- [API Access](api-access.md)
@@ -21,6 +22,10 @@ Use the guides below to set up the app, connect your library tools, and understa
- [Library Check](library-check.md)
- [Custom Scripts](custom-scripts.md)
## Developers
- [Developer Documentation](dev/index.md)
## Help
- [Troubleshooting](troubleshooting.md)
+1
View File
@@ -31,3 +31,4 @@ docker compose up -d
- Universal search is the default mode for new installs
- Direct Download is optional and must be enabled and configured before it can be used
- Torrent and usenet setups require matching download paths between Shelfmark and your download client
- The Lite image and the Tor and WireGuard variants have their own compose files, described under Docker Variants in the [README](../readme.md)
+33 -5
View File
@@ -21,6 +21,26 @@ database directly. No HTTP call, no API token, and nothing is ever written back.
A result that matches the library then carries an **In library** badge in the card, list
and compact views, and in the details dialog.
## Audiobookshelf
Shelfmark asks the Audiobookshelf server over its REST API for the title, author, ISBN and
ASIN of every item, and nothing is written back.
1. In **Settings, General**, turn on **Mark audiobooks already in your Audiobookshelf
library**.
2. Set **Audiobookshelf URL** to an address the Shelfmark container can reach (often the
host's LAN IP, not the public URL), for example `http://10.0.0.91:13378`.
3. Paste an **Audiobookshelf API Token** (Audiobookshelf **Settings, Users, your user, API
Token**). Read access is enough.
4. Leave **Audiobookshelf Library IDs** blank to check every book library, or list the IDs
to check (comma separated).
5. Press **Test Audiobookshelf library**. It reports how many items it indexed, using the
values on the form, so it works before you save.
Audiobookshelf titles are often raw folder names such as `Alex Cross 02: Kiss the Girls`.
When the server reports a series for the item, the leading label and number are read as the
series, not as a different book, so a search for `Kiss the Girls` still matches.
## How a match is decided
In order of confidence:
@@ -32,12 +52,20 @@ In order of confidence:
## Behaviour worth knowing
- **It fails open.** If the database cannot be read, Shelfmark logs a warning, reuses the
last successful read if it has one, and otherwise treats the book as not owned. A broken
path degrades the badge, it never blocks a search.
Sequels and other near-miss titles fail the title rule; the same title by a different author fails the surname rule. A series label in front of a library title ("Alex Cross 02: Kiss the Girls", "HHG 5: Mostly Harmless") is read as the series, not as part of the title, when the entry is in a series, so renaming a shelf to show reading order doesn't make its books look unowned. A shelf title whose bracketed words name another edition ("Full Cast Edition", "Dramatized Adaptation") is not the book either: owning the adaptation is not owning the recording asked for. Printing words ("Illustrated Edition", "Unabridged") change nothing, and in a Calibre title a numbered parenthetical ("(Alex Cross Series #11)") is read as the series, not as an edition.
- **It fails open for badges.** If a library cannot be read, Shelfmark logs a warning, reuses
the last successful read if it has one, and otherwise treats the book as not owned. A
broken path degrades the badge, it never blocks a search.
- **It fails closed for anything that acts on the answer.** The Hardcover sync and automatic
downloads would otherwise request or download books the library already holds, so when an
enabled library cannot be read and has not been read before, the sync cycle is skipped and
the request stays pending until the next one. A library that listed items a moment ago and
now lists none is treated as unreadable too, not as an emptied shelf.
- **Results are cached** for ten minutes, and refreshed early when the database file
changes, so a large library costs one read rather than one per search.
- **Ebooks only.** A Calibre library holds ebooks, so the badge answers for ebooks. The
provider interface in `shelfmark/core/library_providers/` takes more libraries: add a
- **Each library answers for its own format.** Calibre holds ebooks and Audiobookshelf holds
audiobooks, so an ebook result is only checked against Calibre and an audiobook against
Audiobookshelf. The provider interface in `shelfmark/core/library_providers/` takes more libraries: add a
module with the `LibraryProvider` shape and list it in `all_providers()`. Nothing above
that function knows which libraries exist.
+12
View File
@@ -30,10 +30,14 @@ server {
proxy_set_header X-Forwarded-Host $http_host;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_read_timeout 86400;
proxy_send_timeout 86400;
}
}
```
Keep the read timeout at least as high as `RELEASE_SEARCH_TIMEOUT` (300 seconds by default). Nginx's own default is 60 seconds, which cuts off any release search that has to solve a protection challenge first. See [Searches that time out behind a proxy](#searches-that-time-out-behind-a-proxy).
## Subpath setup
Running Shelfmark under a subpath like `/shelfmark` is supported without extra rewrite rules.
@@ -164,6 +168,14 @@ If login, settings saves, or downloads appear to fail in the browser but the act
This is especially relevant for Nginx Proxy Manager or custom advanced config snippets that add websocket headers globally.
## Searches that time out behind a proxy
A release search can legitimately take minutes. When a source's protection cookies expire, the next search has to solve a browser challenge before it can run. Shelfmark gives a search up to `RELEASE_SEARCH_TIMEOUT` seconds (300 by default) and says why when it gives up, but a proxy with a shorter read timeout cuts the request off first. The search then fails with a message about the reverse proxy, or, on older versions, with "Unable to reach download source. Network may be restricted or mirrors blocked." A retry often works because the solve finished on the server after the proxy stopped waiting.
- Set the proxy's read timeout to at least `RELEASE_SEARCH_TIMEOUT`. In Nginx and Nginx Proxy Manager (the **Advanced** tab of the proxy host), add the `proxy_read_timeout` and `proxy_send_timeout` lines from the examples above.
- Cloudflare's proxy gives up after 100 seconds with a `524` error, and only Enterprise plans can raise that. If searches outlast it, reach Shelfmark without going through Cloudflare's proxy, for example on your LAN or over a VPN.
- To confirm the proxy is the cause, run the same search against Shelfmark directly at `http://<host>:8084`. If it succeeds there, or fails with a specific reason, the proxy was cutting it off.
---
## Health checks
+17 -1
View File
@@ -1,3 +1,19 @@
# Troubleshooting
TODO
Start with the logs, then attach a debug bundle if you open an issue.
## Logs
Shelfmark writes its log to the container output, so `docker logs shelfmark` shows it. With `ENABLE_LOGGING` on (the default) it also writes log files under `LOG_ROOT/shelfmark/` (`/var/log/shelfmark/` by default), including `shelfmark.log` and the startup logs.
`LOG_LEVEL` sets how much is written (`INFO` by default). Set it to `DEBUG` to see what a search or download is doing. See the [Environment Variables](environment-variables.md) reference for both settings.
## Debug bundle
Set `DEBUG=true` on the container and restart it. A **DEBUG** button then appears in the interface. It generates a zip with logs, configuration and environment details, with sensitive values redacted. Have a look through it before you post it publicly, then attach it when you open an issue.
The full image also records the browser's screen during a bypass while Debug mode is on. ffmpeg isn't in the image, so the container installs it in the background at startup, which needs network access. Bypasses that run before it finishes aren't recorded. The log says when the install succeeds, fails or gives up after 120 seconds. In non-root mode it can't install anything, so the log warns that bypasses won't be recorded and everything else works as normal.
## Reporting an issue
Open an issue on [GitHub](https://github.com/calibrain/shelfmark/issues) and fill in the bug report template. A description of what you expected, the steps to reproduce it and the debug bundle are what make a problem findable.
+23 -4
View File
@@ -42,10 +42,10 @@ There are four categories of per-user settings:
Override where a user's downloads are sent. Options depend on the global output mode configuration:
- **Output mode** — Folder, Email (SMTP), or BookLore (API)
- **Output mode** — Folder, Email (SMTP), or Grimmory (API)
- **Destination** — A custom folder path for this user's ebook downloads
- **Audiobook destination** — A custom folder path for audiobook downloads
- **BookLore library/path** — Per-user BookLore target (when using BookLore output mode)
- **Grimmory library/path** — Per-user Grimmory target (when using Grimmory output mode)
- **Email recipient** — Per-user email address (when using Email output mode)
### Search Preferences
@@ -61,12 +61,18 @@ Override how a user searches, on top of the global search defaults:
Users can configure personal notification routes, separate from the global notification settings. Each route targets a URL (e.g. an Apprise-compatible endpoint) and can be scoped to specific event types or all events.
Route URLs can use `{User}` and `{Email}` in their path or query, filled in from each user's account when a notification is sent. A route is skipped for users without the value it needs, or whose value contains spaces or any of `, ; / \ "` (or is only dots, such as `..`), since those would split it into several targets or change the URL path. The admin-set default routes apply to every user who hasn't set routes of their own and can use any service, so one default such as `mailtos://user:pass@smtp.example.com?to={Email}` reaches each user at their own address. The service a default points to then receives each user's email address, so only use one you trust with it. Routes a user sets themselves stay limited to the personal-route services, e.g. `ntfys://ntfy.sh/shelfmark-{User}`.
### Request Policy (admin-only)
Admins can override the default ebook/audiobook modes and request rules for individual users. See [Per-User Overrides](#per-user-overrides) below.
---
## Clearing the activity list
**Clear Completed** in the activity list clears finished downloads and their requests, and also finished requests that never had a download, such as a request closed because the book was already in the library. Pending and rejected requests are left alone.
## Requests
The request system controls whether users can download directly or need admin approval first.
@@ -92,6 +98,7 @@ Each content type (ebook, audiobook) has a default mode that sets the baseline:
| Request Rules | Per-source overrides (see below) | None |
| Max Pending Requests Per User | Open request limit per user | 20 |
| Allow Notes on Requests | Let users attach a note when submitting | On |
| Allow book requests alongside downloads | Let users who can download also request a book without choosing a release, for a book nobody has released yet or one no source has. The request waits for an admin, and the user gets the Requests tab to follow or cancel it. Admins can always do this | Off |
### Request Rules
@@ -99,6 +106,8 @@ The rules matrix lets you override the mode for specific source + content type c
For example, if the default ebook mode is "Download", a rule can restrict a specific source to "Request Release" or "Blocked", but not the other way around. If no rule matches, the content-type default applies.
Rules cannot use "Request Book". Sources whose browse results are already concrete releases are also treated as "Request Release" when the default mode is "Request Book", since there is no book-level request to make for them.
### Per-User Overrides
Admins can override the default ebook/audiobook modes and request rules for individual users. Per-user rules are overlaid on the global rules, not replacing them.
@@ -109,6 +118,16 @@ Admins can override the default ebook/audiobook modes and request rules for indi
2. Request appears in the admin request queue as **pending**
3. Admin either **fulfils** (queues a download) or **rejects** the request
4. For fulfilled requests, delivery state is tracked through the download pipeline
5. If delivery fails, an admin can reopen the request to try a different release
5. If delivery fails and the download cannot be retried, the request goes back to pending automatically so an admin can approve it again with a different release. An admin can also reopen a rejected request
6. Users can cancel their own pending requests
7. An admin can **reopen** a rejected request, from the Requests tab or from History. It goes back to pending with what the user originally asked for, and shows in the activity list again even if the user had dismissed it.
7. An admin can **reopen** a rejected request, from the Requests tab or from History. It goes back to pending with what the user originally asked for, and shows in the activity list again even if the user had dismissed it. A fulfilled request whose download failed or was cancelled can be reopened the same way: it goes back to pending without its release, and the next automatic search skips the release that failed (an admin can still pick it by hand).
An admin sees who declined a request and when on its card, with the admin note if one was given.
### Rejected view (this fork)
A button next to History opens **Rejected**: every rejected request, including ones cleared from the activity list, each with **Reopen**. A card can be hidden from this view, and **Show hidden (N)** in its header brings hidden ones back, so a request is never out of reach. Hiding here is separate from clearing a card in Activity.
Every card shows the format as a coloured pill: **Audiobook** or **Ebook** on a request, or the file format (EPUB, M4B, MP3) once a release is chosen or downloaded. Audio is violet and ebooks are orange.
Admin actions taken by scripts or automation carry an admin note starting `[auto]`, for example `[auto] Already in the library.`, so they can be told apart from decisions a person made.
+53
View File
@@ -498,6 +498,50 @@ else
fi
command="${GUNICORN_BIN} --log-level ${gunicorn_loglevel} --access-logfile - --error-logfile - --worker-class geventwebsocket.gunicorn.workers.GeventWebSocketWorker --workers 1 -t 300 -b ${FLASK_HOST:-0.0.0.0}:${FLASK_PORT:-8084} shelfmark.main:app"
# Debug mode's screen recording needs ffmpeg, which the image leaves out because nothing
# else uses it. This runs in the background so a slow or silent mirror can't hold up
# startup. A mirror can only hang the download, and killing that leaves nothing half
# installed. dpkg then works from what's on disk, and the next start's --configure -a
# clears up if a restart or the time limit cuts it off.
install_ffmpeg() {
local limit="${FFMPEG_INSTALL_TIMEOUT:-120}" status=0
export DEBIAN_FRONTEND=noninteractive
umask 0022
timeout -k 10 "$limit" dpkg --configure -a || status=$?
if [ "$status" = 0 ]; then
# No && after update: a partial index failure should not rule out the install.
timeout -k 10 "$limit" sh -c 'apt-get update -o Acquire::Retries=3
apt-get install -y --no-install-recommends --download-only ffmpeg' || status=$?
fi
if [ "$status" = 0 ]; then
timeout -k 10 "$limit" apt-get install -y --no-install-recommends --no-download ffmpeg || status=$?
fi
case "$status" in
0)
if ffmpeg -version >/dev/null 2>&1; then
echo "ffmpeg installed; bypasses will be recorded from now on."
else
echo "Could not install ffmpeg (apt finished but ffmpeg does not run); bypasses will not be recorded."
fi
;;
124 | 137) echo "Timed out installing ffmpeg after ${limit}s; bypasses will not be recorded." ;;
*) echo "Could not install ffmpeg (exit $status); bypasses will not be recorded." ;;
esac
}
# Ask the app rather than reading $DEBUG: the Debug Mode setting lives in the config file
# and needs a restart, so this is where it takes effect.
INSTALL_FFMPEG="false"
if [ "$USING_EXTERNAL_BYPASSER" != "true" ] && ! ffmpeg -version >/dev/null 2>&1 &&
"$PYTHON_BIN" -c 'from shelfmark.config.env import DEBUG; raise SystemExit(0 if DEBUG else 1)' 2>/dev/null; then
if [ "$RUN_AS_NON_ROOT" = "true" ]; then
echo "Debug mode is on but ffmpeg is not installed and non-root mode cannot install it; bypasses will not be recorded."
else
echo "Debug mode is on: installing ffmpeg in the background; bypasses before it finishes will not be recorded."
INSTALL_FFMPEG="true"
fi
fi
# If DEBUG and not using an external bypass
if [ "$DEBUG" = "true" ] && [ "$USING_EXTERNAL_BYPASSER" != "true" ]; then
set +e
@@ -589,4 +633,13 @@ echo "Setting umask to $UMASK_VALUE"
umask $UMASK_VALUE
stop_file_logging
# Started only now because stop_file_logging waits for the log pipe to close, which a
# background job holding it would delay. The outer subshell leaves it to dumb-init to reap.
if [ "$INSTALL_FFMPEG" = "true" ]; then
if [ "$FILE_LOGGING_ENABLED" = "true" ]; then
( (install_ffmpeg 2>&1 | tee -a "$LOG_FILE") </dev/null & )
else
( (install_ffmpeg 2>&1) </dev/null & )
fi
fi
exec_as_target_user env HOME="$RUNTIME_HOME" $command
+2 -2
View File
@@ -141,14 +141,14 @@ fi
# Add environment variables (redacting sensitive info and the credentials in URLs
# such as a proxy's http://user:pass@host:port)
env | grep -v -E "^[^=]*(_KEY|_SECRET|_PASSWORD|_TOKEN|_MAM_ID)[^=]*=" | sed -E 's#(://)[^/@[:space:]]+@#\1[REDACTED]@#g' | sort > "$LOG_DIR/environment.txt"
env | grep -v -E "^[^=]*(_KEY|_SECRET|_PASSWORD|_TOKEN|_MAM_ID)[^=]*=" | sed -E 's#(://)[^/?#@[:space:]]+@#\1[REDACTED]@#g' | sort > "$LOG_DIR/environment.txt"
# Redact sensitive fields (API keys, passwords, tokens, the MAM session id), the
# credentials in URLs, and notification URLs, which carry their service's token
redact_config_json() {
sed -E \
-e 's/("(AA_DONATOR_KEY|HARDCOVER_API_KEY|[^"]*_KEY|[^"]*_SECRET|[^"]*_PASSWORD|[^"]*_TOKEN|[^"]*_MAM_ID)"[[:space:]]*:[[:space:]]*")[^"]+"/\1[REDACTED]"/g' \
-e 's#(://)[^/@[:space:]"]+@#\1[REDACTED]@#g' \
-e 's#(://)[^/?#@[:space:]"]+@#\1[REDACTED]@#g' \
-e 's#("url"[[:space:]]*:[[:space:]]*"([[:alnum:]+.-]+://)?)[^"]+"#\1[REDACTED]"#g' \
"$1"
}
+7 -4
View File
@@ -9,13 +9,16 @@
}
},
"node_modules/baseline-browser-mapping": {
"version": "2.9.19",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz",
"integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==",
"version": "2.11.31",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.31.tgz",
"integrity": "sha512-b0k7wXlWjpuL/Uxb/pYlpjY0ej5BjAmurtPUk3eRGuloI4yjK32m8TiKUQ81l1ptcLjKS/KH2+uqIolZti1x4Q==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"baseline-browser-mapping": "dist/cli.js"
"baseline-browser-mapping": "dist/cli.cjs"
},
"engines": {
"node": ">=6.0.0"
}
}
}
+9 -6
View File
@@ -19,21 +19,24 @@ dependencies = [
"psutil",
"emoji",
"rarfile",
"qbittorrent-api>=2026.10.0",
"qbittorrent-api>=2026.10.1",
"transmission-rpc",
"authlib>=1.9.1,<1.10",
"apprise>=2.0.1",
"authlib>=1.9.5,<1.10",
# Authlib validates ID tokens with joserfc and raises its errors, so the OIDC routes
# catch joserfc's InvalidClaimError. authlib.jose is deprecated and goes in Authlib 2.0.
"joserfc>=1.8.0",
"apprise>=2.1.0",
# HTTP/2 client for RFC 8484 DoH: quad9 rejects HTTP/1.1 outright (505), which
# requests cannot speak. See shelfmark/download/doh_wireformat.py.
"httpx[http2]>=0.28.1",
"publicsuffixlist>=1.1.0.20261017",
"publicsuffixlist>=1.1.2.20261105",
]
[project.optional-dependencies]
browser = [
"pyvirtualdisplay",
"pyautogui",
"seleniumbase==4.56.2",
"seleniumbase==4.57.1",
"python-xlib",
]
@@ -44,7 +47,7 @@ dev = [
"pytest",
"pytest-cov",
"pytest-xdist>=3.8.0",
"ruff==0.17.1",
"ruff==0.17.4",
"vulture>=2.14",
]
+32 -12
View File
@@ -17,11 +17,13 @@ Works great alongside the following library tools, with support for automatic im
## ✨ Features
- **One-Stop Interface** - A clean, modern UI to search, browse, and download from multiple configured sources in one place
- **Multiple Sources** - Configurable web, torrent, usenet, IRC, and Soulseek (slskd) source support
- **Multiple Sources** - Configurable web (including OceanofPDF), torrent, usenet, IRC, Soulseek (slskd), and Webshare.cz source support, with indexers through Prowlarr or Jackett
- **Audiobook Support** - Full audiobook search and download with dedicated processing
- **Flexible Search** - Search metadata providers (Hardcover, Open Library, Google Books) for rich book and audiobook discovery, or query configured sources directly
- **Flexible Search** - Search metadata providers (Hardcover, Open Library, Google Books, DNB, Databáze knih, Moly.hu) for rich book and audiobook discovery, or query configured sources directly
- **Multi-User & Requests** - Share your instance with others, let users browse and request books, and manage approvals with configurable notifications
- **Authentication** - Built-in login, OIDC single sign-on, proxy auth, and Calibre-Web database support
- **Library Check** - Optionally mark search results you already own in a Calibre or Audiobookshelf library, so you don't download a second copy. See [Library Check](docs/library-check.md)
- **Hardcover Sync** - Optionally pull a Hardcover shelf in as requests and download the books without an admin picking a release. Off by default. See [Hardcover Sync](docs/hardcover-sync.md)
- **Real-Time Progress** - Unified download queue with live status updates across all sources
- **Network Flexibility** - Configurable proxy support, DNS settings, and optional Cloudflare handling for protected sources
@@ -91,7 +93,7 @@ volumes:
- Queries configured sources directly
**Universal** (recommended)
- Search via metadata providers (Hardcover, Open Library, Google Books) for richer results
- Search via metadata providers (Hardcover, Open Library, Google Books, DNB, Databáze knih, Moly.hu) for richer results
- Aggregates releases from multiple configured sources
- Full audiobook support
@@ -145,16 +147,18 @@ Environment variables work for initial setup and Docker deployments. They serve
See the full [Environment Variables Reference](docs/environment-variables.md) for all available options.
Some of the additional options available in Settings:
- **Prowlarr** - Configure indexers and download clients to download books and audiobooks
- **Prowlarr / Jackett** - Configure indexers to download books and audiobooks through your torrent and usenet clients
- **Download clients** - qBittorrent, Transmission, Deluge, rTorrent, NZBGet, SABnzbd, a blackhole folder, or a debrid service (AllDebrid, Real-Debrid, TorBox, Debrid-Link)
- **Additional audiobook sources** - Configure additional sources for audiobook discovery
- **Webshare.cz** - Search Webshare.cz by file name and download ebooks and audiobooks with your Webshare account (a VIP account is recommended for full speed)
- **Direct Download mirrors** - Supply your own Anna's Archive mirror URLs; Auto mode tries them in the order listed. The `annas-archive.is` domain does not currently work as a source — use `annas-archive.gl` instead (checked August 2026; mirror availability changes)
- **IRC** - Add details for IRC book sources and download directly from the UI. Most networks serve audiobooks from the same channel as ebooks (on `irc.irchighway.net` that's `#ebooks`, while `#bookz` is effectively inactive), so leave the separate audiobook channel blank unless your network actually indexes one. IRC audiobooks usually arrive as ZIP/RAR archives — keep those enabled under Supported Audiobook Formats or the releases are filtered out of results
- **Soulseek (slskd)** - Search the Soulseek network and download through a [slskd](https://github.com/slskd/slskd) instance. Point Shelfmark at slskd's URL with an API key (`web.authentication.api_keys` in `slskd.yml`, role `readwrite`), and mount slskd's downloads folder into the Shelfmark container. If the folder sits at a different path inside Shelfmark, set **Downloads Path** in the slskd settings or add a Remote Path Mapping for client `slskd`. Ebook results are single files; audiobook results group a peer's folder of audio files into one release
- **Library Link** - Add a link to your Calibre-Web or Grimmory instance in the UI header
- **Soulseek (slskd)** - Search the Soulseek network and download through a [slskd](https://github.com/slskd/slskd) instance. Point Shelfmark at slskd's URL with an API key (`web.authentication.api_keys` in `slskd.yml`, role `readwrite`), and mount slskd's downloads folder into the Shelfmark container. If the folder sits at a different path inside Shelfmark, set **Downloads Path** in the slskd settings or add a Remote Path Mapping for client `slskd`. Ebook results are single files; audiobook results group a peer's folder of audio files into one release. Some uploaders block downloads until you type a word to them in a private message; turn on **Answer anti-leech chat challenges** and Shelfmark types it for you, at most once per user every 12 hours. It reads every unread private message in slskd and marks a conversation it answered as read, so leave it off if you also read slskd's messages yourself
- **Library Link** - Add links to your book library (Calibre-Web or Grimmory) and your audiobook library (Audiobookshelf, Plex) in the UI header
- **File processing** - Customiseable download paths, file renaming and directory creation with template-based renaming
- **Network Settings** - Custom proxy support (SOCKS5 + HTTP/S) and configurable DNS
- **Format & Language** - Filter downloads by preferred formats, languages and sorting order
- **Metadata Providers** - Configure API keys for Hardcover, Open Library, etc.
- **Metadata Providers** - Configure API keys for Hardcover, Google Books and the other metadata providers
## 🐳 Docker Variants
@@ -253,14 +257,14 @@ See [API Access](docs/api-access.md) to call the API with a static key from scri
## 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 core scope.
Shelfmark intentionally does not:
- **Track or manage your library** - it doesn't know or care what you already own
- **Integrate with library software** - what happens after delivery is up to your library tool
- **Monitor authors, series, or new releases** - there is no background automation
- **Queue future downloads** - if a book isn't available now, Shelfmark won't watch for it
- **Manage your library** - it never edits, tags or reorganises what you already own. The optional [Library Check](docs/library-check.md) only reads your Calibre or Audiobookshelf library to mark results you already have
- **Integrate with library software beyond delivery** - what happens after delivery is up to your library tool
- **Monitor authors, series, or new releases** - there is no watching for new books. The optional [Hardcover Sync](docs/hardcover-sync.md) is the one background job: it turns a Hardcover shelf you choose into requests, and it is off by default
- **Queue future downloads** - if a book isn't available now, Shelfmark won't watch for it. Hardcover Sync retries a request on a schedule, which is the closest it gets
These are non-goals, not missing features.
@@ -284,6 +288,8 @@ healthcheck:
retries: 3
```
The endpoint answers 503 when the download coordinator, the part that picks queued downloads up, is not running. Without it every download stays queued, so a container that reports unhealthy for this reason needs a restart.
## Logging
Logs are available via:
@@ -318,6 +324,20 @@ make restart # Restart container
The frontend dev server proxies to the backend on port 8084.
## Sponsors
Code review on Shelfmark's pull requests is sponsored by [CodeRabbit](https://coderabbit.link/calibrain).
<a href="https://coderabbit.link/calibrain">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://www.coderabbit.ai/content/assets/white-typemark.svg">
<source media="(prefers-color-scheme: light)" srcset="https://www.coderabbit.ai/content/assets/orange-typemark.svg">
<img src="https://www.coderabbit.ai/content/assets/orange-typemark.svg" alt="CodeRabbit" width="240">
</picture>
</a>
## License
MIT License - see [LICENSE](LICENSE) for details.
+35 -4
View File
@@ -16,6 +16,7 @@ The generated documentation includes:
"""
import argparse
import re
import sys
from pathlib import Path
from typing import Any
@@ -119,6 +120,11 @@ def get_select_options(field: Any) -> list[str] | None:
return result
def _anchor(title: str) -> str:
"""Heading anchor the way GitHub builds it: lowercase, punctuation dropped, spaces to hyphens."""
return re.sub(r"[^\w\- ]", "", title.lower()).replace(" ", "-")
def _generate_bootstrap_env_docs() -> list[str]:
"""Generate documentation for bootstrap environment variables from env.py."""
# These are environment variables defined in env.py that are used before
@@ -196,12 +202,24 @@ def _generate_bootstrap_env_docs() -> list[str]:
"type": "string",
"default": "unset",
},
{
"name": "SHELFMARK_API_KEY_READONLY",
"description": "Optional second API key for dashboards and monitors. Send it like SHELFMARK_API_KEY. It can only GET /api/stats (counters only, no titles or user names). Every other request, and any write, is refused with 403, and it never gets a session. Unset = off.",
"type": "string",
"default": "unset",
},
{
"name": "OIDC_AUTO_REDIRECT",
"description": "Automatically redirect to the OIDC provider instead of showing the login page.",
"type": "boolean",
"default": "false",
},
{
"name": "UMASK",
"description": "File creation mask for the app process, read by entrypoint.sh. The default 0022 gives files 644 and directories 755.",
"type": "string (octal)",
"default": "0022",
},
{
"name": "DOCKERMODE",
"description": "Indicates the application is running inside a Docker container.",
@@ -321,12 +339,24 @@ def _generate_egress_env_docs() -> list[str]:
"type": "number",
"default": "180",
},
{
"name": "TOR_CHECK_INTERVAL",
"description": "Seconds between Tor watchdog probes. The watchdog resets the container when Tor stops being usable, so the restart policy rebuilds the Tor setup.",
"type": "integer (seconds)",
"default": "30",
},
{
"name": "TOR_MAX_FAILURES",
"description": "Consecutive failed Tor watchdog probes before the container is reset. 2 tolerates one failed probe.",
"type": "integer",
"default": "2",
},
]
lines = [
"## Egress / VPN Routing",
"",
"These startup-only variables are consumed by `entrypoint.sh` / `wireguard.sh` to select and configure the WireGuard transparent-egress kill-switch. `USING_WIREGUARD` and [`USING_TOR`](#using_tor) (documented under Network) are mutually exclusive; both require root startup.",
"These startup-only variables are consumed by `entrypoint.sh` / `wireguard.sh` to select and configure the WireGuard transparent-egress kill-switch. `USING_WIREGUARD` and [`USING_TOR`](#using_tor) (documented under Network) are mutually exclusive; both require root startup. `TOR_CHECK_INTERVAL` and `TOR_MAX_FAILURES` tune the Tor watchdog in `tor.sh`.",
"",
"| Variable | Description | Type | Default |",
"|----------|-------------|------|---------|",
@@ -362,6 +392,7 @@ def generate_env_docs() -> str:
# Import settings modules to ensure all settings are registered
import shelfmark.config.security
import shelfmark.config.settings
import shelfmark.metadata_providers.dnb
import shelfmark.metadata_providers.googlebooks
import shelfmark.metadata_providers.hardcover
import shelfmark.metadata_providers.openlibrary
@@ -406,17 +437,17 @@ def generate_env_docs() -> str:
# Ungrouped tabs first
for tab in grouped_tabs.get(None, []):
anchor = tab.display_name.lower().replace(" ", "-")
anchor = _anchor(tab.display_name)
toc_entries.append(f"- [{tab.display_name}](#{anchor})")
# Then grouped tabs
for group_name, group in groups.items():
group_tabs = grouped_tabs.get(group_name, [])
if group_tabs:
anchor = group.display_name.lower().replace(" ", "-")
anchor = _anchor(group.display_name)
toc_entries.append(f"- [{group.display_name}](#{anchor})")
for tab in group_tabs:
sub_anchor = f"{group.display_name}-{tab.display_name}".lower().replace(" ", "-")
sub_anchor = _anchor(f"{group.display_name}-{tab.display_name}")
toc_entries.append(f" - [{tab.display_name}](#{sub_anchor})")
lines.extend(toc_entries)
+2 -2
View File
@@ -2,12 +2,12 @@
from __future__ import annotations
import logging
import threading
from typing import TYPE_CHECKING, Any
from flask_socketio import SocketIO, join_room, leave_room
from shelfmark.core.logger import setup_logger
from shelfmark.core.viewer_redaction import viewer_download_id
if TYPE_CHECKING:
@@ -15,7 +15,7 @@ if TYPE_CHECKING:
from flask import Flask
logger = logging.getLogger(__name__)
logger = setup_logger(__name__)
class WebSocketManager:
+6
View File
@@ -5,6 +5,7 @@ import asyncio
import json
import os
import random
import shutil
import signal
import socket
import stat
@@ -1499,6 +1500,11 @@ async def _close_websocket_connection(conn: Any) -> None:
def _start_ffmpeg_recording(display: str) -> None:
"""Start FFmpeg screen recording for debug mode."""
global DISPLAY
# Debug mode can be on when ffmpeg is not installed. A recording is a diagnostic, so
# going without one must not fail the bypass it was meant to help diagnose.
if shutil.which("ffmpeg") is None:
logger.warning("ffmpeg is not installed; no recording for this bypass.")
return
RECORDING_DIR.mkdir(parents=True, exist_ok=True)
timestamp = datetime.now(UTC).strftime("%y%m%d-%H%M%S")
output_file = RECORDING_DIR / f"screen_recording_{timestamp}.mp4"
+25 -2
View File
@@ -4,6 +4,7 @@ import json
import os
import shutil
import tempfile
import time
from pathlib import Path
LOG_LEVELS = ("DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL")
@@ -118,14 +119,36 @@ def _is_config_dir_writable() -> bool:
return True
# The probe above creates and deletes a file, and cover URLs are rewritten per result,
# per queue task and per history row, so it is remembered for a short time.
_WRITABLE_PROBE_TTL_SECONDS = 60.0
_writable_probe: tuple[float, bool] | None = None
def _config_dir_writable_cached() -> bool:
global _writable_probe
now = time.monotonic()
if _writable_probe is not None and now - _writable_probe[0] < _WRITABLE_PROBE_TTL_SECONDS:
return _writable_probe[1]
writable = _is_config_dir_writable()
_writable_probe = (now, writable)
return writable
def reset_config_dir_writable_cache() -> None:
"""Forget the remembered probe (tests change the directory under it)."""
global _writable_probe
_writable_probe = None
def is_covers_cache_enabled() -> bool:
"""Check if cover caching is enabled (requires setting + writable config dir)."""
from shelfmark.core.config import config
setting_enabled = config.get("COVERS_CACHE_ENABLED", True)
if isinstance(setting_enabled, str):
return string_to_bool(setting_enabled) and _is_config_dir_writable()
return bool(setting_enabled) and _is_config_dir_writable()
return string_to_bool(setting_enabled) and _config_dir_writable_cached()
return bool(setting_enabled) and _config_dir_writable_cached()
# =============================================================================
+292
View File
@@ -0,0 +1,292 @@
"""Settings tab for Hardcover wishlist sync + automatic downloads."""
from __future__ import annotations
from typing import Any
from shelfmark.core.settings_registry import (
ActionButton,
CheckboxField,
HeadingField,
NumberField,
OrderableListField,
PasswordField,
SelectField,
SettingsField,
register_settings,
)
_CONTENT_TYPE_OPTIONS = [
{"value": "audiobook", "label": "Audiobooks"},
{"value": "ebook", "label": "Ebooks"},
{"value": "both", "label": "Ebooks and audiobooks"},
]
def _status_options() -> list[dict[str, str]]:
from shelfmark.metadata_providers.hardcover import HARDCOVER_STATUSES
return [{"value": str(status["id"]), "label": status["label"]} for status in HARDCOVER_STATUSES]
def _source_options(content_type: str) -> list[dict[str, Any]]:
"""Orderable-list options: release sources that can return ``content_type``."""
from shelfmark.release_sources import list_available_sources
options: list[dict[str, Any]] = []
for src in list_available_sources():
supported = src.get("supported_content_types") or ["ebook", "audiobook"]
if content_type not in supported:
continue
enabled = bool(src.get("enabled"))
options.append(
{
"id": src["name"],
"label": src.get("display_name") or src["name"],
"description": None if enabled else "Source not configured / unavailable",
"isLocked": not enabled,
"disabledReason": None if enabled else "Source not configured / unavailable",
}
)
return options
def _audiobook_source_options() -> list[dict[str, Any]]:
return _source_options("audiobook")
def _ebook_source_options() -> list[dict[str, Any]]:
return _source_options("ebook")
def _sync_now(current_values: dict[str, Any] | None = None) -> dict[str, Any]:
"""Action-button callback: trigger an immediate sync + auto-download pass."""
from shelfmark.core import hardcover_scheduler
started = hardcover_scheduler.trigger_async(force=True)
if not started:
return {"success": False, "message": "A sync is already running. Try again shortly."}
return {
"success": True,
"message": "Sync started. New wishlist books will appear as requests; "
"matching audiobooks auto-download if enabled.",
}
@register_settings("hardcover_sync", "Hardcover Sync", icon="book", order=8)
def hardcover_sync_settings() -> list[SettingsField]:
"""Configure Hardcover wishlist sync and automatic downloads."""
return [
HeadingField(
key="hardcover_sync_heading",
title="Hardcover Wishlist Sync",
description=(
"Automatically pull books from your Hardcover reading shelves into "
"Shelfmark as requests, on a schedule."
),
),
CheckboxField(
key="HARDCOVER_SYNC_ENABLED",
label="Enable scheduled sync",
description="Periodically sync the selected Hardcover shelves into requests.",
default=False,
),
PasswordField(
key="HARDCOVER_SYNC_TOKEN",
label="Hardcover API Token",
description=(
"Bearer token for your Hardcover account. Leave blank to reuse the "
"token from the Hardcover metadata provider."
),
placeholder="Reuses metadata provider token if blank",
),
SelectField(
key="HARDCOVER_SYNC_STATUSES",
label="Shelves to sync",
description="Which Hardcover reading shelf to pull from.",
options=_status_options,
default="1",
),
SelectField(
key="HARDCOVER_SYNC_CONTENT_TYPE",
label="Request as",
description=(
'Content type for synced requests. "Ebooks and audiobooks" creates one request '
"of each per shelf book; every request is checked against its own library and "
"downloaded with its own format guard and source priority."
),
options=_CONTENT_TYPE_OPTIONS,
default="audiobook",
),
NumberField(
key="HARDCOVER_SYNC_INTERVAL",
label="Sync interval",
description="How often the scheduled sync runs (minimum 1 minute).",
default=6,
min_value=1,
max_value=10000,
),
SelectField(
key="HARDCOVER_SYNC_INTERVAL_UNIT",
label="Interval unit",
description="Whether the sync interval is measured in minutes or hours.",
options=[
{"value": "minutes", "label": "Minutes"},
{"value": "hours", "label": "Hours"},
],
default="hours",
),
HeadingField(
key="auto_download_heading",
title="Automatic Downloads",
description=(
"When enabled, pending Hardcover requests (synced from a shelf, or made by a "
"user whose request policy lets them download directly) are auto-approved and "
"downloaded from the "
"first source in the matching list below that yields a strict title/author/"
"format match for the request's content type (ebook formats for ebook "
"requests, audiobook formats for audiobook requests). If no confident match "
"is found, the request is left pending for manual review."
),
),
CheckboxField(
key="AUTO_DOWNLOAD_ENABLED",
label="Enable automatic downloads",
description=(
"Auto-approve and auto-download matching releases for Hardcover requests. "
"Another user's request is only approved this way where their request "
"policy already lets them download directly; the rest wait for an admin."
),
default=False,
),
OrderableListField(
key="AUTO_DOWNLOAD_SOURCE_PRIORITY",
label="Audiobook source priority",
description=(
"Drag to set which release sources are tried first for audiobook requests. "
"The first source with a strict match wins. Disabled sources are skipped."
),
options=_audiobook_source_options,
# Default left empty so registration never imports release_sources (which would
# deadlock the registry lock). When empty, all available sources are used in
# registry order (see auto_download._configured_source_priority).
default=[],
),
OrderableListField(
key="AUTO_DOWNLOAD_EBOOK_SOURCE_PRIORITY",
label="Ebook source priority",
description=(
"Drag to set which release sources are tried first for ebook requests. "
"The first source with a strict match wins. Disabled sources are skipped."
),
options=_ebook_source_options,
default=[],
),
NumberField(
key="AUTO_DOWNLOAD_MIN_SEEDERS",
label="Minimum seeders (torrents)",
description="Skip torrent releases with fewer seeders than this.",
default=1,
min_value=0,
max_value=1000,
),
NumberField(
key="AUTO_DOWNLOAD_RETRY_DAYS",
label="Retry failed downloads after (days)",
description=(
"A synced request whose download failed, or was interrupted (a restart, say), "
"goes back to pending after this many days and is tried again, a few per pass. "
"A release that failed is not picked again, and a request is given up on after "
"three failed retries. A download the stall timer cancelled counts as failed; one "
"cancelled by a person, and rejected requests, are never retried. "
"0 turns retries off."
),
default=7,
min_value=0,
max_value=365,
),
NumberField(
key="AUTO_DOWNLOAD_FAILURE_RETRY_MINUTES",
label="Try another release after a failed download (minutes)",
description=(
"A download that failed, or that was given up on (stalled, no seeders), goes "
"back to pending this many minutes later and another release is chosen, "
"without waiting for the retry days above or the next sync cycle. The release "
"that failed is not picked again, and a request is given up on after three "
"failed attempts. 0 turns this off."
),
default=0,
min_value=0,
max_value=1440,
),
NumberField(
key="AUTO_DOWNLOAD_RETRY_FAILED_RELEASE_DAYS",
label="Try a failed release again after (days)",
description=(
"A release that failed or stalled is kept out of the search for this many "
"days, then it may be picked again. A torrent that stalled can be revived "
"by new seeds, so a failure should not shut a book out for good. A request "
"is still given up on after three failed attempts."
),
default=7,
min_value=1,
max_value=365,
),
NumberField(
key="AUTO_DOWNLOAD_BACKOFF_MAX_DAYS",
label="Longest wait between searches for an unmatched request (days)",
description=(
"A request that no source has a match for is searched every cycle at first. "
"After three passes in a row with no match it is searched once a day, then "
"every two days, and at most this often apart. A match, or Sync now, starts it "
"over. 0 searches every unmatched request every cycle."
),
default=3,
min_value=0,
max_value=30,
),
CheckboxField(
key="AUTO_DOWNLOAD_EXISTENCE_CHECK_ENABLED",
label="Mark audiobooks no store sells",
description=(
"An audiobook request that stays unmatched is looked up on Audible (US and "
"UK) through your Audiobookshelf server. Either way it stays pending and its "
"reason says what the store answered (with the ASIN when listed), for you to "
"decline or keep; it is checked again after a week. A lookup that "
"fails decides nothing. Needs the Audiobookshelf URL and token below."
),
default=False,
),
NumberField(
key="AUTO_DOWNLOAD_EXISTENCE_CHECK_AFTER_PASSES",
label="Look the book up after (unmatched passes)",
description=(
"How many consecutive passes with no strict match a request goes through "
"before the store is asked. A pass that matched, or a reopen, starts the "
"count over."
),
default=3,
min_value=1,
max_value=100,
),
NumberField(
key="AUTO_DOWNLOAD_AUDIOBOOK_MIN_SIZE_PERCENT",
label="Smallest acceptable audiobook copy (%)",
description=(
"A copy of an audiobook smaller than this share of the largest copy found for "
"the same book is probably a lower-bitrate copy. It is tried after the others, "
"and a single m4b that small is not preferred over a fuller set of chapter "
"files. 0 turns the check off."
),
default=67,
min_value=0,
max_value=100,
),
ActionButton(
key="sync_now",
label="Sync now",
description="Run a sync + auto-download pass immediately (uses saved settings).",
style="primary",
callback=_sync_now,
),
]
+22 -3
View File
@@ -9,6 +9,7 @@ from urllib.parse import urlsplit
from shelfmark.core.config import config as app_config
from shelfmark.core.notifications import (
NotificationEvent,
expand_user_route_placeholders,
send_test_notification,
user_route_url_problem,
)
@@ -147,12 +148,17 @@ def _extract_unique_route_urls(routes: list[dict[str, Any]]) -> list[str]:
def build_notification_test_result(
routes_input: Any, *, scope_label: str, user_scope: bool = False
routes_input: Any,
*,
scope_label: str,
user_scope: bool = False,
user_id: int | None = None,
) -> dict[str, Any]:
"""Validate routes and return a test-notification result payload.
``user_scope`` is for a user's own routes, which may only use the services and public hosts
that ``user_route_url_problem`` allows.
that ``user_route_url_problem`` allows. ``user_id`` fills in ``{User}``/``{Email}`` first, so
the check sees the URL that would actually be sent.
"""
invalid_event_count = _count_invalid_route_events(routes_input)
if invalid_event_count:
@@ -182,6 +188,17 @@ def build_notification_test_result(
"message": f"Add at least one {scope_label} notification URL route first.",
}
if user_id is not None:
urls = expand_user_route_placeholders(urls, user_id)
if not urls:
return {
"success": False,
"message": (
"Every route was skipped while filling in {User}/{Email}: the account has "
"no value for it, or a placeholder is in the host part of the URL."
),
}
if user_scope:
for url in urls:
problem = user_route_url_problem(url)
@@ -332,7 +349,9 @@ def notifications_settings() -> list[SettingsField]:
label="",
description=(
"Create one route per URL. Start with All, then add event-specific routes "
"for targeted delivery. Need format examples? "
"for targeted delivery. Use {User} or {Email} in the path or query to fill in "
"each user's account details, e.g. ntfys://ntfy.sh/shelfmark-{User}, or "
"mailtos://...?to={Email} in the admin default. Need format examples? "
"[View Apprise URL formats](https://appriseit.com/services/)."
),
columns=[
+62 -5
View File
@@ -36,7 +36,12 @@ from shelfmark.core.settings_registry import (
register_on_save,
register_settings,
)
from shelfmark.core.utils import ARCHIVE_FORMATS, AUDIOBOOK_FORMATS
from shelfmark.core.utils import (
ARCHIVE_FORMATS,
AUDIOBOOK_FORMATS,
DEFAULT_AUDIOBOOK_FORMATS,
DEFAULT_EBOOK_FORMATS,
)
_DOWNLOAD_CLIENT_COMPLETED_PATH_TIMEOUT_DEFAULT = 60
_DOWNLOAD_CLIENT_COMPLETED_PATH_TIMEOUT_MAX = 3600
@@ -396,6 +401,13 @@ def _test_calibre_library(current_values: dict[str, Any] | None = None) -> dict[
return library_index.test_connection("calibre", current_values)
def _test_audiobookshelf_library(current_values: dict[str, Any] | None = None) -> dict[str, Any]:
"""Action-button callback: read the Audiobookshelf libraries and count the items."""
from shelfmark.core import library_index
return library_index.test_connection("audiobookshelf", current_values)
@register_settings("general", "General", icon="settings", order=0)
def general_settings() -> list[SettingsField]:
"""Core application settings."""
@@ -444,6 +456,41 @@ def general_settings() -> list[SettingsField]:
description="Check that Shelfmark can read the Calibre database and count the books.",
callback=_test_calibre_library,
),
CheckboxField(
key="LIBRARY_CHECK_AUDIOBOOKSHELF_ENABLED",
label="Mark audiobooks already in your Audiobookshelf library",
description=(
"Ask your Audiobookshelf server which audiobooks you already own, so synced "
"and automatic downloads skip them. Read only, nothing is written to Audiobookshelf."
),
default=False,
),
TextField(
key="AUDIOBOOKSHELF_URL",
label="Audiobookshelf URL",
description=(
"Base URL reachable from the Shelfmark container (not the public URL). Often the "
"host LAN IP, e.g. http://10.0.0.91:13378."
),
placeholder="http://10.0.0.91:13378",
),
PasswordField(
key="AUDIOBOOKSHELF_TOKEN",
label="Audiobookshelf API Token",
description="API token from Audiobookshelf (Settings > Users > your user > API Token).",
),
TextField(
key="AUDIOBOOKSHELF_LIBRARY_IDS",
label="Audiobookshelf Library IDs (optional)",
description="Comma-separated library IDs to check. Leave blank for all book libraries.",
placeholder="Blank = all book libraries",
),
ActionButton(
key="test_audiobookshelf_library",
label="Test Audiobookshelf library",
description="Check that Shelfmark can reach Audiobookshelf and count the items.",
callback=_test_audiobookshelf_library,
),
HeadingField(
key="search_defaults_heading",
title="Default Search Filters",
@@ -454,14 +501,14 @@ def general_settings() -> list[SettingsField]:
label="Supported Book Formats",
description="Book formats to include in search results. ZIP/RAR archives are extracted automatically and book files are used if found.",
options=_FORMAT_OPTIONS,
default=["epub", "mobi", "azw3", "fb2", "djvu", "cbz", "cbr"],
default=list(DEFAULT_EBOOK_FORMATS),
),
MultiSelectField(
key="SUPPORTED_AUDIOBOOK_FORMATS",
label="Supported Audiobook Formats",
description="Audiobook formats to include in search results. ZIP/RAR archives are extracted automatically and audiobook files are used if found.",
options=_AUDIOBOOK_FORMAT_OPTIONS,
default=[*AUDIOBOOK_FORMATS, *ARCHIVE_FORMATS],
default=list(DEFAULT_AUDIOBOOK_FORMATS),
),
]
@@ -1486,7 +1533,10 @@ def download_settings() -> list[SettingsField]:
NumberField(
key="MAX_CONCURRENT_DOWNLOADS",
label="Max Concurrent Downloads",
description="Maximum number of simultaneous downloads.",
description=(
"Maximum number of simultaneous downloads. Not used while Max Concurrent "
"Downloads Per Source is above 0."
),
default=3,
min_value=1,
max_value=10,
@@ -1499,7 +1549,9 @@ def download_settings() -> list[SettingsField]:
"Simultaneous downloads allowed from each source (Soulseek, AudiobookBay, "
"Direct Download and so on). A source at its limit does not hold the others "
"back, so a Soulseek download waiting in a peer's queue cannot stop a torrent "
"from starting. 0 uses the single limit above for all sources together."
"from starting. Above 0 this replaces Max Concurrent Downloads: the total can "
"be this number for every source that has downloads at once. "
"0 uses the single limit above for all sources together."
),
default=0,
min_value=0,
@@ -2162,3 +2214,8 @@ def advanced_settings() -> list[SettingsField]:
register_on_save("advanced", _on_save_advanced)
# Register the Hardcover Sync settings tab. Imported here (rather than at each
# settings endpoint) so every code path that imports this module also registers it.
from shelfmark.config import hardcover_sync_settings as _hardcover_sync_settings # noqa: E402,F401
+12
View File
@@ -450,4 +450,16 @@ def users_settings() -> list[SettingsField]:
user_overridable=True,
show_when={"field": "REQUESTS_ENABLED", "value": True},
),
CheckboxField(
key="REQUESTS_ALLOW_BOOK_REQUEST_WITH_DOWNLOAD",
label="Allow book requests alongside downloads",
description=(
"Let users who can download also request a book without choosing a release, for "
"a book nobody has released yet or one no source has. The request waits for an "
"admin. Admins can always do this."
),
default=False,
user_overridable=True,
show_when={"field": "REQUESTS_ENABLED", "value": True},
),
]
+7
View File
@@ -28,9 +28,11 @@ from shelfmark.core.request_helpers import (
emit_ws_event,
extract_release_source_id,
normalize_positive_int,
populate_request_reviewers,
populate_request_usernames,
)
from shelfmark.core.request_validation import RequestStatus
from shelfmark.core.status_messages import explain_buckets, explain_request_rows
from shelfmark.core.viewer_redaction import (
is_public_download_id,
match_public_download_id,
@@ -335,9 +337,11 @@ def _request_row_log_context(row: dict[str, Any]) -> dict[str, Any]:
def _list_visible_requests(
user_db: UserDB, *, is_admin: bool, db_user_id: int | None
) -> list[dict[str, Any]]:
"""Every request with requester and reviewer names for an admin; a user's own, or none."""
if is_admin:
request_rows = user_db.list_requests()
populate_request_usernames(request_rows, user_db)
populate_request_reviewers(request_rows, user_db)
return request_rows
if db_user_id is None:
@@ -646,6 +650,8 @@ def register_activity_routes(
user_id=actor.owner_scope,
)
emit_request_updates(updated_requests)
# Plain wording for what the list shows, after the sync above has used the real text.
explain_buckets(status)
request_rows = _list_visible_requests(
user_db,
is_admin=actor.is_admin,
@@ -659,6 +665,7 @@ def register_activity_routes(
if f"request:{request_id}" in hidden_item_keys:
continue
visible_request_rows.append(row)
visible_request_rows = explain_request_rows(visible_request_rows)
if not actor.is_admin:
# Redacted only now: the delivery-state sync above matches on real task ids.
@@ -7,6 +7,7 @@ import threading
from typing import Any
from shelfmark.core.request_helpers import now_utc_iso
from shelfmark.core.utils import DB_BUSY_TIMEOUT_SECONDS
VALID_ACTIVITY_ITEM_TYPES = frozenset({"download", "request"})
ADMIN_VIEWER_SCOPE = "admin:shared"
@@ -82,7 +83,7 @@ class ActivityViewStateService:
self._lock = threading.Lock()
def _connect(self) -> sqlite3.Connection:
conn = sqlite3.connect(self._db_path)
conn = sqlite3.connect(self._db_path, timeout=DB_BUSY_TIMEOUT_SECONDS)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA foreign_keys = ON")
return conn
+14 -2
View File
@@ -226,14 +226,26 @@ def build_user_notification_test_response(
"""Build a notification test response using effective per-user routes."""
from shelfmark.core.config import config as app_config
routes_input = app_config.get("USER_NOTIFICATION_ROUTES", [], user_id=user_id)
effective_routes, is_own = app_config.get_for_user(
"USER_NOTIFICATION_ROUTES", [], user_id=user_id
)
routes_input = effective_routes
if isinstance(payload, dict):
if "USER_NOTIFICATION_ROUTES" in payload:
routes_input = payload.get("USER_NOTIFICATION_ROUTES")
elif "routes" in payload:
routes_input = payload.get("routes")
result = build_notification_test_result(routes_input, scope_label="personal", user_scope=True)
# The settings screens always post the routes they show, which for a user without their own
# is the inherited admin default. Trust it like at send time only when it is exactly that;
# anything else would become the user's own routes once saved, so it is checked.
user_scope = is_own or normalize_notification_routes(
routes_input
) != normalize_notification_routes(effective_routes)
result = build_notification_test_result(
routes_input, scope_label="personal", user_scope=user_scope, user_id=user_id
)
status_code = 200 if result.get("success", False) else 400
return result, status_code
+142
View File
@@ -0,0 +1,142 @@
"""App setup that does not depend on any route: the log filter, the persisted secret key with
the session cookie settings, and the baseline security headers.
"""
from __future__ import annotations
import logging
import os
from typing import TYPE_CHECKING
from shelfmark.core.logger import setup_logger
if TYPE_CHECKING:
from pathlib import Path
from flask import Flask
from werkzeug.wrappers import Response
logger = setup_logger(__name__)
FLASK_SECRET_KEY_MIN_BYTES = 32
class LogNoiseFilter(logging.Filter):
"""Filter out routine status endpoint requests and WebSocket upgrade errors to reduce log noise.
WebSocket upgrade errors are benign - Flask-SocketIO automatically falls back to polling transport.
The error occurs because Werkzeug's built-in server doesn't fully support WebSocket upgrades.
"""
def filter(self, record: logging.LogRecord) -> bool:
"""Return whether a log record should be emitted."""
message = record.getMessage() if hasattr(record, "getMessage") else str(record.msg)
# Exclude GET /api/status requests (polling noise)
if "GET /api/status" in message:
return False
# Exclude WebSocket upgrade errors (benign - falls back to polling)
if "write() before start_response" in message:
return False
# Exclude the Error on request line that precedes WebSocket errors
if record.levelno == logging.ERROR:
if "Error on request:" in message:
return False
# Filter WebSocket-related AssertionError tracebacks
if hasattr(record, "exc_info") and record.exc_info:
exc_type, exc_value = record.exc_info[0], record.exc_info[1]
if (
exc_type
and exc_type.__name__ == "AssertionError"
and exc_value
and "write() before start_response" in str(exc_value)
):
return False
return True
def configure_app_logging(app: Flask, app_logger: logging.Logger) -> None:
"""Send Flask's and Werkzeug's logs through the app's handlers, minus the routine noise."""
app.logger.handlers = app_logger.handlers
app.logger.setLevel(app_logger.level)
# Also handle Werkzeug's logger
werkzeug_logger = logging.getLogger("werkzeug")
werkzeug_logger.handlers = app_logger.handlers
werkzeug_logger.setLevel(app_logger.level)
# Add filter to suppress routine status endpoint polling logs and WebSocket upgrade errors
werkzeug_logger.addFilter(LogNoiseFilter())
def load_or_create_secret_key(config_dir: Path) -> bytes:
"""Load a persisted Flask secret key from config, or create one."""
secret_path = config_dir / ".flask_secret"
try:
if secret_path.exists():
secret_key = secret_path.read_bytes()
if len(secret_key) >= FLASK_SECRET_KEY_MIN_BYTES:
return secret_key
logger.warning(
"Invalid persisted Flask secret key at %s (length=%s). Regenerating.",
secret_path,
len(secret_key),
)
except OSError as exc:
logger.warning("Failed to read Flask secret key at %s: %s", secret_path, exc)
secret_key = os.urandom(64)
try:
secret_path.parent.mkdir(parents=True, exist_ok=True)
# Created private, and an existing file made private before the key goes in, so the key
# is never in a file others can read.
fd = os.open(secret_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "wb") as secret_file:
os.fchmod(secret_file.fileno(), 0o600)
secret_file.write(secret_key)
except OSError as exc:
logger.warning(
"Failed to persist Flask secret key at %s. Sessions may reset on restart: %s",
secret_path,
exc,
)
return secret_key
def configure_secret_key_and_session(
app: Flask, config_dir: Path, *, cookie_secure: bool, cookie_name: str
) -> None:
"""Set the secret key and the session cookie settings."""
app.config.update(
SECRET_KEY=load_or_create_secret_key(config_dir),
SESSION_COOKIE_HTTPONLY=True,
SESSION_COOKIE_SAMESITE="Lax",
SESSION_COOKIE_SECURE=cookie_secure,
SESSION_COOKIE_NAME=cookie_name,
PERMANENT_SESSION_LIFETIME=604800, # 7 days in seconds
)
def register_security_headers(app: Flask) -> None:
"""Add the baseline security headers to every response.
Call this at the place the hook should take in the ``after_request`` order, which runs
the hooks in reverse.
"""
@app.after_request
def set_security_headers(response: Response) -> Response:
"""Add baseline security headers to every response."""
response.headers.setdefault("X-Content-Type-Options", "nosniff")
response.headers.setdefault(
"Content-Security-Policy",
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self' ws: wss:",
)
response.headers.setdefault(
"Permissions-Policy", "camera=(), microphone=(), geolocation=()"
)
response.headers.setdefault("Cross-Origin-Embedder-Policy", "credentialless")
return response
+279
View File
@@ -0,0 +1,279 @@
"""Request hooks for the two ways a request can be authenticated without logging in: the
SHELFMARK_API_KEY credentials and the reverse proxy's identity headers.
"""
from __future__ import annotations
import sqlite3
from typing import TYPE_CHECKING, NoReturn
from flask import Response, g, jsonify, request, session
from shelfmark.core import api_key as api_key_module # module access lets tests monkeypatch the key
from shelfmark.core.config import config as app_config
from shelfmark.core.external_user_linking import upsert_external_user
from shelfmark.core.logger import setup_logger
from shelfmark.core.request_helpers import normalize_optional_text
from shelfmark.core.stats_routes import STATS_PATH
if TYPE_CHECKING:
from collections.abc import Callable
from flask import Flask
from shelfmark.core.user_db import UserDB
logger = setup_logger(__name__)
_OPERATIONAL_ERRORS = (OSError, RuntimeError, TypeError, ValueError, sqlite3.Error)
def _raise_runtime_error(message: str) -> NoReturn:
"""Raise RuntimeError with the message, for raising inside a try block (ruff TRY301)."""
raise RuntimeError(message)
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"})
_READ_ONLY_KEY_PATHS = frozenset({STATS_PATH})
def register_auth_middleware(
app: Flask,
*,
resolve_auth_mode: Callable[[], str],
get_user_db: Callable[[], UserDB | None],
) -> None:
"""Register the API-key and proxy-auth ``before_request`` hooks and the cookie-stripping
``after_request`` hook.
Call this at the place the hooks should take in the hook order: ``before_request`` hooks run
in registration order, ``after_request`` hooks in reverse. The API-key hook must come first
because the proxy hook skips requests it already authenticated.
"""
@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 and not api_key_module.SHELFMARK_API_KEY_READONLY:
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
scopes = {api_key_module.key_scope(candidate) for candidate in candidates}
scope = "admin" if "admin" in scopes else "readonly" if "readonly" in scopes else None
if scope is None:
return None
if resolve_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
if scope == "readonly":
# A read-only key is a dashboard credential: one GET endpoint, no session.
g.api_key_scope = "readonly"
if request.method != "GET" or request.path not in _READ_ONLY_KEY_PATHS:
return jsonify({"error": "This API key is read-only"}), 403
return None
g.api_key_scope = "admin"
try:
user_db = get_user_db()
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
def proxy_auth_middleware() -> Response | tuple[Response, int] | None:
"""Middleware to handle proxy authentication.
When AUTH_METHOD is set to "proxy", this middleware automatically
authenticates users based on headers set by the reverse proxy.
"""
auth_mode = resolve_auth_mode()
# Only run for proxy auth mode
if auth_mode != "proxy":
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
if request.path == "/api/health":
return None
def get_proxy_header(header_name: str) -> str | None:
"""Resolve proxy auth values from headers with WSGI env fallbacks."""
value = request.headers.get(header_name)
if value:
return value
env_key = f"HTTP_{header_name.upper().replace('-', '_')}"
value = request.environ.get(env_key)
if value:
return value
# Some proxies set authenticated username in REMOTE_USER (not as a header).
if header_name.lower().replace("_", "-") == "remote-user":
return request.environ.get("REMOTE_USER")
return None
user_db = get_user_db()
try:
user_header = (
normalize_optional_text(app_config.get("PROXY_AUTH_USER_HEADER", "X-Auth-User"))
or "X-Auth-User"
)
# Extract username from proxy header
username = get_proxy_header(user_header)
if not username:
if request.path.startswith("/api/auth/"):
return None
logger.warning(
"Proxy auth enabled but no username found in header '%s'", user_header
)
return jsonify({"error": "Authentication required. Proxy header not set."}), 401
# Resolve admin role for proxy sessions.
# If an admin group is configured, derive from groups header.
# Otherwise preserve the existing DB role for known users; a first-time user
# 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 = (
normalize_optional_text(
app_config.get("PROXY_AUTH_ADMIN_GROUP_HEADER", "X-Auth-Groups")
)
or "X-Auth-Groups"
)
admin_group_name = (
normalize_optional_text(app_config.get("PROXY_AUTH_ADMIN_GROUP_NAME", "")) or ""
)
is_admin = True
if admin_group_name:
groups_header = get_proxy_header(admin_group_header) or ""
user_groups_delimiter = "," if "," in groups_header else "|"
user_groups = [
g.strip() for g in groups_header.split(user_groups_delimiter) if g.strip()
]
is_admin = admin_group_name in user_groups
elif user_db is not None:
existing_db_user = user_db.get_user_cached(username=username)
if existing_db_user:
is_admin = existing_db_user.get("role") == "admin"
else:
is_admin = _proxy_default_is_admin(user_db)
# Create or update session
previous_username = session.get("user_id")
if previous_username and previous_username != username:
# Header identity changed mid-session; force reprovision for the new user.
session.pop("db_user_id", None)
session["user_id"] = username
session["is_admin"] = is_admin
# Provision proxy-authenticated users into users.db for multi-user features.
# Re-provision when db_user_id is missing/stale/mismatched to avoid broken
# sessions after DB resets or auth-mode transitions.
if user_db is not None:
raw_db_user_id = session.get("db_user_id")
session_db_user = None
if raw_db_user_id is not None:
try:
session_db_user = user_db.get_user_cached(user_id=int(raw_db_user_id))
except TypeError, ValueError:
session_db_user = None
session_db_username = (
str(session_db_user.get("username") or "").strip() if session_db_user else ""
)
needs_db_user_sync = (
raw_db_user_id is None
or session_db_user is None
or session_db_username != username
)
if needs_db_user_sync:
role = "admin" if is_admin else "user"
db_user, _ = upsert_external_user(
user_db,
auth_source="proxy",
username=username,
role=role,
collision_strategy="takeover",
context="proxy_request",
)
if db_user is None:
_raise_runtime_error("Unexpected proxy user sync result: no user returned")
session["db_user_id"] = db_user["id"]
session.permanent = False
except _OPERATIONAL_ERRORS:
logger.exception("Proxy auth middleware error")
return jsonify({"error": "Authentication error"}), 500
else:
return None
@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
+455
View File
@@ -0,0 +1,455 @@
"""Sign-in routes: ``POST /api/auth/login``, ``POST /api/auth/logout``, ``GET /api/auth/check``.
The failed-login counters and their lockout helpers live here too, as module state
shared by everything that imports them (``main`` re-exports the names).
"""
from __future__ import annotations
import os
import sqlite3
from datetime import UTC, datetime, timedelta
from typing import TYPE_CHECKING, Any
from flask import jsonify, request, session
from shelfmark.core.config import config as app_config
from shelfmark.core.cwa_user_sync import upsert_cwa_user
from shelfmark.core.logger import setup_logger
if TYPE_CHECKING:
from collections.abc import Callable
from pathlib import Path
from flask import Flask
from werkzeug.wrappers import Response
from shelfmark.core.user_db import UserDB
logger = setup_logger(__name__)
# Rate limiting for login attempts
# Map usernames to their failed-attempt counters and lockout timestamps.
failed_login_attempts: dict[str, dict[str, Any]] = {}
MAX_LOGIN_ATTEMPTS = 10
LOCKOUT_DURATION_MINUTES = 30
LOGIN_ATTEMPT_WARNING_THRESHOLD = 5
def cleanup_old_lockouts() -> None:
"""Remove expired lockout entries to prevent memory buildup."""
current_time = datetime.now(UTC)
expired_users = []
for username in list(failed_login_attempts):
lockout_until = _get_lockout_until(username, repair_if_locked=True)
if lockout_until is not None and lockout_until < current_time:
expired_users.append(username)
for username in expired_users:
logger.info("Lockout expired for user: %s", username)
# A login can clear the entry while this runs, so it may be gone already.
failed_login_attempts.pop(username, None)
def _get_lockout_until(username: str, *, repair_if_locked: bool = False) -> datetime | None:
"""Return a valid lockout timestamp for the user when one exists.
When a user has already crossed the lockout threshold but the timestamp is
missing or malformed, optionally repair the state to keep the lockout in
force rather than silently letting the user through.
"""
lockout_state = failed_login_attempts.get(username)
if lockout_state is None:
return None
lockout_until = lockout_state.get("lockout_until")
if isinstance(lockout_until, datetime):
return lockout_until
attempt_count = lockout_state.get("count")
if repair_if_locked and isinstance(attempt_count, int) and attempt_count >= MAX_LOGIN_ATTEMPTS:
repaired_lockout_until = datetime.now(UTC) + timedelta(minutes=LOCKOUT_DURATION_MINUTES)
lockout_state["lockout_until"] = repaired_lockout_until
logger.warning("Repaired missing lockout timestamp for locked account '%s'", username)
return repaired_lockout_until
if lockout_until is not None:
logger.warning("Ignoring invalid lockout timestamp for user '%s'", username)
return None
def is_account_locked(username: str) -> bool:
"""Check if an account is currently locked due to failed login attempts."""
cleanup_old_lockouts()
if username not in failed_login_attempts:
return False
lockout_until = _get_lockout_until(username, repair_if_locked=True)
return lockout_until is not None and datetime.now(UTC) < lockout_until
def record_failed_login(username: str, ip_address: str) -> bool:
"""Record a failed login attempt and lock account if threshold is reached.
Returns True if account is now locked, False otherwise.
"""
if username not in failed_login_attempts:
failed_login_attempts[username] = {"count": 0}
failed_login_attempts[username]["count"] += 1
count = failed_login_attempts[username]["count"]
logger.warning(
"Failed login attempt %s/%s for user '%s' from IP %s",
count,
MAX_LOGIN_ATTEMPTS,
username,
ip_address,
)
if count >= MAX_LOGIN_ATTEMPTS:
lockout_until = datetime.now(UTC) + timedelta(minutes=LOCKOUT_DURATION_MINUTES)
failed_login_attempts[username]["lockout_until"] = lockout_until
logger.warning(
"Account locked for user '%s' until %s due to %s failed login attempts",
username,
lockout_until.strftime("%Y-%m-%d %H:%M:%S"),
count,
)
return True
return False
def clear_failed_logins(username: str) -> None:
"""Clear failed login attempts for a user after successful login."""
if failed_login_attempts.pop(username, None) is not None:
logger.debug("Cleared failed login attempts for user: %s", username)
def _failed_login_response(username: str, ip_address: str) -> tuple[Response, int]:
"""Handle a failed login attempt by recording it and returning the appropriate response."""
is_now_locked = record_failed_login(username, ip_address)
if is_now_locked:
return jsonify(
{
"error": f"Account locked due to {MAX_LOGIN_ATTEMPTS} failed login attempts. Try again in {LOCKOUT_DURATION_MINUTES} minutes."
}
), 429
attempts_remaining = MAX_LOGIN_ATTEMPTS - failed_login_attempts[username]["count"]
if attempts_remaining <= LOGIN_ATTEMPT_WARNING_THRESHOLD:
return jsonify(
{"error": f"Invalid username or password. {attempts_remaining} attempts remaining."}
), 401
return jsonify({"error": "Invalid username or password."}), 401
def register_auth_routes(
app: Flask,
*,
get_user_db: Callable[[], UserDB | None],
resolve_auth_mode: Callable[[], str],
get_client_ip: Callable[[], str],
account_is_locked: Callable[[str], bool],
disable_local_auth: Callable[[], bool],
hide_local_auth: Callable[[], bool],
oidc_auto_redirect: Callable[[], bool],
get_cwa_db_path: Callable[[], Path | None],
check_password: Callable[[str, str], bool],
get_auth_check_admin_status: Callable[..., bool],
operational_errors: tuple[type[BaseException], ...],
) -> None:
"""Register the three auth routes.
Everything that lives in ``main.py`` or is patched there by tests is passed as a callable
that is called per request, so a runtime patch of any of them still takes effect.
``account_is_locked`` is one of them: ``main.is_account_locked`` is looked up on each login.
"""
@app.route("/api/auth/login", methods=["POST"])
def api_login() -> Response | tuple[Response, int]:
"""Login endpoint that validates credentials and creates a session.
Supports both built-in credentials and CWA database authentication.
Includes rate limiting: 10 failed attempts = 30 minute lockout.
Request Body:
username (str): Username
password (str): Password
remember_me (bool): Whether to extend session duration
Returns:
flask.Response: JSON with success status or error message.
"""
try:
ip_address = get_client_ip()
data = request.get_json(silent=True)
if not data:
return jsonify({"error": "No data provided"}), 400
auth_mode = resolve_auth_mode()
if auth_mode == "proxy":
return jsonify({"error": "Proxy authentication is enabled"}), 401
if auth_mode in ("builtin", "oidc") and disable_local_auth():
return jsonify({"error": "Local authentication is disabled"}), 403
if auth_mode == "oidc" and hide_local_auth():
return jsonify({"error": "Local authentication is disabled"}), 403
username = data.get("username", "").strip()
password = data.get("password", "")
remember_me = data.get("remember_me", False)
if not username or not password:
return jsonify({"error": "Username and password are required"}), 400
# Check if account is locked due to failed login attempts
if account_is_locked(username):
lockout_until = _get_lockout_until(username, repair_if_locked=True)
if lockout_until is None:
logger.error("Locked account '%s' is missing a lockout timestamp", username)
return jsonify(
{
"error": f"Account temporarily locked due to multiple failed login attempts. Try again in {LOCKOUT_DURATION_MINUTES} minutes."
}
), 429
remaining_time = (lockout_until - datetime.now(UTC)).total_seconds() / 60
logger.warning(
"Login attempt blocked for locked account '%s' from IP %s", username, ip_address
)
return jsonify(
{
"error": f"Account temporarily locked due to multiple failed login attempts. Try again in {int(remaining_time)} minutes."
}
), 429
# If no authentication is configured, authentication always succeeds
if auth_mode == "none":
session["user_id"] = username
session.permanent = remember_me
clear_failed_logins(username)
logger.info(
"Login successful for user '%s' from IP %s (no auth configured)",
username,
ip_address,
)
return jsonify({"success": True})
# Password authentication (builtin and OIDC modes)
# OIDC mode also allows password login as a fallback so admins don't get locked out
if auth_mode in ("builtin", "oidc"):
user_db = get_user_db()
if user_db is None:
logger.error("User database not available for %s auth", auth_mode)
return jsonify({"error": "Authentication service unavailable"}), 503
try:
db_user = user_db.get_user(username=username)
if not db_user:
return _failed_login_response(username, ip_address)
# Authenticate against DB user
if not db_user.get("password_hash") or not check_password(
db_user["password_hash"], password
):
return _failed_login_response(username, ip_address)
is_admin = db_user["role"] == "admin"
session["user_id"] = username
session["db_user_id"] = db_user["id"]
session["is_admin"] = is_admin
session.permanent = remember_me
clear_failed_logins(username)
logger.info(
"Login successful for user '%s' from IP %s (%s auth, is_admin=%s, remember_me=%s)",
username,
ip_address,
auth_mode,
is_admin,
remember_me,
)
return jsonify({"success": True})
except operational_errors as e:
logger.error_trace(f"Built-in auth error: {e}")
return jsonify({"error": "Authentication system error"}), 500
# CWA database authentication mode
if auth_mode == "cwa":
# Verify database still exists (it was validated at startup)
cwa_db_path = get_cwa_db_path()
if not cwa_db_path or not cwa_db_path.exists():
logger.error("CWA database at %s is no longer accessible", cwa_db_path)
return jsonify({"error": "Database configuration error"}), 500
try:
db_path = os.fspath(cwa_db_path)
db_uri = f"file:{db_path}?mode=ro&immutable=1"
conn = sqlite3.connect(db_uri, uri=True)
cur = conn.cursor()
cur.execute(
"SELECT password, role, email FROM user WHERE name = ?", (username,)
)
row = cur.fetchone()
conn.close()
# Check if user exists and password is correct
if not row or not row[0] or not check_password(row[0], password):
return _failed_login_response(username, ip_address)
# Check if user has admin role (ROLE_ADMIN = 1, bit flag)
user_role = row[1] if row[1] is not None else 0
is_admin = (user_role & 1) == 1
cwa_email = row[2] or None
db_user_id = None
user_db = get_user_db()
if user_db is not None:
role = "admin" if is_admin else "user"
db_user, _ = upsert_cwa_user(
user_db,
cwa_username=username,
cwa_email=cwa_email,
role=role,
context="cwa_login",
)
db_user_id = db_user["id"]
# Successful authentication - create session and clear failed attempts
session["user_id"] = username
session["is_admin"] = is_admin
if db_user_id is not None:
session["db_user_id"] = db_user_id
session.permanent = remember_me
clear_failed_logins(username)
logger.info(
"Login successful for user '%s' from IP %s (CWA auth, is_admin=%s, remember_me=%s)",
username,
ip_address,
is_admin,
remember_me,
)
return jsonify({"success": True})
except operational_errors as e:
logger.error_trace(f"CWA database error during login: {e}")
return jsonify({"error": "Authentication system error"}), 500
# Should not reach here, but handle gracefully
return jsonify({"error": "Unknown authentication mode"}), 500
except operational_errors as e:
logger.error_trace(f"Login error: {e}")
return jsonify({"error": "Login failed"}), 500
@app.route("/api/auth/logout", methods=["POST"])
def api_logout() -> Response | tuple[Response, int]:
"""Logout endpoint that clears the session.
For proxy auth, returns the logout URL if configured.
Returns:
flask.Response: JSON with success status and optional logout_url.
"""
try:
auth_mode = resolve_auth_mode()
ip_address = get_client_ip()
username = session.get("user_id", "unknown")
session.clear()
logger.info("Logout successful for user '%s' from IP %s", username, ip_address)
# For proxy auth, include logout URL if configured
if auth_mode == "proxy":
logout_url = app_config.get("PROXY_AUTH_LOGOUT_URL", "")
if logout_url:
return jsonify({"success": True, "logout_url": logout_url})
return jsonify({"success": True})
except operational_errors as e:
logger.error_trace(f"Logout error: {e}")
return jsonify({"error": "Logout failed"}), 500
@app.route("/api/auth/check", methods=["GET"])
def api_auth_check() -> Response | tuple[Response, int]:
"""Check if user has a valid session.
Returns:
flask.Response: JSON with authentication status, whether auth is required,
which auth mode is active, and whether user has admin privileges.
"""
try:
auth_mode = resolve_auth_mode()
# If no authentication is configured, access is allowed (full admin)
if auth_mode == "none":
return jsonify(
{
"authenticated": True,
"auth_required": False,
"auth_mode": "none",
"is_admin": True,
}
)
# Check if user has a valid session
is_authenticated = "user_id" in session
is_admin = get_auth_check_admin_status(auth_mode, {}, session)
display_name = None
user_db = get_user_db()
if is_authenticated and session.get("db_user_id") and user_db is not None:
try:
db_user = user_db.get_user(user_id=session["db_user_id"])
if db_user:
display_name = db_user.get("display_name") or None
except (sqlite3.Error, TypeError, ValueError) as exc:
logger.debug("Could not load display name for session user: %s", exc)
response_data = {
"authenticated": is_authenticated,
"auth_required": True,
"auth_mode": auth_mode,
"is_admin": is_admin if is_authenticated else False,
"username": session.get("user_id") if is_authenticated else None,
"display_name": display_name,
}
# Add logout URL for proxy auth if configured
if auth_mode == "proxy" and app_config.get("PROXY_AUTH_USER_HEADER", ""):
logout_url = app_config.get("PROXY_AUTH_LOGOUT_URL", "")
if logout_url:
response_data["logout_url"] = logout_url
if auth_mode in ("builtin", "oidc") and disable_local_auth():
response_data["hide_local_auth"] = True
# Add custom OIDC button label and SSO enforcement flags if configured
if auth_mode == "oidc":
oidc_button_label = app_config.get("OIDC_BUTTON_LABEL", "")
if oidc_button_label:
response_data["oidc_button_label"] = oidc_button_label
if hide_local_auth():
response_data["hide_local_auth"] = True
if oidc_auto_redirect():
response_data["oidc_auto_redirect"] = True
return jsonify(response_data)
except operational_errors as e:
logger.error_trace(f"Auth check error: {e}")
return jsonify(
{
"authenticated": False,
"auth_required": True,
"auth_mode": "unknown",
"is_admin": False,
}
)
File diff suppressed because it is too large. Load diff
+17 -6
View File
@@ -124,9 +124,10 @@ class Config:
self._field_map.clear()
self._cache.clear()
for key, (field, tab_name) in registry.get_settings_field_map().items():
self._field_map[key] = (field, tab_name)
self._cache[key] = registry.get_setting_value(field, tab_name)
with registry.config_files_read_once():
for key, (field, tab_name) in registry.get_settings_field_map().items():
self._field_map[key] = (field, tab_name)
self._cache[key] = registry.get_setting_value(field, tab_name)
self._loaded = True
@@ -212,6 +213,16 @@ class Config:
Returns:
The setting value, or default if not found
"""
return self.get_for_user(key, default, user_id=user_id)[0]
def get_for_user(
self, key: str, default: object = None, user_id: int | None = None
) -> tuple[object, bool]:
"""Like ``get``, plus whether the value is the user's own override.
Both come from a single read, so a settings save in between cannot make them
disagree. False means the deployment-wide value set by an admin or ENV.
"""
self._ensure_loaded()
@@ -221,15 +232,15 @@ class Config:
# Deployment-level ENV values always win.
if field.env_supported and registry.is_value_from_env(field):
return self._cache.get(key, default)
return self._cache.get(key, default), False
# User overrides are only available for explicitly overridable fields.
if user_id is not None and getattr(field, "user_overridable", False):
user_value = self._get_user_override(user_id, key)
if user_value is not None:
return user_value
return user_value, True
return self._cache.get(key, default)
return self._cache.get(key, default), False
def __getattr__(self, name: str) -> object:
"""Allow attribute-style access to settings.
+8 -2
View File
@@ -16,7 +16,7 @@ from shelfmark.core.request_helpers import (
normalize_optional_text,
now_utc_iso,
)
from shelfmark.core.utils import transform_cover_url
from shelfmark.core.utils import DB_BUSY_TIMEOUT_SECONDS, transform_cover_url
logger = setup_logger(__name__)
@@ -100,7 +100,7 @@ class DownloadHistoryService:
self._lock = threading.Lock()
def _connect(self) -> sqlite3.Connection:
conn = sqlite3.connect(self._db_path)
conn = sqlite3.connect(self._db_path, timeout=DB_BUSY_TIMEOUT_SECONDS)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA foreign_keys = ON")
return conn
@@ -214,6 +214,12 @@ class DownloadHistoryService:
row.get("download_path")
),
"added_time": DownloadHistoryService._iso_to_epoch(row.get("queued_at")),
# When it ended (epoch seconds, like added_time); None while it is still active.
"finished_at": (
None
if row.get("final_status") == "active"
else DownloadHistoryService._iso_to_epoch(row.get("terminal_at"))
),
"user_id": row.get("user_id"),
"username": row.get("username"),
"request_id": row.get("request_id"),
+282
View File
@@ -0,0 +1,282 @@
"""Download recording: write each queued download to the history and, when it reaches a
terminal status, finalize the row, notify, push the activity update and reopen a failed request.
"""
from __future__ import annotations
import sqlite3
from typing import TYPE_CHECKING, Any
from shelfmark.core.logger import setup_logger
from shelfmark.core.models import TERMINAL_QUEUE_STATUSES, QueueStatus
from shelfmark.core.notifications import (
NotificationContext,
NotificationEvent,
notify_admin,
notify_user,
)
from shelfmark.core.request_helpers import (
emit_ws_event,
normalize_optional_text,
normalize_positive_int,
)
from shelfmark.core.request_policy import normalize_content_type, normalize_source
from shelfmark.core.requests_service import reopen_failed_request
from shelfmark.core.viewer_redaction import viewer_download_id
from shelfmark.download import orchestrator as backend
from shelfmark.release_sources import get_source_display_name
if TYPE_CHECKING:
from collections.abc import Callable
from shelfmark.core.activity_view_state_service import ActivityViewStateService
from shelfmark.core.download_history_service import DownloadHistoryService
from shelfmark.core.user_db import UserDB
logger = setup_logger(__name__)
_OPERATIONAL_ERRORS = (OSError, RuntimeError, TypeError, ValueError, sqlite3.Error)
def _queue_status_to_final_activity_status(status: QueueStatus) -> str | None:
"""The status to record in download history, or None when the download has not finished."""
return status.value if status in TERMINAL_QUEUE_STATUSES else None
def _queue_status_to_notification_event(status: QueueStatus) -> NotificationEvent | None:
"""The notification for a finished download, or None for a status that sends none."""
if status == QueueStatus.COMPLETE:
return NotificationEvent.DOWNLOAD_COMPLETE
if status == QueueStatus.ERROR:
return NotificationEvent.DOWNLOAD_FAILED
return None
def _notify_admin_for_terminal_download_status(
*, task_id: str, status: QueueStatus, task: Any
) -> None:
"""Notify the admins, and the user who owns the task, that a download completed or failed.
A notifier that raises is logged and does not stop the other.
"""
event = _queue_status_to_notification_event(status)
if event is None:
return
raw_owner_user_id = getattr(task, "user_id", None)
try:
owner_user_id = int(raw_owner_user_id) if raw_owner_user_id is not None else None
except TypeError, ValueError:
owner_user_id = None
content_type = normalize_optional_text(getattr(task, "content_type", None))
context = NotificationContext(
event=event,
title=str(getattr(task, "title", "Unknown title") or "Unknown title"),
author=str(getattr(task, "author", "Unknown author") or "Unknown author"),
username=normalize_optional_text(getattr(task, "username", None)),
content_type=normalize_content_type(content_type) if content_type is not None else None,
format=normalize_optional_text(getattr(task, "format", None)),
source=normalize_source(getattr(task, "source", None)),
error_message=(
normalize_optional_text(getattr(task, "status_message", None))
if event == NotificationEvent.DOWNLOAD_FAILED
else None
),
)
try:
notify_admin(event, context)
except (RuntimeError, TypeError, ValueError) as exc:
logger.warning(
"Failed to trigger admin notification for download %s (%s): %s",
task_id,
status.value,
exc,
)
if owner_user_id is None:
return
try:
notify_user(owner_user_id, event, context)
except (RuntimeError, TypeError, ValueError) as exc:
logger.warning(
"Failed to trigger user notification for download %s (%s, user_id=%s): %s",
task_id,
status.value,
owner_user_id,
exc,
)
def register_download_recording(
*,
get_user_db: Callable[[], UserDB | None],
get_download_history_service: Callable[[], DownloadHistoryService | None],
get_activity_view_state_service: Callable[[], ActivityViewStateService | None],
emit_request_update_events: Callable[[list[dict[str, Any]]], None],
ws_manager: object,
) -> None:
"""Hook the download queue so every queued and finished download is recorded."""
def _emit_activity_update_for_task(*, payload: dict[str, Any], task: Any) -> None:
"""Send an activity update to the admins and to the user who owns the task."""
owner_user_id = normalize_positive_int(getattr(task, "user_id", None))
emit_ws_event(
ws_manager,
event_name="activity_update",
room="admins",
payload=payload,
)
if owner_user_id is None:
return
# The owner knows a request-linked download by its opaque id, as /api/status
# shows it, since the task id can carry a private tracker's passkey (#1418).
task_id = payload.get("task_id")
if isinstance(task_id, str):
payload = {
**payload,
"task_id": viewer_download_id(task_id, getattr(task, "request_id", None)),
}
emit_ws_event(
ws_manager,
event_name="activity_update",
room=f"user_{owner_user_id}",
payload=payload,
)
def _record_download_queued(task_id: str, task: Any) -> None:
"""Persist initial download record when a task enters the queue."""
history_service = get_download_history_service()
if history_service is None:
return
owner_user_id = normalize_positive_int(getattr(task, "user_id", None))
request_id = normalize_positive_int(getattr(task, "request_id", None))
origin = "requested" if request_id else "direct"
source_name = normalize_source(getattr(task, "source", None))
source_display = get_source_display_name(source_name)
try:
history_service.record_download(
task_id=task_id,
user_id=owner_user_id,
username=normalize_optional_text(getattr(task, "username", None)),
request_id=request_id,
source=source_name,
source_display_name=source_display,
title=str(getattr(task, "title", "Unknown title") or "Unknown title"),
author=normalize_optional_text(getattr(task, "author", None)),
file_format=normalize_optional_text(getattr(task, "format", None)),
size=normalize_optional_text(getattr(task, "size", None)),
preview=normalize_optional_text(getattr(task, "preview", None)),
content_type=normalize_optional_text(getattr(task, "content_type", None)),
downloads=getattr(task, "downloads", None),
origin=origin,
retry_payload=backend.serialize_task_for_retry(task),
)
except _OPERATIONAL_ERRORS as exc:
logger.warning("Failed to record download at queue time for task %s: %s", task_id, exc)
return
view_state_service = get_activity_view_state_service()
if view_state_service is None:
return
try:
cleared_view_state = 0
cleared_view_state += view_state_service.clear_item_for_all_viewers(
item_type="download",
item_key=f"download:{task_id}",
)
if request_id is not None:
cleared_view_state += view_state_service.clear_item_for_all_viewers(
item_type="request",
item_key=f"request:{request_id}",
)
if cleared_view_state > 0:
_emit_activity_update_for_task(
task=task,
payload={
"kind": "activity_reset",
"task_id": task_id,
},
)
except _OPERATIONAL_ERRORS as exc:
logger.warning("Failed to reset activity viewer state for task %s: %s", task_id, exc)
def _record_download_terminal_snapshot(task_id: str, status: QueueStatus, task: Any) -> None:
"""Record a finished download: notify, finalize its history and tell the Activity view.
A failed download that cannot be retried also reopens the request it was fulfilling.
"""
_notify_admin_for_terminal_download_status(task_id=task_id, status=status, task=task)
final_status = _queue_status_to_final_activity_status(status)
if final_status is None:
return
history_service = get_download_history_service()
view_state_service = get_activity_view_state_service()
user_db = get_user_db()
finalized_download = False
if history_service is not None:
try:
history_service.finalize_download(
task_id=task_id,
final_status=final_status,
status_message=normalize_optional_text(getattr(task, "status_message", None)),
download_path=normalize_optional_text(getattr(task, "download_path", None)),
retry_payload=backend.serialize_task_for_retry(task),
)
finalized_download = True
except _OPERATIONAL_ERRORS as exc:
logger.warning("Failed to finalize download history for task %s: %s", task_id, exc)
if finalized_download:
_emit_activity_update_for_task(
task=task,
payload={
"kind": "download_terminal",
"task_id": task_id,
"status": final_status,
},
)
if user_db is None or status != QueueStatus.ERROR:
return
request_id = normalize_positive_int(getattr(task, "request_id", None))
if request_id is None:
return
if backend.can_retry_download_task(task, status):
return
raw_error_message = getattr(task, "status_message", None)
fallback_reason = (
raw_error_message.strip()
if isinstance(raw_error_message, str) and raw_error_message.strip()
else "Download failed"
)
try:
reopened_request = reopen_failed_request(
user_db,
request_id=request_id,
failure_reason=fallback_reason,
)
if reopened_request is not None:
if view_state_service is not None:
view_state_service.clear_item_for_all_viewers(
item_type="request",
item_key=f"request:{request_id}",
)
emit_request_update_events([reopened_request])
except _OPERATIONAL_ERRORS as exc:
logger.warning(
"Failed to reopen request %s after terminal download error %s: %s",
request_id,
task_id,
exc,
)
backend.book_queue.set_queue_hook(_record_download_queued)
backend.book_queue.set_terminal_status_hook(_record_download_terminal_snapshot)
+286
View File
@@ -0,0 +1,286 @@
"""Mark audiobook requests for books that no store sells as an audiobook.
A pending audiobook request that no source ever matches sits in Needs Review for good,
and an admin ends up looking it up by hand to learn that there is no recording to find.
This does that lookup: once a request has gone a configured number of consecutive
auto-download passes without a strict match, Audible's US and UK catalogues are asked,
through the Audiobookshelf server Shelfmark already talks to, whether the book exists as
an audiobook at all.
- Not listed on either store: the request stays pending and its failure reason says so,
for an admin to decline or keep. Audible search misses real books (collection-only
novellas, retitled releases), so the check never declines on its own.
- Listed: the request stays pending and its failure reason says so, with the ASIN, so
the admin sees why it waits. Either way the store is not asked again for a week.
- The store or the server failed, or the request's search budget was spent: nothing is
decided. An error is not "no audiobook". Audiobookshelf answers a failed Audible search
with an empty list rather than an error, so before a "not listed" counts, a book both
stores are known to sell (``CANARY``) is looked up too; if that is not found either,
the answer is "unknown".
The pass counter lives in the request's ``book_data`` beside the retry bookkeeping. A
pass that matched the book clears it, and so does any rejection (``requests_service``
drops it), so a request an admin reopens starts counting afresh. Only audiobook requests are checked: ebooks have no store
that could vouch for their absence.
"""
from __future__ import annotations
import time
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from typing import TYPE_CHECKING, Any
import requests
from shelfmark.core.config import config as app_config
from shelfmark.core.existence_match import StoreMatch, find_match, search_title, us_spelling
from shelfmark.core.library_providers import audiobookshelf
from shelfmark.core.logger import setup_logger
from shelfmark.core.request_helpers import coerce_int
from shelfmark.core.request_validation import RequestStatus
if TYPE_CHECKING:
from shelfmark.core.auto_download import AutoDownloadOutcome
from shelfmark.core.user_db import UserDB
logger = setup_logger(__name__)
ENABLED_SETTING = "AUTO_DOWNLOAD_EXISTENCE_CHECK_ENABLED"
AFTER_PASSES_SETTING = "AUTO_DOWNLOAD_EXISTENCE_CHECK_AFTER_PASSES"
DEFAULT_AFTER_PASSES = 3
# A book the store listed is asked about again after this long, not every pass.
RECHECK_DAYS = 7
# Audiobookshelf's provider ids for the two Audible stores asked, in order.
STORES: tuple[tuple[str, str], ...] = (("audible", "US"), ("audible.uk", "UK"))
US_STORE = "audible"
# Where the bookkeeping sits in a request's book_data, beside ``failed_releases``.
BOOK_DATA_KEY = "existence_check"
# One store lookup may take this long, and one is not started with less than this of the
# request's search budget left: the pass must not stall on a store that stopped answering.
_LOOKUP_TIMEOUT = 15.0
_MIN_LOOKUP_BUDGET = 5.0
# A book both stores sell, asked about before a "not listed" is believed: Audiobookshelf
# answers a failed Audible search with an empty list, which reads the same as "no such book".
CANARY = ("The Hobbit", "J.R.R. Tolkien")
# Outcomes of a pass that mean the request found its book, so the count starts over.
_MATCHED = frozenset({"queued", "in_library"})
NOT_LISTED_REASON = "Not found on Audible (US/UK) after {passes} searches, {date}"
LISTED_REASON = "Exists on Audible ({asin}), not on our sources yet"
@dataclass(frozen=True)
class ExistenceResult:
"""What the stores said: ``found``, ``not_found`` or ``unknown`` (nothing decided)."""
status: str
match: StoreMatch | None = None
store: str | None = None
detail: str = ""
def enabled() -> bool:
return bool(app_config.get(ENABLED_SETTING, False))
def passes_before_check() -> int:
"""How many consecutive no-match passes a request goes through before the lookup."""
raw = coerce_int(app_config.get(AFTER_PASSES_SETTING, DEFAULT_AFTER_PASSES), 0)
return raw if raw >= 1 else DEFAULT_AFTER_PASSES
def _search(
title: str, author: str, provider: str, store: str, deadline: float | None
) -> list[dict[str, Any]] | ExistenceResult:
"""One store search, or the ``unknown`` result that stands in for it."""
timeout = _LOOKUP_TIMEOUT
if deadline is not None:
left = deadline - time.monotonic()
if left < _MIN_LOOKUP_BUDGET:
return ExistenceResult(
"unknown", store=store, detail=f"no search time left for the {store} store"
)
timeout = min(timeout, left)
try:
return audiobookshelf.search_books(title, author, provider, timeout=timeout)
except (requests.RequestException, ValueError) as exc:
return ExistenceResult("unknown", store=store, detail=f"{store} lookup failed: {exc}")
def look_up(
title: str | None, author: str | None, *, budget_seconds: float | None = None
) -> ExistenceResult:
"""Ask Audible US, then UK, whether ``title`` by ``author`` exists as an audiobook.
The lookups share ``budget_seconds`` when one is given; a store that cannot be asked
within it is not asked, and the answer is ``unknown``. Any server or store error is
``unknown`` too, and so is a "not listed" that the stores also give for ``CANARY``:
an error never says the book is missing.
"""
query = search_title(title, author)
if not query:
return ExistenceResult("unknown", detail="the request has no title to search")
deadline = None if budget_seconds is None else time.monotonic() + budget_seconds
for provider, store in STORES:
store_query = us_spelling(query) if provider == US_STORE else query
results = _search(store_query, author or "", provider, store, deadline)
if isinstance(results, ExistenceResult):
return results
match = find_match(title, author, results)
if match is not None:
return ExistenceResult("found", match=match, store=store)
canary_title, canary_author = CANARY
for provider, store in STORES:
results = _search(canary_title, canary_author, provider, store, deadline)
if isinstance(results, ExistenceResult):
return results
if find_match(canary_title, canary_author, results) is None:
return ExistenceResult(
"unknown",
store=store,
detail=f"the {store} store did not list {canary_title} either; taken as a failure",
)
return ExistenceResult("not_found")
def without_state(book_data: dict[str, Any]) -> dict[str, Any] | None:
"""``book_data`` without this check's bookkeeping, or None when it has none."""
if BOOK_DATA_KEY not in book_data:
return None
return _with_state(book_data, None)
def _state(book_data: dict[str, Any]) -> dict[str, Any]:
raw = book_data.get(BOOK_DATA_KEY)
return dict(raw) if isinstance(raw, dict) else {}
def _with_state(book_data: dict[str, Any], state: dict[str, Any] | None) -> dict[str, Any]:
cleared = {key: value for key, value in book_data.items() if key != BOOK_DATA_KEY}
if state:
cleared[BOOK_DATA_KEY] = state
return cleared
def _checked_recently(state: dict[str, Any], now: datetime) -> bool:
"""True when the store said "found" less than ``RECHECK_DAYS`` ago."""
if state.get("result") not in ("found", "not_found"):
return False
try:
checked_at = datetime.fromisoformat(str(state.get("checked_at") or ""))
except ValueError:
return False
if checked_at.tzinfo is None:
checked_at = checked_at.replace(tzinfo=UTC)
return now - checked_at < timedelta(days=RECHECK_DAYS)
def after_request(
user_db: UserDB,
row: dict[str, Any],
outcome: AutoDownloadOutcome,
*,
admin_user_id: int,
content_type: str,
budget_seconds: float | None = None,
now: datetime | None = None,
) -> str | None:
"""Count the pass for one request and, when it is time, ask the stores.
Called by the auto-download pass after each request with that request's outcome.
Returns what happened: ``counted`` (not yet time), ``waiting`` (the store listed it
recently), ``listed`` (the store lists it; the request stays pending and says so),
``not_listed`` (not on either store; stays pending and says so), ``unknown`` (lookup failed, nothing changed) or None when
the request was not an audiobook no-match, or the check is off.
"""
if content_type != "audiobook" or not enabled():
return None
request_id = int(row["id"])
# The pass may have written to the request (a failed release, a status change), so
# work from what is stored now, not from the row the pass started with.
current = user_db.get_request(request_id)
if current is None:
return None
book_data = current.get("book_data")
if not isinstance(book_data, dict):
return None
state = _state(book_data)
if outcome.status != "no_match":
if outcome.status in _MATCHED and state:
# The book was found; a retry after a failed download starts counting afresh.
user_db.update_request(request_id, book_data=_with_state(book_data, None))
return None
if current.get("status") != RequestStatus.PENDING:
return None
moment = now or datetime.now(UTC)
passes = coerce_int(state.get("no_match_passes"), 0) + 1
state["no_match_passes"] = passes
# Written before the lookup, so a pass still counts if the lookup never finishes.
user_db.update_request(
request_id,
expected_current_status=RequestStatus.PENDING,
book_data=_with_state(book_data, state),
)
if passes < passes_before_check():
return "counted"
if _checked_recently(state, moment):
return "waiting"
title = str(book_data.get("title") or "")
author = str(book_data.get("author") or "")
result = look_up(title, author, budget_seconds=budget_seconds)
if result.status == "unknown":
logger.warning(
"existence check: request %s (%s) not decided: %s", request_id, title, result.detail
)
return "unknown"
if result.status == "found" and result.match is not None:
state.update(
result="found",
checked_at=moment.isoformat(timespec="seconds"),
asin=result.match.asin,
store=result.store,
)
user_db.update_request(
request_id,
expected_current_status=RequestStatus.PENDING,
book_data=_with_state(book_data, state),
last_failure_reason=LISTED_REASON.format(asin=result.match.asin),
)
logger.info(
"existence check: request %s (%s) is on Audible %s as %s; still pending",
request_id,
title,
result.store,
result.match.asin,
)
return "listed"
# Not on either store. Audible search misses too much to decline on that alone (books
# sold only in a collection, retitled releases, fan recordings), so the request stays
# pending for an admin to decide, with the reason saying what the store answered.
state.update(result="not_found", checked_at=moment.isoformat(timespec="seconds"))
user_db.update_request(
request_id,
expected_current_status=RequestStatus.PENDING,
book_data=_with_state(book_data, state),
last_failure_reason=NOT_LISTED_REASON.format(passes=passes, date=moment.date().isoformat()),
)
logger.info(
"existence check: request %s (%s) not on Audible US or UK after %d searches; "
"left pending for review",
request_id,
title,
passes,
)
return "not_listed"
+273
View File
@@ -0,0 +1,273 @@
"""Does an Audible catalogue result name the audiobook we want?
Text work only, for the results Audiobookshelf's ``/api/search/books`` returns from its
Audible providers (``audible`` is the US store, ``audible.uk`` the UK one). A result names
the wanted book when its title, normalised the same way as the wanted title, is the same
sequence of words (or one begins the other, see ``titles_match``), and one of the wanted
author's surnames appears in its author field.
Normalising a title: diacritics are folded; bracketed notes such as "(Unabridged)" go; a
leading series label ("Robert Langdon, Book 4 - ") goes; the subtitle after ":", ", or",
" aka " or a spaced dash is dropped; "&" reads as "and"; a possessive "'s" is dropped; and
a roman, spelled-out or digit numeral all become the digit ("Part II", "Part Two" and
"Part 2" are one title). Stopwords are left out of the comparison.
The matching leans towards "found" on purpose. A wrong "found" leaves a request pending,
where it already was; a wrong "not found" declines a book that exists.
Standard library only, on purpose: the stress test against a live library runs this file
as-is next to the Audiobookshelf server, so what is tested is what ships.
"""
from __future__ import annotations
import re
import unicodedata
from dataclasses import dataclass
_STOPWORDS = frozenset({"a", "an", "the", "of", "and", "or", "to", "in", "on", "for", "with", "by"})
_NUMERALS = {
"zero": "0",
"one": "1",
"two": "2",
"three": "3",
"four": "4",
"five": "5",
"six": "6",
"seven": "7",
"eight": "8",
"nine": "9",
"ten": "10",
"eleven": "11",
"twelve": "12",
"thirteen": "13",
"fourteen": "14",
"fifteen": "15",
"sixteen": "16",
"seventeen": "17",
"eighteen": "18",
"nineteen": "19",
"twenty": "20",
"i": "1",
"ii": "2",
"iii": "3",
"iv": "4",
"v": "5",
"vi": "6",
"vii": "7",
"viii": "8",
"ix": "9",
"x": "10",
"xi": "11",
"xii": "12",
"xiii": "13",
"xiv": "14",
"xv": "15",
"xvi": "16",
"xvii": "17",
"xviii": "18",
"xix": "19",
"xx": "20",
}
# British spellings the US store does not find ("The Armour of Light" is "The Armor of
# Light" there). Titles compare in the US spelling, and the US store is asked in it.
_US_SPELLING = {
"armour": "armor",
"behaviour": "behavior",
"colour": "color",
"colours": "colors",
"endeavour": "endeavor",
"favour": "favor",
"harbour": "harbor",
"honour": "honor",
"humour": "humor",
"labour": "labor",
"neighbour": "neighbor",
"neighbours": "neighbors",
"rumour": "rumor",
"rumours": "rumors",
"saviour": "savior",
"splendour": "splendor",
"valour": "valor",
}
_US_SPELLING_WORD = re.compile(r"\b(" + "|".join(_US_SPELLING) + r")\b", re.IGNORECASE)
_BRACKETED = re.compile(r"\([^)]*\)|\[[^\]]*\]|\{[^}]*\}")
# A dash with space on at least one side splits off a part: "Born a Crime- Stories from
# ...", "Robert Langdon, Book 4 - Inferno". Hyphenated words ("Sword-Sworn") survive.
_DASH = re.compile(r"\s+[-–—]+\s*|\s*[-–—]+\s+")
# ": subtitle", ", or Alternative Title", " aka Other Title": the first ends the title.
_SUBTITLE = re.compile(r"\s*(?::|,\s+or\b|\s+aka\b).*$", re.IGNORECASE)
# A leading part that ends in a series position: "Mayfair Witches 01", "Robert Langdon,
# Book 4", "Discworld #3". A plain number does not count ("Fahrenheit 451 - A Novel").
_NUMBERED_PART = re.compile(
r"(?:\b(?:book|vol(?:ume)?|part|no)\.?\s*\d+(?:\.\d+)?|\b0\d+|#\d+(?:\.\d+)?)$", re.IGNORECASE
)
# A series label marked by a bracketed number in front of the title: "HGTTG [01] The
# Hitchhiker's Guide to the Galaxy".
_BRACKETED_LABEL = re.compile(r"^[^\[\]()]{0,40}?[\[(]\s*#?\d+(?:\.\d+)?\s*[\])]\s*(?=\S)")
# A zero-padded track number in front of a title ("04 The Visitor").
_TRACK_NUMBER = re.compile(r"^0\d\s+(?=\S)")
_POSSESSIVE = re.compile(r"[’']s\b")
_SPLIT = re.compile(r"[^a-z0-9]+")
# Separators between the names in an author field: "Anne Rice, Christopher Rice",
# "Kristin Hannah/Polly Stone", "Lee Child & Andrew Child", "Douglas Adams and ...".
_AUTHOR_SPLIT = re.compile(r"\s*(?:[,;/&]|\band\b)\s*", re.IGNORECASE)
# A prefix match ("Start with Why" for "Start with Why 15th Anniversary Edition") needs
# this many words in the shorter title, so a one-word title never matches a longer one.
_MIN_PREFIX_WORDS = 2
def _fold(text: str) -> str:
"""ASCII-folded, lower-cased text: diacritics and typographic quotes removed."""
decomposed = unicodedata.normalize("NFKD", text)
stripped = "".join(ch for ch in decomposed if not unicodedata.combining(ch))
return stripped.replace("’", "'").replace("‘", "'").lower()
def _words(text: str) -> list[str]:
folded = _fold(text).replace("&", " and ")
folded = _POSSESSIVE.sub("", folded).replace("'", "")
return [_US_SPELLING.get(word, word) for word in _SPLIT.split(folded) if word]
def us_spelling(text: str) -> str:
"""``text`` with the British spellings in ``_US_SPELLING`` turned American."""
def _swap(match: re.Match[str]) -> str:
word = match.group(0)
us = _US_SPELLING[word.lower()]
return us.capitalize() if word[0].isupper() else us
return _US_SPELLING_WORD.sub(_swap, text)
def search_title(
title: str | None, author: str | None = None, *, keep_subtitle: bool = False
) -> str:
"""The title alone, as it is sent to the store.
Bracketed notes ("(Unabridged)", "[Series 02]") go. Of the parts a spaced dash
separates, leading ones that are a numbered series label or the author's name go too,
and the first that is left is the title; the rest is subtitle. A subtitle after ":",
", or" and " aka " goes (unless ``keep_subtitle``), and so does a trailing ellipsis.
"""
original = (title or "").strip()
text = _BRACKETED_LABEL.sub("", original)
text = _BRACKETED.sub(" - ", text)
parts = [part.strip(" ,;") for part in _DASH.split(text)]
parts = [part for part in parts if part]
author_words = _words(author or "")
while len(parts) > 1 and (
_NUMBERED_PART.search(parts[0]) or (author_words and _words(parts[0]) == author_words)
):
parts.pop(0)
if keep_subtitle:
text = " ".join(parts)
else:
text = parts[0] if parts else ""
text = _SUBTITLE.sub("", text)
text = _TRACK_NUMBER.sub("", text)
text = re.sub(r"\s+", " ", text).strip(" ,;")
text = re.sub(r"\s*(?:\.{3}|…)$", "", text)
return text or original
def title_words(
title: str | None, author: str | None = None, *, keep_subtitle: bool = False
) -> tuple[str, ...]:
"""The comparable words of a title, in order."""
words = _words(search_title(title, author, keep_subtitle=keep_subtitle))
return tuple(_NUMERALS.get(word, word) for word in words if word not in _STOPWORDS)
def _same_title(wanted: tuple[str, ...], candidate: tuple[str, ...]) -> bool:
if not wanted or not candidate:
return False
if wanted == candidate:
return True
shorter, longer = sorted((wanted, candidate), key=len)
return len(shorter) >= _MIN_PREFIX_WORDS and longer[: len(shorter)] == shorter
def titles_match(wanted: str | None, candidate: str | None, author: str | None = None) -> bool:
"""True when the two titles name the same book once normalised.
Same words, or one title's words begin the other's when the shorter has at least two
("Merry Christmas" and "Merry Christmas, Alex Cross"). Each title is compared both
without and with its subtitle, so "The Mummy, or Ramses the Damned" matches "The Mummy
or Ramses the Damned". A store title with a series in front of a colon ("Ramses the
Damned: The Reign of Osiris") is also tried without it.
"""
wanted_forms = {
title_words(wanted, author),
title_words(wanted, author, keep_subtitle=True),
}
candidate_title = candidate or ""
candidate_forms = {
title_words(candidate_title),
title_words(candidate_title, keep_subtitle=True),
}
if ":" in candidate_title:
candidate_forms.add(title_words(candidate_title.split(":", 1)[1]))
return any(_same_title(w, c) for w in wanted_forms for c in candidate_forms)
def author_surnames(author: str | None) -> set[str]:
"""The surnames in an author field, one per name.
"Lee Child" gives {"child"}; "R.C. Bray, Andy Weir" gives {"bray", "weir"}; "Child,
Lee" gives {"child", "lee"}, which is harmless as the match is on any of them.
"""
surnames = set()
for name in _AUTHOR_SPLIT.split(author or ""):
words = [w for w in _SPLIT.split(_fold(name)) if len(w) >= 2 and w not in _STOPWORDS]
if words:
surnames.add(words[-1])
return surnames
def author_matches(wanted_author: str | None, candidate_author: str | None) -> bool:
"""True when one of the wanted author's surnames is in the result's author field.
A wanted author with no surname (empty, or initials only) matches on title alone.
"""
surnames = author_surnames(wanted_author)
if not surnames:
return True
return bool(surnames & set(_SPLIT.split(_fold(candidate_author or ""))))
@dataclass(frozen=True)
class StoreMatch:
"""The catalogue entry that names the wanted book."""
asin: str
title: str
author: str
def find_match(
wanted_title: str | None, wanted_author: str | None, results: list[dict]
) -> StoreMatch | None:
"""The first store result that is the wanted book, or None.
``results`` is what ``/api/search/books`` returns: dicts with ``title``, ``author`` and
``asin``. Results without an ASIN are not catalogue entries and are skipped.
"""
for result in results:
if not isinstance(result, dict):
continue
asin = str(result.get("asin") or "").strip().upper()
title = str(result.get("title") or "")
author = str(result.get("author") or "")
if not asin:
continue
if titles_match(wanted_title, title, wanted_author) and author_matches(
wanted_author, author
):
return StoreMatch(asin=asin, title=title, author=author)
return None
+35
View File
@@ -0,0 +1,35 @@
"""Which file formats are enabled, by content type.
The one place that reads the Supported Book Formats and Supported Audiobook Formats
settings. It sits in ``core`` and imports nothing heavy, so release sources and
post-processing can both use it without an import cycle.
"""
from __future__ import annotations
import shelfmark.core.config as core_config
from shelfmark.core.utils import (
DEFAULT_AUDIOBOOK_FORMATS,
DEFAULT_EBOOK_FORMATS,
is_audiobook,
normalize_format_list,
)
def get_supported_formats() -> list[str]:
"""Get current supported book formats from the config singleton."""
formats = core_config.config.get("SUPPORTED_FORMATS", None)
return normalize_format_list(formats, DEFAULT_EBOOK_FORMATS)
def get_supported_audiobook_formats() -> list[str]:
"""Get current supported audiobook formats from the config singleton."""
formats = core_config.config.get("SUPPORTED_AUDIOBOOK_FORMATS", None)
return normalize_format_list(formats, DEFAULT_AUDIOBOOK_FORMATS)
def get_supported_formats_for(content_type: str | None) -> list[str]:
"""Supported formats for a task's content type: audiobook formats or book formats."""
if is_audiobook(content_type):
return get_supported_audiobook_formats()
return get_supported_formats()
+203
View File
@@ -0,0 +1,203 @@
"""Background scheduler for Hardcover wishlist sync + auto-download.
Runs an in-process daemon thread (mirroring the download coordinator pattern in
``shelfmark.download.orchestrator``) that periodically syncs configured Hardcover
shelves into requests and auto-downloads them. Also exposes a manual trigger used
by the "Sync now" settings action button.
"""
from __future__ import annotations
import threading
import time
from typing import TYPE_CHECKING, Any
from shelfmark.core.config import config as app_config
from shelfmark.core.logger import setup_logger
if TYPE_CHECKING:
from collections.abc import Callable
from shelfmark.core.user_db import UserDB
logger = setup_logger(__name__)
_WARMUP_SECONDS = 60 # Delay before the first cycle after startup.
_MIN_INTERVAL_SECONDS = 60 # Floor so a misconfigured interval can't hammer the API.
_UNIT_SECONDS = {"minutes": 60.0, "hours": 3600.0}
_FALLBACK_WAIT_SECONDS = 6 * 3600.0 # Wait used when the interval cannot be read.
_ctx_lock = threading.Lock()
_ctx: dict[str, Any] = {}
_thread: threading.Thread | None = None
_thread_lock = threading.Lock()
# Prevents a scheduled cycle and a manual "Sync now" from overlapping.
_run_lock = threading.Lock()
def configure(
user_db: UserDB,
queue_release: Callable[..., tuple[bool, str | None]],
db_path: str | None,
) -> None:
"""Store the runtime context the background work needs."""
with _ctx_lock:
_ctx["user_db"] = user_db
_ctx["queue_release"] = queue_release
_ctx["db_path"] = db_path
def _interval_seconds() -> float:
raw = app_config.get("HARDCOVER_SYNC_INTERVAL", 6)
try:
value = float(raw) if isinstance(raw, (int, float, str)) else 6.0
except ValueError:
value = 6.0
unit = str(app_config.get("HARDCOVER_SYNC_INTERVAL_UNIT", "hours") or "hours").strip().lower()
multiplier = _UNIT_SECONDS.get(unit, _UNIT_SECONDS["hours"])
return max(value * multiplier, _MIN_INTERVAL_SECONDS)
def _enabled() -> bool:
return bool(app_config.get("HARDCOVER_SYNC_ENABLED", False)) or bool(
app_config.get("AUTO_DOWNLOAD_ENABLED", False)
)
def run_once(*, force: bool = False) -> dict[str, Any]:
"""Run one sync + auto-download pass. Returns a combined summary.
``force=True`` (the manual "Sync now" path) syncs regardless of the scheduler
toggle; auto-download always remains gated by ``AUTO_DOWNLOAD_ENABLED`` inside
``auto_download_pending``. Skips (returns ``{"status": "busy"}``) if another run
is in progress.
"""
if not _run_lock.acquire(blocking=False):
return {"status": "busy"}
try:
with _ctx_lock:
user_db = _ctx.get("user_db")
queue_release = _ctx.get("queue_release")
db_path = _ctx.get("db_path")
if user_db is None or queue_release is None:
return {"status": "unconfigured"}
from shelfmark.core.auto_download import auto_download_pending
from shelfmark.core.hardcover_sync import sync_wishlist
sync_summary: dict[str, Any] | None = None
if force or bool(app_config.get("HARDCOVER_SYNC_ENABLED", False)):
sync_summary = sync_wishlist(user_db, db_path=db_path)
auto_summary = auto_download_pending(
user_db, queue_release=queue_release, db_path=db_path, ignore_backoff=force
)
return {"status": "ok", "sync": sync_summary, "auto_download": auto_summary}
finally:
_run_lock.release()
def trigger_async(*, force: bool = True) -> bool:
"""Kick off a one-off run in a background thread. Returns False if one is active."""
if _run_lock.locked():
return False
thread = threading.Thread(
target=lambda: run_once(force=force), daemon=True, name="HardcoverSyncManual"
)
thread.start()
return True
def _quick_retry_interval_seconds() -> float:
"""How often failed downloads are checked between cycles, 0 when switched off."""
raw = app_config.get("AUTO_DOWNLOAD_FAILURE_RETRY_MINUTES", 0)
try:
minutes = float(raw) if isinstance(raw, (int, float, str)) else 0.0
except ValueError:
return 0.0
return max(minutes * 60.0, _MIN_INTERVAL_SECONDS) if minutes > 0 else 0.0
def run_quick_retry() -> dict[str, int] | None:
"""Pick another release for downloads that failed since the last look.
Skipped (None) while a cycle or a manual sync runs, and when no download failed.
"""
if not _run_lock.acquire(blocking=False):
return None
try:
with _ctx_lock:
user_db = _ctx.get("user_db")
queue_release = _ctx.get("queue_release")
db_path = _ctx.get("db_path")
if user_db is None or queue_release is None:
return None
from shelfmark.core.auto_download import retry_failed_downloads
return retry_failed_downloads(user_db, queue_release=queue_release, db_path=db_path)
finally:
_run_lock.release()
def _wait_for_next_cycle() -> None:
"""Sleep until the next cycle, looking at failed downloads on the way when that is on."""
deadline = time.monotonic() + _interval_seconds()
while True:
remaining = deadline - time.monotonic()
if remaining <= 0:
return
step = _quick_retry_interval_seconds()
if step <= 0:
time.sleep(remaining)
return
time.sleep(min(step, remaining))
if time.monotonic() >= deadline:
return
try:
result = run_quick_retry()
if result is not None:
logger.info("Hardcover scheduler: quick retry result %s", result)
except Exception:
logger.exception("Hardcover scheduler: quick retry failed")
def _scheduler_loop() -> None:
logger.info("Hardcover scheduler started")
time.sleep(_WARMUP_SECONDS)
while True:
try:
if _enabled():
logger.info("Hardcover scheduler: starting cycle")
result = run_once()
logger.info("Hardcover scheduler: cycle result %s", result)
except Exception:
logger.exception("Hardcover scheduler: cycle failed")
try:
_wait_for_next_cycle()
except Exception:
# An unreadable interval setting must not end the thread: nothing would sync
# again until a restart.
logger.exception(
"Hardcover scheduler: could not wait for the next cycle; retrying in %d s",
int(_FALLBACK_WAIT_SECONDS),
)
time.sleep(_FALLBACK_WAIT_SECONDS)
def start(
user_db: UserDB,
queue_release: Callable[..., tuple[bool, str | None]],
db_path: str | None,
) -> None:
"""Configure context and start the scheduler thread (idempotent)."""
configure(user_db, queue_release, db_path)
global _thread
with _thread_lock:
if _thread is not None and _thread.is_alive():
logger.debug("Hardcover scheduler already running")
return
_thread = threading.Thread(target=_scheduler_loop, daemon=True, name="HardcoverScheduler")
_thread.start()
logger.info("Hardcover scheduler thread launched")
+351
View File
@@ -0,0 +1,351 @@
"""Sync a Hardcover reading shelf (e.g. "Want to Read") into Shelfmark requests.
This replaces the standalone ``scripts/sync_hardcover_wishlist.py`` with an in-app
service that reuses the registered Hardcover metadata provider (so synced requests
carry full metadata incl. covers) and the request-service validation/dedup path.
"""
from __future__ import annotations
import sqlite3
from contextlib import contextmanager
from datetime import UTC, datetime, timedelta
from typing import TYPE_CHECKING, Any
from shelfmark.core import library_index
from shelfmark.core.config import config as app_config
from shelfmark.core.download_history_service import ACTIVE_DOWNLOAD_STATUS
from shelfmark.core.logger import setup_logger
from shelfmark.core.request_policy import normalize_content_type
from shelfmark.core.utils import transform_cover_url
if TYPE_CHECKING:
from collections.abc import Iterator
from shelfmark.core.user_db import UserDB
from shelfmark.metadata_providers import BookMetadata
logger = setup_logger(__name__)
# Hardcover status_id -> shelf. Default sync target is "Want to Read" (1).
DEFAULT_SYNC_STATUSES = ("1",)
_PAGE_LIMIT = 25 # Hardcover API page size.
_MAX_PAGES = 40 # Safety bound (~1000 books) so a bad response can't loop forever.
def _configured_token() -> str:
"""Prefer a dedicated sync token, fall back to the provider API key."""
token = app_config.get("HARDCOVER_SYNC_TOKEN", "") or app_config.get("HARDCOVER_API_KEY", "")
return str(token or "").strip()
def _configured_statuses() -> list[int]:
raw = app_config.get("HARDCOVER_SYNC_STATUSES", list(DEFAULT_SYNC_STATUSES))
values: list[object]
if isinstance(raw, str):
values = [raw]
elif isinstance(raw, (list, tuple)):
values = list(raw)
else:
values = []
statuses: list[int] = []
for value in values:
try:
statuses.append(int(str(value).strip()))
except ValueError:
continue
return statuses or [int(s) for s in DEFAULT_SYNC_STATUSES]
_CONTENT_TYPES = {"ebook": ["ebook"], "audiobook": ["audiobook"], "both": ["ebook", "audiobook"]}
DEFAULT_SYNC_CONTENT_TYPE = "audiobook"
def _configured_content_types() -> list[str]:
"""Content types to request for every shelf book ("both" → an ebook and an audiobook)."""
raw = str(app_config.get("HARDCOVER_SYNC_CONTENT_TYPE", DEFAULT_SYNC_CONTENT_TYPE) or "")
return list(_CONTENT_TYPES.get(raw.strip().lower(), _CONTENT_TYPES[DEFAULT_SYNC_CONTENT_TYPE]))
def _build_provider() -> Any | None:
from shelfmark.metadata_providers import get_provider
token = _configured_token()
if not token:
logger.warning("hardcover-sync: no Hardcover token configured")
return None
provider = get_provider("hardcover", api_key=token)
if not provider.is_available():
logger.warning("hardcover-sync: Hardcover provider unavailable (bad token?)")
return None
return provider
def resolve_request_owner(user_db: UserDB) -> int | None:
"""Return the lowest-id admin, who owns synced requests and approves auto-downloads."""
admin = user_db.get_first_admin()
return int(admin["id"]) if admin else None
def _primary_author(book: BookMetadata) -> str:
if book.search_author:
return book.search_author
if book.authors:
return book.authors[0]
return "Unknown"
def _book_to_book_data(book: BookMetadata, content_type: str) -> dict[str, Any]:
book_data: dict[str, Any] = {
"title": book.title,
"author": _primary_author(book),
"provider": "hardcover",
"provider_id": str(book.provider_id),
"content_type": content_type,
}
# Proxy the cover the same way search results are, so the row is ready for the UI.
if book.cover_url:
book_data["preview"] = transform_cover_url(book.cover_url, f"hardcover_{book.provider_id}")
if book.publish_year:
book_data["year"] = book.publish_year
if book.subtitle:
book_data["subtitle"] = book.subtitle
return book_data
def _existing_request_keys(user_db: UserDB) -> set[tuple[str, str]]:
"""``(hardcover id, content_type)`` pairs already requested, in any status.
Other providers' ids are not counted, so one that happens to equal a Hardcover id
cannot skip a book.
"""
keys: set[tuple[str, str]] = set()
for row in user_db.list_requests():
book_data = row.get("book_data") or {}
if isinstance(book_data, dict) and book_data.get("provider") == "hardcover":
pid = book_data.get("provider_id") or book_data.get("id")
if pid is not None:
# Normalized the way requests_service's duplicate check does, so a row
# with no content type counts as the ebook request it is treated as.
content_type = normalize_content_type(
row.get("content_type") or book_data.get("content_type")
)
keys.add((str(pid), content_type))
return keys
# An "active" history row older than this is a download the app lost track of (the
# process died before the row was finalised), not one still running.
_ACTIVE_DOWNLOAD_MAX_AGE = timedelta(hours=24)
def _history_timestamp(value: object) -> datetime | None:
"""A stored history stamp (SQLite's default or ISO with an offset) as an aware datetime."""
if not value:
return None
try:
parsed = datetime.fromisoformat(str(value).strip())
except ValueError:
return None
return parsed if parsed.tzinfo else parsed.replace(tzinfo=UTC)
@contextmanager
def _history_connection(db_path: str | None) -> Iterator[sqlite3.Connection | None]:
"""One read connection to the history for a whole sync pass, None when there is none.
The history check runs for every book on the shelf, so opening a connection per book
is a lot of work for a slow disk or a small board.
"""
conn: sqlite3.Connection | None = None
if db_path:
try:
conn = sqlite3.connect(db_path)
except sqlite3.Error as exc:
logger.debug("hardcover-sync: history dedup connection failed: %s", exc)
try:
yield conn
finally:
if conn is not None:
conn.close()
def _already_downloaded(
conn: sqlite3.Connection | None,
provider: str,
provider_id: str,
content_type: str,
*,
now: datetime | None = None,
) -> bool:
"""True when a request for this book and format has a download finished or running.
Keyed on ``download_history.request_id`` and the request's ``book_data``: the history
row itself holds the release's title and the source's own content label (Anna's
Archive writes "book (fiction)"), neither of which compares to the book. The format
is the request's, normalised the way requests_service stores it.
The request-key check in front of this covers every request the sync can see, so
this only decides anything for a request it cannot: another user's, on a per-user
sync. Failed and cancelled downloads don't count, and neither does an active row
older than :data:`_ACTIVE_DOWNLOAD_MAX_AGE`, so a download that broke or was lost
does not hold the book back for good.
"""
if conn is None:
return False
wanted = normalize_content_type(content_type)
cutoff = (now or datetime.now(UTC)) - _ACTIVE_DOWNLOAD_MAX_AGE
try:
rows = conn.execute(
"SELECT h.final_status, h.terminal_at, r.content_type "
"FROM download_history AS h "
"JOIN download_requests AS r ON r.id = h.request_id "
"WHERE h.final_status IN ('complete', ?) "
"AND json_extract(r.book_data, '$.provider') = ? "
"AND CAST(json_extract(r.book_data, '$.provider_id') AS TEXT) = ?",
(ACTIVE_DOWNLOAD_STATUS, provider, str(provider_id)),
).fetchall()
except sqlite3.Error as exc:
logger.debug("hardcover-sync: history dedup query failed: %s", exc)
return False
for final_status, terminal_at, request_content_type in rows:
if normalize_content_type(request_content_type) != wanted:
continue
if final_status == ACTIVE_DOWNLOAD_STATUS:
stamp = _history_timestamp(terminal_at)
if stamp is None or stamp < cutoff:
continue
return True
return False
def _fetch_status_books(provider: Any, status_id: int) -> tuple[list[BookMetadata], bool]:
"""Page through a Hardcover status shelf: the books read, and whether a fetch failed."""
books: list[BookMetadata] = []
failed = False
for page in range(1, _MAX_PAGES + 1):
try:
result = provider._fetch_current_user_books_by_status(status_id, page, _PAGE_LIMIT)
except Exception as exc: # noqa: BLE001 - network/provider errors shouldn't abort the sync
logger.warning(
"hardcover-sync: fetch failed (status=%s page=%s): %s", status_id, page, exc
)
failed = True
break
page_books = list(result.books or [])
books.extend(page_books)
if not result.has_more or not page_books:
break
return books, failed
def sync_wishlist(
user_db: UserDB,
*,
db_path: str | None = None,
user_id: int | None = None,
) -> dict[str, int]:
"""Sync configured Hardcover shelves into pending requests.
One request is created per shelf book and configured content type ("both" means an
ebook and an audiobook request each), so each format is checked against its own
library. Returns ``{"added", "skipped", "in_library", "errors"}``. Requires a
configured token; enable-gating is the caller's responsibility (see hardcover_scheduler).
"""
summary = {"added": 0, "skipped": 0, "in_library": 0, "errors": 0}
if user_id is None:
user_id = resolve_request_owner(user_db)
if user_id is None:
logger.warning("hardcover-sync: no admin user exists to own synced requests")
summary["errors"] += 1
return summary
provider = _build_provider()
if provider is None:
summary["errors"] += 1
return summary
content_types = _configured_content_types()
try:
for content_type in content_types:
library_index.require_ready(content_type)
except library_index.LibraryUnavailableError as exc:
# Requesting now would queue books the library may already hold.
logger.warning("hardcover-sync: %s; skipping this sync", exc)
summary["errors"] += 1
return summary
known_requests = _existing_request_keys(user_db)
from shelfmark.core.requests_service import RequestServiceError, create_request
with _history_connection(db_path) as history:
for status_id in _configured_statuses():
shelf, fetch_failed = _fetch_status_books(provider, status_id)
if fetch_failed:
summary["errors"] += 1
for book in shelf:
provider_id = str(book.provider_id)
author = _primary_author(book)
for content_type in content_types:
if (provider_id, content_type) in known_requests:
summary["skipped"] += 1
continue
if _already_downloaded(history, book.provider, provider_id, content_type):
summary["skipped"] += 1
continue
if library_index.is_in_library(book, content_type):
summary["in_library"] += 1
logger.info(
"hardcover-sync: '%s' (%s) already in library; skipping",
book.title,
content_type,
)
continue
try:
create_request(
user_db,
user_id=user_id,
source_hint=None,
content_type=content_type,
request_level="book",
policy_mode="request_book",
book_data=_book_to_book_data(book, content_type),
note=None,
)
except RequestServiceError as exc:
# Duplicate / max-pending / validation: treat as skip, not failure.
if exc.code in {"duplicate_pending_request", "max_pending_reached"}:
summary["skipped"] += 1
else:
logger.warning(
"hardcover-sync: could not add '%s' (%s): %s",
book.title,
content_type,
exc,
)
summary["errors"] += 1
continue
except Exception:
logger.exception(
"hardcover-sync: unexpected error adding '%s' (%s)",
book.title,
content_type,
)
summary["errors"] += 1
continue
known_requests.add((provider_id, content_type))
summary["added"] += 1
logger.info(
"hardcover-sync: added %s request for '%s' by %s",
content_type,
book.title,
author,
)
logger.info("hardcover-sync complete: %s", summary)
return summary
+11 -2
View File
@@ -47,6 +47,11 @@ NEGATIVE_CACHE_TTL = 3600
# Short enough to retry soon, long enough to prevent spam during one page view
TRANSIENT_CACHE_TTL = 60
# A hit only moves the entry's last-access time, which orders LRU eviction. The whole
# index is rewritten to disk, so that is done at most this often instead of per hit.
# A crash inside the window loses some access times, nothing else.
ACCESS_FLUSH_SECONDS = 30
_MIN_WEBP_HEADER_LENGTH = 12
HTTP_NOT_FOUND = HTTPStatus.NOT_FOUND
@@ -102,6 +107,7 @@ class ImageCacheService:
self.index_path = cache_dir / "cache_index.json"
self._lock = threading.RLock()
self._index: dict[str, dict[str, Any]] = {}
self._last_saved = time.monotonic()
# Stats tracking
self._hits = 0
@@ -195,6 +201,7 @@ class ImageCacheService:
with temp_path.open("w") as f:
json.dump(self._index, f)
temp_path.rename(self.index_path)
self._last_saved = time.monotonic()
except OSError:
pass
@@ -325,9 +332,11 @@ class ImageCacheService:
with image_path.open("rb") as f:
data = f.read()
# Update accessed time
# Update accessed time. Written out with the next save, or here once
# enough time has passed, not once per hit.
entry["accessed_at"] = time.time()
self._save_index()
if time.monotonic() - self._last_saved >= ACCESS_FLUSH_SECONDS:
self._save_index()
result = data, content_type
except OSError:
+87 -18
View File
@@ -21,11 +21,12 @@ from typing import TYPE_CHECKING, Any
from shelfmark.core.library_providers import all_providers
from shelfmark.core.logger import setup_logger
from shelfmark.core.text_match import (
COLLECTION_MARKERS,
author_surname,
bundle_markers,
extra_work_tokens,
isbn_variants,
title_tokens_match,
significant_tokens,
significant_tokens_match,
)
if TYPE_CHECKING:
@@ -68,14 +69,31 @@ def _store(provider_name: str, entries: list[LibraryEntry], fingerprint: object
slot.failed_at = None
def _entries_for(provider: LibraryProvider) -> list[LibraryEntry]:
"""Cached entries for one provider, re-indexed past the TTL or when the library changed."""
class LibraryUnavailableError(RuntimeError):
"""An enabled library could not be read and there is no earlier index to fall back on."""
def _entries_for(provider: LibraryProvider, *, strict: bool = False) -> list[LibraryEntry]:
"""Cached entries for one provider, re-indexed past the TTL or when the library changed.
A library that cannot be read answers from its stale cache when there is one. With
nothing cached the default is to fail open (no entries, so nothing counts as owned),
which suits badges. ``strict`` raises instead, for callers that act on the answer.
"""
slot = _slot(provider.name)
with _lock:
if slot.failed_at is not None and time.monotonic() - slot.failed_at < (
_FAILURE_BACKOFF_SECONDS
):
return slot.entries or []
backing_off = slot.failed_at is not None and (
time.monotonic() - slot.failed_at < _FAILURE_BACKOFF_SECONDS
)
stale = slot.entries
if backing_off:
# Same answer the failure gave: the stale index, or (strict) a refusal to guess.
if stale is not None:
return stale
if strict:
msg = f"{provider.describe()} is unavailable (retrying after a short back-off)"
raise LibraryUnavailableError(msg)
return []
try:
fingerprint = provider.fingerprint()
with _lock:
@@ -85,11 +103,30 @@ def _entries_for(provider: LibraryProvider) -> list[LibraryEntry]:
if cached is not None and fresh and unchanged:
return cached
entries = provider.fetch_entries()
except Exception as exc: # noqa: BLE001 - any failure must fail open
logger.warning("library check: %s unavailable (%s); failing open", provider.describe(), exc)
with _lock:
had_entries = bool(slot.entries)
if had_entries and not entries:
# A library that held books a moment ago and now lists none is far more likely a
# changed answer from the server than an emptied shelf. Indexing it would make
# every owned book look missing, so it is treated like any other failed read
# (the earlier index keeps answering, strict callers hold). A library that was
# really emptied clears on the next restart.
msg = "it listed no items although it held some at the last index"
raise ValueError(msg) # noqa: TRY301 - handled by the failure path below
except Exception as exc:
with _lock:
slot.failed_at = time.monotonic()
return slot.entries or [] # Use the stale cache if we have one.
stale = slot.entries
if stale is not None:
logger.warning(
"library check: %s unavailable (%s); using stale index", provider.describe(), exc
)
return stale
if strict:
msg = f"{provider.describe()} is unavailable: {exc}"
raise LibraryUnavailableError(msg) from exc
logger.warning("library check: %s unavailable (%s); failing open", provider.describe(), exc)
return []
_store(provider.name, entries, fingerprint)
logger.info("library check: indexed %d %s item(s)", len(entries), provider.display_name)
@@ -125,11 +162,31 @@ def match_entries(book: BookMetadata, entries: list[LibraryEntry]) -> str | None
if external_id in entry.external_ids or entry.isbns & book_isbns:
return "owned"
title = book.search_title or book.title
surname = author_surname(book.search_author or (book.authors[0] if book.authors else ""))
# The search title is the looser of the two, so it goes first. Hardcover cuts "Series:
# Book Title" down to the series name when it cannot tell that prefix from a title, and a
# shelf title with the book's own words then reads as a longer, different work. The full
# title is the more specific of the two and names the book as the shelf does.
titles = [book.search_title or book.title]
if book.title and book.title not in titles:
titles.append(book.title)
collection: str | None = None
for title in titles:
held = _held_as(title, surname, entries)
if held == "owned":
return "owned"
collection = collection or held
return collection
def _held_as(title: str, surname: str | None, entries: list[LibraryEntry]) -> str | None:
"""How the shelf holds a book of this ``title``: ``"owned"``, ``"collection"`` or None."""
collection: str | None = None
title_toks = significant_tokens(title)
for entry in entries:
if not title_tokens_match(title, set(entry.tokens)):
if not significant_tokens_match(title_toks, entry.tokens):
continue
if surname is not None and surname not in entry.tokens:
continue
@@ -138,7 +195,7 @@ def match_entries(book: BookMetadata, entries: list[LibraryEntry]) -> str | None
# another work disqualify it; packaging words do not.
if extra_work_tokens(set(entry.title_tokens), title, set(entry.context_tokens)):
continue
if entry.title_tokens & COLLECTION_MARKERS:
if bundle_markers(entry.title_tokens, title):
collection = "collection"
continue
return "owned"
@@ -151,13 +208,25 @@ def book_matches_entries(book: BookMetadata, entries: list[LibraryEntry]) -> boo
return match_entries(book, entries) is not None
def is_in_library(book: BookMetadata, content_type: str | None = None) -> bool:
"""True if an enabled library holding ``content_type`` already has ``book`` (fail-open).
def require_ready(content_type: str | None = None) -> None:
"""Raise LibraryUnavailableError unless every enabled library can be read (or is cached).
``content_type`` None consults every enabled provider.
``content_type`` limits the check to the libraries holding that type.
"""
for provider in _enabled_providers(content_type):
_entries_for(provider, strict=True)
def is_in_library(
book: BookMetadata, content_type: str | None = None, *, strict: bool = False
) -> bool:
"""True if an enabled library holding ``content_type`` already has ``book``.
``content_type`` None consults every enabled provider. A library that cannot be read
counts as not holding the book, unless ``strict``, which raises LibraryUnavailableError.
"""
return any(
book_matches_entries(book, _entries_for(provider))
book_matches_entries(book, _entries_for(provider, strict=strict))
for provider in _enabled_providers(content_type)
)
+2 -1
View File
@@ -68,6 +68,7 @@ def all_providers(overrides: Mapping[str, Any] | None = None) -> list[LibraryPro
Adding a library means writing a module with the :class:`LibraryProvider` shape and
listing it here. Nothing above this function knows which libraries exist.
"""
from shelfmark.core.library_providers.audiobookshelf import AudiobookshelfLibrary
from shelfmark.core.library_providers.calibre import CalibreLibrary
return [CalibreLibrary(overrides)]
return [CalibreLibrary(overrides), AudiobookshelfLibrary(overrides)]
@@ -0,0 +1,191 @@
"""Audiobookshelf library provider.
Fetches the libraries of an Audiobookshelf (ABS) server over its REST API, with title,
author, ISBN and ASIN per item. ABS metadata is often messy (titles are raw folder
names, the author is frequently empty), so each item is tokenized from title, author and
folder path.
"""
from __future__ import annotations
import re
from typing import TYPE_CHECKING, Any
import requests
from shelfmark.core.config import config as app_config
from shelfmark.core.library_providers import LibraryEntry, setting
from shelfmark.core.text_match import isbn_variants, tokens
from shelfmark.core.utils import normalize_http_url
if TYPE_CHECKING:
from collections.abc import Mapping
_REQUEST_TIMEOUT = 15
_ITEMS_PAGE_LIMIT = 500
# "Alex Cross 02: ", "DCC 1: ", "Reacher 19.5: ": a short label, a number and a colon.
_SERIES_PREFIX = re.compile(r"^(?P<label>[^\d:]{1,40}?)\s+(?P<number>\d+(?:\.\d+)?)\s*:\s*(?=\S)")
def _config(overrides: Mapping[str, Any] | None = None) -> tuple[bool, str, str, list[str]]:
enabled = bool(setting(app_config, "LIBRARY_CHECK_AUDIOBOOKSHELF_ENABLED", False, overrides))
url = normalize_http_url(str(setting(app_config, "AUDIOBOOKSHELF_URL", "", overrides) or ""))
token = str(setting(app_config, "AUDIOBOOKSHELF_TOKEN", "", overrides) or "").strip()
lib_ids_raw = str(setting(app_config, "AUDIOBOOKSHELF_LIBRARY_IDS", "", overrides) or "")
lib_ids = [s.strip() for s in lib_ids_raw.split(",") if s.strip()]
return enabled, url, token, lib_ids
def _session(url: str, token: str) -> requests.Session:
from shelfmark.download.network import get_ssl_verify
session = requests.Session()
session.headers.update({"Authorization": f"Bearer {token}"})
# The URL lets CERTIFICATE_VALIDATION=disabled_local skip verification for a LAN
# server, which is where Audiobookshelf usually lives.
session.verify = get_ssl_verify(url)
return session
def _first_series_name(value: object) -> str | None:
"""The first series in Audiobookshelf's ``seriesName`` ("Name #1, Other Name #3")."""
first = str(value or "").split(", ", 1)[0].strip()
name = re.sub(r"\s+#\S+$", "", first).strip()
return name or None
def _item_to_entry(item: dict[str, Any]) -> LibraryEntry:
media = item.get("media") or {}
meta = media.get("metadata") or {}
title = meta.get("title") or ""
author = meta.get("authorName") or ""
rel_path = item.get("relPath") or ""
context_tok = set(tokens(author))
# A book in a series is often titled "Alex Cross 02: Kiss the Girls" so players that
# ignore the series number still show the order. Those leading words name the series,
# not another work, so they go with the author as context.
prefix = _SERIES_PREFIX.match(title) if _first_series_name(meta.get("seriesName")) else None
if prefix:
context_tok |= set(tokens(f"{prefix['label']} {prefix['number']}"))
title = title[prefix.end() :]
title_tok = set(tokens(title))
tok = title_tok | context_tok | set(tokens(rel_path))
asin = str(meta.get("asin") or "").strip().upper()
return LibraryEntry(
frozenset(tok),
isbn_variants(meta.get("isbn")),
frozenset({asin} if asin else set()),
frozenset(),
frozenset(title_tok),
frozenset(context_tok),
)
def _fetch_library_entries(url: str, token: str, lib_ids: list[str]) -> list[LibraryEntry]:
session = _session(url, token)
resp = session.get(f"{url}/api/libraries", timeout=_REQUEST_TIMEOUT)
resp.raise_for_status()
libraries = resp.json().get("libraries", [])
selected: list[str] = []
for lib in libraries:
lib_id = lib.get("id")
if not lib_id:
continue
if lib_ids:
if lib_id in lib_ids:
selected.append(lib_id)
elif lib.get("mediaType") == "book":
selected.append(lib_id)
entries: list[LibraryEntry] = []
for lib_id in selected:
page = 0
while True:
resp = session.get(
f"{url}/api/libraries/{lib_id}/items",
params={"limit": _ITEMS_PAGE_LIMIT, "page": page},
timeout=_REQUEST_TIMEOUT,
)
resp.raise_for_status()
data = resp.json()
results = data.get("results") if isinstance(data, dict) else None
if not isinstance(results, list):
# An answer without an item list is not an empty library.
msg = f"Audiobookshelf's item list for library {lib_id} has an unexpected shape"
raise TypeError(msg)
total = int(data.get("total", 0) or 0)
if page == 0 and not results and total > 0:
msg = f"Audiobookshelf reports {total} items in library {lib_id} but listed none"
raise ValueError(msg)
entries.extend(_item_to_entry(item) for item in results)
page += 1
if not results or page * _ITEMS_PAGE_LIMIT >= total:
break
return entries
def search_books(
title: str,
author: str,
provider: str,
*,
timeout: float = _REQUEST_TIMEOUT,
overrides: Mapping[str, Any] | None = None,
) -> list[dict[str, Any]]:
"""Ask Audiobookshelf's metadata search (``GET /api/search/books``) about a book.
``provider`` names one of the server's metadata providers: ``audible`` is the US
store, ``audible.uk`` the UK one. The result is the server's list of matches, best
first, each with at least ``title``, ``author`` and (for Audible) ``asin``. Raises
ValueError without a URL and token, and the requests error when the server or the
store fails; the caller decides what an error means.
"""
_enabled, url, token, _lib_ids = _config(overrides)
if not url or not token:
raise ValueError("Set the Audiobookshelf URL and token first.")
resp = _session(url, token).get(
f"{url}/api/search/books",
params={"title": title, "author": author, "provider": provider},
timeout=timeout,
)
resp.raise_for_status()
payload = resp.json()
if not isinstance(payload, list):
msg = "Audiobookshelf answered the book search with something other than a list"
raise ValueError(msg) # noqa: TRY004 - a bad answer from the server, not a bad argument
return [item for item in payload if isinstance(item, dict)]
class AudiobookshelfLibrary:
"""Audiobook ownership via the Audiobookshelf REST API."""
name = "audiobookshelf"
display_name = "Audiobookshelf"
content_types = frozenset({"audiobook"})
def __init__(self, overrides: Mapping[str, Any] | None = None) -> None:
self._overrides = overrides
def is_enabled(self) -> bool:
enabled, url, token, _lib_ids = _config(self._overrides)
return enabled and bool(url) and bool(token)
def describe(self) -> str:
_enabled, url, _token, _lib_ids = _config(self._overrides)
return f"Audiobookshelf at {url}" if url else "Audiobookshelf"
def fingerprint(self) -> None:
return None
def fetch_entries(self) -> list[LibraryEntry]:
_enabled, url, token, lib_ids = _config(self._overrides)
if not url or not token:
msg = "Set the Audiobookshelf URL and token first."
raise ValueError(msg)
return _fetch_library_entries(url, token, lib_ids)
+17 -4
View File
@@ -29,8 +29,12 @@ if TYPE_CHECKING:
logger = setup_logger(__name__)
# "(Alex Cross Series #11)", "[Illustrated Edition]" and friends.
_PARENTHETICAL = re.compile(r"[(\[][^)\]]*[)\]]")
# "(Alex Cross Series #11)", "(Book 2)": a bracketed segment with a number in it.
_NUMBERED_PARENTHETICAL = re.compile(r"[(\[][^)\]]*\d[^)\]]*[)\]]")
# "Label NN: Title", a series label and number in front of the work's own title. The
# label often abbreviates the series ("HHG 5: Mostly Harmless" for The Hitchhiker's
# Guide to the Galaxy), so its words are in neither the series nor the author name.
_SERIES_LABEL = re.compile(r"^\s*(?P<label>[^:]{1,40}?)\s+\d+(?:\.\d+)?\s*:\s+\S")
_DEFAULT_DB_PATH = "/calibre-library/metadata.db"
_BUSY_TIMEOUT_SECONDS = 5
@@ -39,6 +43,7 @@ _ISBN_TYPES = frozenset({"isbn", "isbn13", "isbn-13", "isbn10", "isbn-10"})
_ASIN_TYPES = frozenset({"amazon", "mobi-asin", "asin"})
# Calibre identifier type -> Shelfmark metadata provider name.
_EXTERNAL_ID_TYPES = {
"dnb-idn": "dnb",
"hardcover-id": "hardcover",
"google": "googlebooks",
"openlibrary": "openlibrary",
@@ -110,12 +115,20 @@ def _read_entries(conn: sqlite3.Connection) -> list[LibraryEntry]:
entries: list[LibraryEntry] = []
for book_id, title in titles.items():
# A trailing parenthetical is series or edition metadata by convention rather
# A numbered parenthetical is series or volume metadata by convention rather
# than part of the work name, and Calibre users often put it there instead of
# in the series field. It stays in the recall set below and is dropped from the
# title set, which is what decides whether the shelf holds a different book.
title_tok = set(tokens(_PARENTHETICAL.sub(" ", title)))
# An unnumbered one describes the edition, so its words stay in the title set:
# the matcher ignores printing words there ("Illustrated Edition", "Unabridged")
# but reads anything else ("Full Cast Edition", "Dramatized Adaptation") as a
# different recording of the work, which is not the one being asked for.
title_tok = set(tokens(_NUMBERED_PARENTHETICAL.sub(" ", title)))
context_tok = set(tokens(series.get(book_id)))
# Only for a book with a series: there a leading "Label NN:" is the series
# label, not words naming another work. Without one it is left to the matcher.
if book_id in series and (label := _SERIES_LABEL.match(title)):
context_tok |= set(tokens(label.group("label")))
for name in authors.get(book_id, ()):
context_tok |= set(tokens(name))
tok = title_tok | context_tok
+1 -17
View File
@@ -1,7 +1,6 @@
"""Logging configuration and custom logger with error tracing."""
import logging
import re
import sys
from collections.abc import Mapping
from logging.handlers import RotatingFileHandler
@@ -9,6 +8,7 @@ from threading import Lock
from typing import TYPE_CHECKING
from shelfmark.config.env import ENABLE_LOGGING, LOG_FILE, LOG_LEVEL
from shelfmark.core.redaction import redact_secrets
if TYPE_CHECKING:
from pathlib import Path
@@ -17,22 +17,6 @@ if TYPE_CHECKING:
_file_handlers: dict[Path, RotatingFileHandler] = {}
_file_handlers_lock = Lock()
# Query parameters that carry tracker/indexer credentials. Prowlarr guids are often
# the download URL itself (apikey included), and they end up in task ids that are
# logged all over the download pipeline, so mask them at the formatter instead.
_SECRET_QUERY_PARAM_PATTERN = re.compile(
r"(?<=[?&;])"
r"([a-z0-9_-]*api[_-]?key|api|apitoken|access_token|token|passkey|authkey|rsskey"
r"|torrent_pass|auth|key|password|passwd|pwd|pass|secret|sid|uid)"
r"=[^&\s#'\"<>;:,)\]]+",
re.IGNORECASE,
)
def redact_secrets(text: str) -> str:
"""Mask credential query parameters in text, keeping the rest of the URL readable."""
return _SECRET_QUERY_PARAM_PATTERN.sub(r"\1=(removed)", text)
class RedactingFormatter(logging.Formatter):
"""Formatter that masks credentials in the message, traceback and stack info."""
+544
View File
@@ -0,0 +1,544 @@
"""Metadata providers: the ``/api/metadata/...`` search, book and target routes."""
from __future__ import annotations
import sqlite3
from contextlib import suppress
from functools import wraps
from typing import TYPE_CHECKING, Any
from flask import jsonify, request, session
from shelfmark.core.config import config as app_config
from shelfmark.core.logger import setup_logger
from shelfmark.core.request_helpers import get_session_db_user_id
if TYPE_CHECKING:
from collections.abc import Callable
from flask import Flask
from werkzeug.wrappers import Response
from shelfmark.metadata_providers import BookMetadata, MetadataProvider
logger = setup_logger(__name__)
_OPERATIONAL_ERRORS = (OSError, RuntimeError, TypeError, ValueError, sqlite3.Error)
_IMPORT_OPERATIONAL_ERRORS = (ImportError, *_OPERATIONAL_ERRORS)
def _resolve_metadata_provider(provider_name: str) -> MetadataProvider:
"""Validate, instantiate and return a ready metadata provider.
Raises appropriate HTTP-friendly exceptions on failure.
"""
from shelfmark.metadata_providers import (
get_provider,
get_provider_kwargs,
is_provider_registered,
)
if not is_provider_registered(provider_name):
msg = f"Unknown metadata provider: {provider_name}"
raise ValueError(msg)
kwargs = get_provider_kwargs(provider_name)
prov = get_provider(provider_name, **kwargs)
if not prov.is_available():
msg = f"Provider '{provider_name}' is not available"
raise RuntimeError(msg)
return prov
def _handle_target_errors(
fallback_message: str,
) -> Callable[
[Callable[..., Response | tuple[Response, int]]], Callable[..., Response | tuple[Response, int]]
]:
"""Wrap a metadata-target route with standard error handling."""
def decorator(
fn: Callable[..., Response | tuple[Response, int]],
) -> Callable[..., Response | tuple[Response, int]]:
"""Wrap one route."""
@wraps(fn)
def wrapper(*args: object, **kwargs: object) -> Response | tuple[Response, int]:
"""Map errors to responses: bad input 400, provider failure 502, anything else 500."""
try:
return fn(*args, **kwargs)
except (NotImplementedError, ValueError) as e:
return jsonify({"error": str(e)}), 400
except RuntimeError as e:
return jsonify({"error": str(e)}), 502
except (OSError, TypeError, sqlite3.Error) as e:
logger.error_trace(f"{fallback_message}: {e}")
return jsonify({"error": fallback_message}), 500
return wrapper
return decorator
def register_metadata_routes(
app: Flask,
*,
login_required: Callable[..., Callable[..., Response | tuple[Response, int]]],
resolve_auth_mode: Callable[[], str],
attach_library_ownership: Callable[[BookMetadata, dict[str, Any]], None],
) -> None:
"""Register the ``/api/metadata/...`` routes.
``login_required`` is the app's decorator. ``resolve_auth_mode`` is called per request so a
runtime patch of the auth mode still takes effect. ``attach_library_ownership`` is shared
with the releases routes, which is why it stays in ``main.py``.
"""
def _session_may_use_provider_account() -> bool:
"""Whether this session may read or change the metadata provider's own account.
A provider such as Hardcover is signed in as one account, the instance owner's, and its lists
and shelves belong to that person. With no authentication configured the one user is the owner.
Otherwise only an admin may use them.
"""
return resolve_auth_mode() == "none" or bool(session.get("is_admin", False))
@app.route("/api/metadata/providers", methods=["GET"])
@login_required
def api_metadata_providers() -> Response | tuple[Response, int]:
"""Get list of available metadata providers.
Returns:
flask.Response: JSON with list of providers and their status.
"""
try:
from shelfmark.metadata_providers import (
get_configured_provider_name,
get_provider,
get_provider_kwargs,
list_providers,
)
app_config.refresh()
db_user_id = get_session_db_user_id(session)
configured_metadata_provider = get_configured_provider_name(
content_type="ebook",
user_id=db_user_id,
fallback_to_main=True,
)
configured_audiobook_metadata_provider = get_configured_provider_name(
content_type="audiobook",
user_id=db_user_id,
fallback_to_main=False,
)
configured_combined_metadata_provider = get_configured_provider_name(
content_type="combined",
user_id=db_user_id,
fallback_to_main=False,
)
providers = []
for info in list_providers():
enabled_key = f"{info['name'].upper()}_ENABLED"
provider_info = {
"name": info["name"],
"display_name": info["display_name"],
"requires_auth": info["requires_auth"],
"enabled": app_config.get(enabled_key, False) is True,
"available": False,
}
try:
kwargs = get_provider_kwargs(info["name"])
provider = get_provider(info["name"], **kwargs)
provider_info["available"] = provider.is_available()
except _OPERATIONAL_ERRORS as exc:
logger.debug(
"Metadata provider %s availability check failed: %s",
info["name"],
exc,
)
providers.append(provider_info)
return jsonify(
{
"providers": providers,
"configured_provider": configured_metadata_provider or None,
"configured_provider_audiobook": configured_audiobook_metadata_provider or None,
"configured_provider_combined": configured_combined_metadata_provider or None,
}
)
except _IMPORT_OPERATIONAL_ERRORS as e:
logger.error_trace(f"Metadata providers error: {e}")
return jsonify({"error": "Could not load the metadata providers"}), 500
@app.route("/api/metadata/config", methods=["GET"])
@login_required
def api_metadata_config() -> Response | tuple[Response, int]:
"""Return provider-specific metadata search config for the active session."""
try:
from shelfmark.metadata_providers import (
get_configured_provider_name,
get_provider,
get_provider_capabilities,
get_provider_default_sort,
get_provider_kwargs,
get_provider_search_fields,
get_provider_sort_options,
is_provider_registered,
)
app_config.refresh()
content_type = request.args.get("content_type", "ebook").strip()
provider_name = request.args.get("provider", "").strip()
db_user_id = get_session_db_user_id(session)
if not provider_name:
provider_name = get_configured_provider_name(
content_type=content_type,
user_id=db_user_id,
fallback_to_main=True,
)
if not provider_name:
return jsonify(
{
"provider": None,
"display_name": None,
"enabled": False,
"available": False,
"search_fields": [],
"capabilities": [],
"sort_options": [{"value": "relevance", "label": "Most relevant"}],
"default_sort": "relevance",
}
)
if not is_provider_registered(provider_name):
return jsonify({"error": f"Unknown metadata provider: {provider_name}"}), 400
kwargs = get_provider_kwargs(provider_name)
provider = get_provider(provider_name, **kwargs)
enabled_key = f"{provider_name.upper()}_ENABLED"
provider_enabled = app_config.get(enabled_key, False) is True
provider_available = provider.is_available()
return jsonify(
{
"provider": provider_name,
"display_name": provider.display_name,
"enabled": provider_enabled,
"available": provider_available,
"search_fields": get_provider_search_fields(provider_name),
"capabilities": get_provider_capabilities(provider_name),
"sort_options": get_provider_sort_options(provider_name),
"default_sort": get_provider_default_sort(provider_name, user_id=db_user_id),
}
)
except _IMPORT_OPERATIONAL_ERRORS as e:
logger.error_trace(f"Metadata config error: {e}")
return jsonify({"error": "Could not load the metadata configuration"}), 500
@app.route("/api/metadata/search", methods=["GET"])
@login_required
def api_metadata_search() -> Response | tuple[Response, int]:
"""Search for books using the configured metadata provider.
Query Parameters:
query (str): Search query (required)
limit (int): Maximum number of results (default: 40, max: 100)
sort (str): Sort order - relevance, popularity, rating, newest, oldest (default: relevance)
[dynamic fields]: Provider-specific search fields passed as query params
Returns:
flask.Response: JSON with list of books from metadata provider.
"""
try:
from dataclasses import asdict
from shelfmark.metadata_providers import (
CheckboxSearchField,
MetadataSearchOptions,
NumberSearchField,
SortOrder,
get_configured_provider,
get_provider,
get_provider_kwargs,
is_provider_enabled,
is_provider_registered,
)
query = request.args.get("query", "").strip()
content_type = request.args.get("content_type", "ebook").strip()
provider_name = request.args.get("provider", "").strip()
try:
limit = min(int(request.args.get("limit", 40)), 100)
except ValueError:
limit = 40
try:
page = max(1, int(request.args.get("page", 1)))
except ValueError:
page = 1
# Parse sort parameter
sort_value = request.args.get("sort", "relevance").lower()
try:
sort_order = SortOrder(sort_value)
except ValueError:
sort_order = SortOrder.RELEVANCE
db_user_id = get_session_db_user_id(session)
if provider_name:
if not is_provider_registered(provider_name):
return jsonify(
{
"error": f"Unknown metadata provider: {provider_name}",
"message": f"Unknown metadata provider: {provider_name}",
}
), 400
if not is_provider_enabled(provider_name):
return jsonify(
{
"error": f"Metadata provider '{provider_name}' is not enabled",
"message": f"{provider_name} is not enabled. Enable it in Settings first.",
}
), 503
kwargs = get_provider_kwargs(provider_name)
provider = get_provider(provider_name, **kwargs)
else:
provider = get_configured_provider(content_type=content_type, user_id=db_user_id)
if not provider:
return jsonify(
{
"error": "No metadata provider configured",
"message": "No metadata provider configured. Enable one in Settings.",
}
), 503
if not provider.is_available():
return jsonify(
{
"error": f"Metadata provider '{provider.name}' is not available",
"message": f"{provider.display_name} is not available. Check configuration in Settings.",
}
), 503
# Extract custom search field values from query params
fields: dict[str, Any] = {}
may_use_account = _session_may_use_provider_account()
for search_field in provider.search_fields:
if search_field.key in provider.account_search_fields and not may_use_account:
continue
value = request.args.get(search_field.key)
if value is not None:
# Strip string values to handle whitespace-only input
value = value.strip()
if value != "":
# Parse value based on field type
if isinstance(search_field, CheckboxSearchField):
fields[search_field.key] = value.lower() in ("true", "1", "yes", "on")
elif isinstance(search_field, NumberSearchField):
with suppress(ValueError):
fields[search_field.key] = int(value)
else:
fields[search_field.key] = value
# Require either a query or at least one field value
if not query and not fields:
return jsonify({"error": "Either 'query' or search field values are required"}), 400
options = MetadataSearchOptions(
query=query,
limit=limit,
page=page,
sort=sort_order,
fields=fields,
user_id=db_user_id,
)
search_result = provider.search_paginated(options)
# Convert BookMetadata objects to dicts
books_data = [asdict(book) for book in search_result.books]
# Transform cover_url to local proxy URLs when caching is enabled
from shelfmark.core.utils import transform_cover_url
for book_dict in books_data:
if book_dict.get("cover_url"):
cache_id = f"{book_dict['provider']}_{book_dict['provider_id']}"
book_dict["cover_url"] = transform_cover_url(book_dict["cover_url"], cache_id)
for book, book_dict in zip(search_result.books, books_data, strict=True):
attach_library_ownership(book, book_dict)
response_data = {
"books": books_data,
"provider": provider.name,
"query": query,
"page": search_result.page,
"total_found": search_result.total_found,
"has_more": search_result.has_more,
}
if search_result.source_url:
response_data["source_url"] = search_result.source_url
if search_result.source_title:
response_data["source_title"] = search_result.source_title
return jsonify(response_data)
except _IMPORT_OPERATIONAL_ERRORS as e:
logger.error_trace(f"Metadata search error: {e}")
return jsonify({"error": "Metadata search failed"}), 500
@app.route("/api/metadata/field-options", methods=["GET"])
@login_required
def api_metadata_field_options() -> Response:
"""Return dynamic search-field options for a metadata provider."""
try:
from shelfmark.metadata_providers import (
get_configured_provider,
get_provider,
get_provider_kwargs,
is_provider_registered,
)
field_key = request.args.get("field", "").strip()
provider_name = request.args.get("provider", "").strip()
content_type = request.args.get("content_type", "ebook").strip()
query_text = request.args.get("query", "").strip()
if not field_key:
return jsonify({"options": []})
db_user_id = get_session_db_user_id(session)
provider = None
if provider_name:
if not is_provider_registered(provider_name):
return jsonify({"options": []})
kwargs = get_provider_kwargs(provider_name)
provider = get_provider(provider_name, **kwargs)
else:
provider = get_configured_provider(content_type=content_type, user_id=db_user_id)
if not provider or not provider.is_available():
return jsonify({"options": []})
if (
field_key in provider.account_search_fields
and not _session_may_use_provider_account()
):
return jsonify({"options": []})
options = provider.get_search_field_options(field_key, query=query_text or None)
return jsonify({"options": options})
except _OPERATIONAL_ERRORS as e:
logger.warning("Metadata field options endpoint error: %s", e)
return jsonify({"options": []})
@app.route("/api/metadata/book/<provider>/<book_id>", methods=["GET"])
@login_required
def api_metadata_book(provider: str, book_id: str) -> Response | tuple[Response, int]:
"""Get detailed book information from a metadata provider.
Path Parameters:
provider (str): Provider name (e.g., "hardcover", "openlibrary")
book_id (str): Book ID in the provider's system
Returns:
flask.Response: JSON with book details.
"""
try:
from dataclasses import asdict
prov = _resolve_metadata_provider(provider)
book = prov.get_book(book_id)
if not book:
return jsonify({"error": "Book not found"}), 404
book_dict = asdict(book)
attach_library_ownership(book, book_dict)
# Transform cover_url to local proxy URL when caching is enabled
from shelfmark.core.utils import transform_cover_url
if book_dict.get("cover_url"):
cache_id = f"{provider}_{book_id}"
book_dict["cover_url"] = transform_cover_url(book_dict["cover_url"], cache_id)
return jsonify(book_dict)
except ValueError as e:
return jsonify({"error": str(e)}), 400
except RuntimeError as e:
return jsonify({"error": str(e)}), 503
except (OSError, TypeError, sqlite3.Error) as e:
logger.error_trace(f"Metadata book error: {e}")
return jsonify({"error": "Could not load the book"}), 500
@app.route("/api/metadata/book/<provider>/<book_id>/targets", methods=["GET"])
@login_required
@_handle_target_errors("Failed to load book targets")
def api_metadata_book_targets(provider: str, book_id: str) -> Response | tuple[Response, int]:
"""Get provider-managed list/status targets for a specific book."""
prov = _resolve_metadata_provider(provider)
if not _session_may_use_provider_account():
return jsonify({"options": []})
return jsonify({"options": prov.get_book_targets(book_id)})
@app.route("/api/metadata/book/<provider>/targets/batch", methods=["POST"])
@login_required
@_handle_target_errors("Failed to load book targets")
def api_metadata_book_targets_batch(provider: str) -> Response | tuple[Response, int]:
"""Get provider-managed list/status targets for multiple books."""
prov = _resolve_metadata_provider(provider)
payload = request.get_json(silent=True) or {}
raw_ids = payload.get("book_ids", []) if isinstance(payload, dict) else []
if not isinstance(raw_ids, list) or not raw_ids:
return jsonify({"error": "book_ids must be a non-empty array"}), 400
book_ids = [str(bid) for bid in raw_ids[:50]]
if not _session_may_use_provider_account():
return jsonify({"results": {book_id: [] for book_id in book_ids}})
return jsonify({"results": prov.get_book_targets_batch(book_ids)})
@app.route("/api/metadata/book/<provider>/<book_id>/targets", methods=["PUT"])
@login_required
@_handle_target_errors("Failed to update book targets")
def api_metadata_book_targets_update(
provider: str, book_id: str
) -> Response | tuple[Response, int]:
"""Set whether a book belongs to a provider-managed list or shelf."""
if not _session_may_use_provider_account():
return jsonify({"error": "Admin access required"}), 403
prov = _resolve_metadata_provider(provider)
payload = request.get_json(silent=True) or {}
target = str(payload.get("target", "")).strip() if isinstance(payload, dict) else ""
selected = payload.get("selected") if isinstance(payload, dict) else None
if not target:
return jsonify({"error": "target is required"}), 400
if not isinstance(selected, bool):
return jsonify({"error": "selected must be a boolean"}), 400
result = prov.set_book_target_state(book_id, target, selected=selected)
response: dict = {
"success": True,
"changed": bool(result.get("changed", True)),
"selected": selected,
}
deselected = result.get("deselected_target")
if isinstance(deselected, str) and deselected:
response["deselected_target"] = deselected
return jsonify(response)
+7
View File
@@ -155,6 +155,10 @@ class DownloadTask:
# Hardlinking support
original_download_path: str | None = None # Path in download client (for hardlinking)
# True when the task picked up work that was already under way (a torrent the client had,
# files already on disk) instead of starting it. The activity card shows it as a pill.
reattached: bool = False
# Search mode - determines post-download processing behavior
# See SearchMode enum for behavioral differences
search_mode: SearchMode | None = None
@@ -186,6 +190,9 @@ class DownloadTask:
progress: float = 0.0
status: QueueStatus = QueueStatus.QUEUED
status_message: str | None = None
# When the task first reached a terminal status (epoch seconds, like added_time); None
# while it is running or queued again. Mirrors history's terminal_at for a live entry.
finished_at: float | None = None
download_path: str | None = None
# Every file the output step produced, download_path among them. Kept in memory
# only: history persists download_path alone.
+107 -10
View File
@@ -4,14 +4,16 @@ from __future__ import annotations
import ipaddress
import logging
import re
import socket
import sqlite3
from concurrent.futures import ThreadPoolExecutor
from contextlib import contextmanager, suppress
from contextvars import ContextVar
from dataclasses import dataclass
from enum import StrEnum
from typing import TYPE_CHECKING, Any, Protocol, TypeGuard
from urllib.parse import urlsplit
from urllib.parse import quote, urlsplit
try:
import apprise
@@ -20,7 +22,7 @@ except ImportError: # pragma: no cover - exercised in tests via monkeypatch
from shelfmark.core.config import config as app_config
from shelfmark.core.logger import setup_logger
from shelfmark.core.request_helpers import normalize_positive_int
from shelfmark.core.request_helpers import is_plain_email_address, normalize_positive_int
if TYPE_CHECKING:
from collections.abc import Iterable, Iterator
@@ -37,6 +39,12 @@ _APPRISE_LOGO_URL = (
)
_APPRISE_LOGGER_NAME = "apprise"
_APPRISE_DISPATCH_ERRORS = (RuntimeError, TypeError, ValueError)
_USER_ROUTE_PLACEHOLDER_PATTERN = re.compile(r"\{(user|email)\}", re.IGNORECASE)
# Apprise splits a decoded value on these, so "John Smith" or '"a,b@x.com"@y.com' would reach
# extra topics or recipients (e.g. add recipients to an admin-set mailto route).
_UNSAFE_PLACEHOLDER_VALUE = re.compile(r'[\s,;/\\"]')
# Percent-encoding leaves dots alone, so a username of ".." would walk up a webhook URL's path.
_DOTS_ONLY_PLACEHOLDER_VALUE = re.compile(r"\.+")
class _ApprisePluginWithUrl(Protocol):
@@ -363,14 +371,89 @@ def _allowed_user_urls(urls: list[str]) -> list[str]:
return allowed
def _resolve_user_routes(user_id: int | None) -> list[dict[str, str]]:
def _resolve_user_routes(user_id: int | None) -> tuple[list[dict[str, str]], bool]:
"""The user's routes, and whether they are the inherited admin default.
Admin-set routes are trusted like the global ones, so they skip the personal-route checks.
Both come from one config read, so a settings save in between cannot pair the user's own
routes with the admin default's trust.
"""
normalized_user_id = _normalize_user_id(user_id)
if normalized_user_id is None:
return []
return [], False
return _normalize_routes(
app_config.get("USER_NOTIFICATION_ROUTES", [], user_id=normalized_user_id)
value, is_own = app_config.get_for_user(
"USER_NOTIFICATION_ROUTES", [], user_id=normalized_user_id
)
return _normalize_routes(value), not is_own
def _get_route_user(user_id: int) -> dict[str, Any] | None:
try:
from shelfmark.core.user_db import UserDB, get_users_db_path
return UserDB(get_users_db_path()).get_user(user_id=user_id)
except (ImportError, OSError, sqlite3.Error) as exc:
logger.warning("Failed to look up user_id=%s for notification routes: %s", user_id, exc)
return None
def expand_user_route_placeholders(urls: list[str], user_id: int) -> list[str]:
"""Expand `{User}` and `{Email}` in per-user route URLs from the user's account.
Placeholders are only filled in the path, query or fragment: a URL with one in its host part
is dropped, because Apprise decodes percent-escapes there (so an email such as
``x%40127.0.0.1`` would still reach 127.0.0.1). Values are fully percent-encoded, and a
value containing whitespace or any of ``, ; / \\ "`` is ignored, since Apprise would split it
into several targets, and so is a value made only of dots, which would change the URL path.
URLs that need a value the user does not have are dropped.
Callers should still validate the returned URLs, not the templates.
"""
if not any(_USER_ROUTE_PLACEHOLDER_PATTERN.search(url) for url in urls):
return urls
user = _get_route_user(user_id) or {}
values: dict[str, str] = {}
for name, field in (("user", "username"), ("email", "email")):
value = str(user.get(field) or "").strip()
if value and (
_UNSAFE_PLACEHOLDER_VALUE.search(value)
or _DOTS_ONLY_PLACEHOLDER_VALUE.fullmatch(value)
or (name == "email" and not is_plain_email_address(value))
):
logger.info(
"Ignoring the %s on file for user_id=%s in notification routes: it could "
"split into several targets or change the URL path",
field,
user_id,
)
value = ""
values[name] = value
expanded: list[str] = []
for url in urls:
if _USER_ROUTE_PLACEHOLDER_PATTERN.search(urlsplit(url).netloc):
logger.warning(
"Skipping notification route for user_id=%s: {User}/{Email} is not allowed "
"in the host part of a URL",
user_id,
)
continue
needed = {match.lower() for match in _USER_ROUTE_PLACEHOLDER_PATTERN.findall(url)}
missing = sorted(name for name in needed if not values[name])
if missing:
logger.info(
"Skipping notification route for user_id=%s: no %s on file",
user_id,
", ".join(missing),
)
continue
expanded.append(
_USER_ROUTE_PLACEHOLDER_PATTERN.sub(
lambda match: quote(values[match.group(1).lower()], safe=""), url
)
)
return expanded
def _resolve_route_urls_for_event(
@@ -715,13 +798,24 @@ def notify_user(
if normalized_user_id is None:
return
routes = _resolve_user_routes(normalized_user_id)
urls = _allowed_user_urls(_resolve_route_urls_for_event(routes, event))
routes, admin_default = _resolve_user_routes(normalized_user_id)
urls = expand_user_route_placeholders(
_resolve_route_urls_for_event(routes, event), normalized_user_id
)
if not admin_default:
urls = _allowed_user_urls(urls)
if not urls:
return
try:
_executor.submit(_dispatch_user_async, normalized_user_id, event, context, urls)
_executor.submit(
_dispatch_user_async,
normalized_user_id,
event,
context,
urls,
admin_default=admin_default,
)
except RuntimeError as exc:
logger.warning(
"Failed to queue user notification '%s' for user_id=%s: %s",
@@ -748,9 +842,12 @@ def _dispatch_user_async(
event: NotificationEvent,
context: NotificationContext,
urls: list[str],
*,
admin_default: bool = False,
) -> None:
# Checked again at send time: the host may resolve differently than when it was queued.
urls = _allowed_user_urls(urls)
if not admin_default:
urls = _allowed_user_urls(urls)
if not urls:
return
result = _send_admin_event(event, context, urls)
+2 -2
View File
@@ -12,8 +12,8 @@ from urllib.parse import urlencode, urlsplit, urlunsplit
from authlib.integrations.base_client.errors import OAuthError
from authlib.integrations.flask_client import OAuth
from authlib.jose.errors import InvalidClaimError
from flask import Flask, jsonify, redirect, request, session
from joserfc.errors import InvalidClaimError
from shelfmark.core.config import config as app_config
from shelfmark.core.logger import setup_logger
@@ -261,7 +261,7 @@ def register_oidc_routes(app: Flask, user_db: UserDB) -> None:
try:
token = client.authorize_access_token()
except InvalidClaimError as e:
claim_name = getattr(e, "claim_name", "unknown")
claim_name = getattr(e, "claim", "unknown")
discovery_url = str(config.get("OIDC_DISCOVERY_URL", ""))
provider_issuer = ""
try:
+5
View File
@@ -265,6 +265,11 @@ def get_metadata_provider_fields() -> list[SettingsField]:
"label": "Google Books",
"description": "Google's book database with good coverage. Requires free API key.",
},
{
"value": "dnb",
"label": "DNB (Deutsche Nationalbibliothek)",
"description": "German national library catalog with the best coverage of German-language editions. No API key required.",
},
]
# Clone with onboarding-specific options and default
+100
View File
@@ -0,0 +1,100 @@
"""First-run onboarding: ``/api/onboarding`` and ``/api/onboarding/skip``."""
from __future__ import annotations
import sqlite3
from importlib import import_module
from typing import TYPE_CHECKING
from flask import jsonify, request
from shelfmark.core.logger import setup_logger
if TYPE_CHECKING:
from collections.abc import Callable
from flask import Flask
from werkzeug.wrappers import Response
logger = setup_logger(__name__)
_ONBOARDING_ERRORS = (ImportError, OSError, RuntimeError, TypeError, ValueError, sqlite3.Error)
def register_onboarding_routes(
app: Flask,
login_required: Callable[..., Callable[..., Response | tuple[Response, int]]],
) -> None:
"""Register the onboarding routes.
``login_required`` is the app's decorator. It is also what limits these paths to an
admin session when authentication is on, so it has to be the same one the other routes use.
"""
@app.route("/api/onboarding", methods=["GET"])
@login_required
def api_onboarding_get() -> Response | tuple[Response, int]:
"""Get onboarding configuration including steps, fields, and current values.
Returns:
flask.Response: JSON with onboarding steps and values.
"""
try:
# Ensure settings are registered
import_module("shelfmark.config.settings")
from shelfmark.core.onboarding import get_onboarding_config
config = get_onboarding_config()
return jsonify(config)
except _ONBOARDING_ERRORS as e:
logger.error_trace(f"Onboarding get error: {e}")
return jsonify({"error": str(e)}), 500
@app.route("/api/onboarding", methods=["POST"])
@login_required
def api_onboarding_save() -> Response | tuple[Response, int]:
"""Save onboarding settings and mark as complete.
Request Body:
JSON object with all onboarding field values
Returns:
flask.Response: JSON with success/error status.
"""
try:
# Ensure settings are registered
import_module("shelfmark.config.settings")
from shelfmark.core.onboarding import save_onboarding_settings
data = request.get_json(silent=True)
if not data:
return jsonify({"success": False, "message": "No data provided"}), 400
result = save_onboarding_settings(data)
if result["success"]:
return jsonify(result)
return jsonify(result), 400
except _ONBOARDING_ERRORS as e:
logger.error_trace(f"Onboarding save error: {e}")
return jsonify({"error": str(e)}), 500
@app.route("/api/onboarding/skip", methods=["POST"])
@login_required
def api_onboarding_skip() -> Response | tuple[Response, int]:
"""Skip onboarding and mark as complete without saving any settings.
Returns:
flask.Response: JSON with success status.
"""
try:
from shelfmark.core.onboarding import mark_onboarding_complete
mark_onboarding_complete()
return jsonify({"success": True, "message": "Onboarding skipped"})
except _ONBOARDING_ERRORS as e:
logger.error_trace(f"Onboarding skip error: {e}")
return jsonify({"error": str(e)}), 500
+14 -13
View File
@@ -78,6 +78,7 @@ class BookQueue:
# Ensure added_time is set
if task.added_time == 0:
task.added_time = time.time()
task.finished_at = None
queue_item = QueueItem(task_id, task.priority, task.added_time)
self._queue.put(queue_item)
@@ -168,26 +169,25 @@ class BookQueue:
hook: Callable[[str, QueueStatus, DownloadTask], None] | None = None
hook_task: DownloadTask | None = None
previous_status = self._status.get(book_id)
if (
previous_status in TERMINAL_QUEUE_STATUSES
and status in TERMINAL_QUEUE_STATUSES
and previous_status != status
):
if previous_status in TERMINAL_QUEUE_STATUSES and previous_status != status:
# The first terminal status wins. A worker that checked its cancel flag just before
# a cancel landed would otherwise overwrite CANCELLED with COMPLETE and fire the
# terminal hook a second time.
# terminal hook a second time. A late progress update (DOWNLOADING) is refused for
# the same reason: it would reopen the task and let the next terminal status
# through. A retry goes through add() or enqueue_existing(), which reset the
# status themselves.
return None, None
self._update_status(book_id, status)
if (
status in TERMINAL_QUEUE_STATUSES
and previous_status != status
and self._terminal_status_hook is not None
):
if status in TERMINAL_QUEUE_STATUSES and previous_status != status:
current_task = self._task_data.get(book_id)
if current_task is not None:
hook = self._terminal_status_hook
hook_task = current_task
# Stamped before the hook runs, so a snapshot taken while history is still
# being finalized already knows when the download ended.
current_task.finished_at = time.time()
if self._terminal_status_hook 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:
@@ -340,6 +340,7 @@ class BookQueue:
if priority is not None:
task.priority = priority
task.finished_at = None
# Ensure task doesn't appear active while waiting for retry.
self._active_downloads.pop(task_id, None)
+657
View File
@@ -0,0 +1,657 @@
"""Download queue routes: status, cancel, retry, priority, order, local file and cover."""
from __future__ import annotations
import binascii
import sqlite3
import unicodedata
from pathlib import Path
from typing import TYPE_CHECKING, Any
from urllib.parse import quote
from flask import Response, jsonify, request, send_file, session
from shelfmark.core.download_history_service import DownloadHistoryService
from shelfmark.core.logger import setup_logger
from shelfmark.core.models import build_filename
from shelfmark.core.request_helpers import normalize_optional_text, normalize_positive_int
from shelfmark.core.requests_service import sync_delivery_states_from_queue_status
from shelfmark.core.viewer_redaction import (
is_public_download_id,
match_public_download_id,
redact_status,
viewer_download_id,
)
from shelfmark.download import orchestrator as backend
from shelfmark.download.zip_stream import stream_zip
if TYPE_CHECKING:
from collections.abc import Callable
from flask import Flask
from shelfmark.core.models import DownloadTask
from shelfmark.core.user_db import UserDB
logger = setup_logger(__name__)
_OPERATIONAL_ERRORS = (OSError, RuntimeError, TypeError, ValueError, sqlite3.Error)
_IMPORT_OPERATIONAL_ERRORS = (ImportError, *_OPERATIONAL_ERRORS)
def _task_owned_by_actor(
task: Any, *, actor_user_id: int | None, actor_username: str | None
) -> bool:
"""Whether a queue task belongs to the actor: by user id when both have one, else by username."""
raw_task_user_id = getattr(task, "user_id", None)
try:
task_user_id = int(raw_task_user_id) if raw_task_user_id is not None else None
except TypeError, ValueError:
task_user_id = None
if actor_user_id is not None and task_user_id is not None:
return task_user_id == actor_user_id
task_username = getattr(task, "username", None)
if isinstance(task_username, str) and task_username.strip() and isinstance(actor_username, str):
return task_username.strip() == actor_username.strip()
return False
def _download_row_owned_by_actor(
row: dict[str, Any],
*,
actor_user_id: int | None,
actor_username: str | None,
) -> bool:
"""Whether a download history row belongs to the actor, matched the same way as a task."""
owner_user_id = normalize_positive_int(row.get("user_id"))
if actor_user_id is not None and owner_user_id is not None:
return owner_user_id == actor_user_id
row_username = normalize_optional_text(row.get("username"))
if row_username is not None and isinstance(actor_username, str):
return row_username == actor_username.strip()
return False
def _queue_task_visible_to_actor(
task_id: str,
*,
is_admin: bool,
actor_user_id: int | None,
actor_username: str | None,
) -> bool:
"""Whether the actor may see a live queue task: admins see all, others only their own."""
if is_admin:
return True
task = backend.book_queue.get_task(task_id)
if task is None:
return False
return _task_owned_by_actor(
task,
actor_user_id=actor_user_id,
actor_username=actor_username,
)
def _viewer_queue_task_id(task_id: str) -> str:
"""Return the id a non-admin sees for a live queue task."""
task = backend.book_queue.get_task(task_id)
return viewer_download_id(task_id, getattr(task, "request_id", None))
def _attachment_filename_options(download_name: str) -> dict[str, str]:
"""Content-Disposition filename options, RFC 2231-encoded when not plain ASCII.
Mirrors what send_file does, for responses built from a generator.
"""
try:
download_name.encode("ascii")
except UnicodeEncodeError:
simple = unicodedata.normalize("NFKD", download_name)
simple = simple.encode("ascii", "ignore").decode("ascii")
quoted = quote(download_name, safe="!#$&+^`|~")
return {"filename": simple, "filename*": f"UTF-8''{quoted}"}
return {"filename": download_name}
def _zip_download_response(file_paths: list[str], task: DownloadTask) -> Response:
"""Send a multi-file download (audiobook chapters, a pack) as one streamed ZIP."""
download_name = build_filename(task.title or "download", task.author, task.year, "zip")
response = Response(stream_zip(file_paths), mimetype="application/zip")
response.headers.set(
"Content-Disposition", "attachment", **_attachment_filename_options(download_name)
)
return response
def register_queue_routes(
app: Flask,
login_required: Callable[..., Any],
*,
get_user_db: Callable[[], UserDB | None],
get_download_history_service: Callable[[], DownloadHistoryService | None],
resolve_status_scope: Callable[..., tuple[bool, int | None, bool]],
emit_request_update_events: Callable[[list[dict[str, Any]]], None],
) -> None:
"""Register the queue routes.
The history service and the user database are called per request, so a runtime patch of
either still takes effect. ``resolve_status_scope`` and ``emit_request_update_events`` stay
in ``main.py`` because the release routes and the request routes use them too.
"""
def _resolve_queue_actor() -> tuple[bool, int | None, str | None, Response | None]:
"""Return ``(is_admin, db_user_id, username, error)`` for the session.
``error`` is a response to send back when a non-admin has no usable identity.
"""
is_admin, db_user_id, can_access_status = resolve_status_scope()
actor_username = session.get("user_id")
normalized_actor_username = actor_username if isinstance(actor_username, str) else None
if not is_admin and (not can_access_status or db_user_id is None):
return (
is_admin,
db_user_id,
normalized_actor_username,
jsonify(
{"error": "User identity unavailable", "code": "user_identity_unavailable"}
),
)
return is_admin, db_user_id, normalized_actor_username, None
def _resolve_viewer_download_id(book_id: str) -> str:
"""Map a non-admin's opaque download id back to the task it names (#1418).
Only the caller's own request-linked downloads are searched, so an id that
doesn't resolve comes back unchanged and fails the route's usual lookup.
"""
download_history_service = get_download_history_service()
if not is_public_download_id(book_id):
return book_id
is_admin, db_user_id, _ = resolve_status_scope()
if is_admin or db_user_id is None:
return book_id
candidates = [
task_id
for tasks in backend.book_queue.get_status(user_id=db_user_id).values()
for task_id, task in tasks.items()
if getattr(task, "request_id", None) is not None
]
if download_history_service is not None:
candidates.extend(download_history_service.list_request_task_ids(user_id=db_user_id))
return match_public_download_id(book_id, candidates) or book_id
@app.route("/api/status", methods=["GET"])
@login_required
def api_status() -> Response | tuple[Response, int]:
"""Get current download queue status.
Returns:
flask.Response: JSON object with queue status.
"""
user_db = get_user_db()
try:
is_admin, db_user_id, can_access_status = resolve_status_scope()
if not can_access_status:
return jsonify({})
user_id = None if is_admin else db_user_id
status = backend.queue_status(user_id=user_id)
if user_db is not None:
updated_requests = sync_delivery_states_from_queue_status(
user_db,
queue_status=status,
user_id=user_id,
)
emit_request_update_events(updated_requests)
return jsonify(status if is_admin else redact_status(status))
except _OPERATIONAL_ERRORS as e:
logger.error_trace(f"Status error: {e}")
return jsonify({"error": "Could not read the download status"}), 500
@app.route("/api/localdownload", methods=["GET"])
@login_required
def api_local_download() -> Response | tuple[Response, int]:
"""Download a completed book from local storage if available.
Query Parameters:
id (str): Book identifier (MD5 hash)
Returns:
flask.Response: The file if found, a ZIP of all of them when the download
produced several (a live task only; history keeps just the first), otherwise
an error response.
"""
download_history_service = get_download_history_service()
book_id = request.args.get("id", "")
if not book_id:
return jsonify({"error": "No book ID provided"}), 400
book_id = _resolve_viewer_download_id(book_id)
try:
# The path, not the bytes: send_file streams it off disk, where reading it
# first cost two resident copies of the whole book.
file_paths, book_info = backend.get_book_paths(book_id)
if not file_paths:
# Fallback for dismissed/history entries where queue task may no longer exist.
if download_history_service is not None:
is_admin, db_user_id, can_access_status = resolve_status_scope()
if can_access_status:
history_row = download_history_service.get_by_task_id(book_id)
if history_row is not None:
owner_user_id = history_row.get("user_id")
if is_admin or owner_user_id == db_user_id:
download_path = (
DownloadHistoryService._resolve_existing_download_path(
history_row.get("download_path")
)
)
if download_path:
return send_file(
download_path,
download_name=Path(download_path).name,
as_attachment=True,
)
# Book data not found or not available
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
if book_info is not None and len(file_paths) > 1:
return _zip_download_response(file_paths, book_info)
file_name = book_info.get_filename() if book_info is not None else Path(book_id).name
return send_file(file_paths[0], download_name=file_name, as_attachment=True)
except _OPERATIONAL_ERRORS as e:
# The details are logged. The message of an OSError names the file's path on the server,
# which a non-admin never sees anywhere else.
logger.error_trace(f"Local download error: {e}")
return jsonify({"error": "Could not read the file"}), 500
@app.route("/api/covers/<cover_id>", methods=["GET"])
@login_required
def api_cover(cover_id: str) -> Response | tuple[Response, int]:
"""Serve a cached book cover image.
This endpoint proxies and caches cover images from external sources.
Images are cached to disk for faster subsequent requests.
Path Parameters:
cover_id (str): Cover identifier (book ID or composite key for universal mode).
Kept for the link shape only; the cache entry is chosen by `url`.
Query Parameters:
url (str): Base64-encoded original image URL (required)
Returns:
flask.Response: Binary image data with appropriate Content-Type, or 404.
"""
try:
import base64
from shelfmark.config.env import is_covers_cache_enabled
from shelfmark.core.image_cache import cover_cache_key, get_image_cache
# Check if caching is enabled
if not is_covers_cache_enabled():
return jsonify({"error": "Cover caching is disabled"}), 404
encoded_url = request.args.get("url")
if not encoded_url:
return jsonify({"error": "Cover URL not provided"}), 404
try:
original_url = base64.urlsafe_b64decode(encoded_url).decode()
except (binascii.Error, UnicodeDecodeError) as e:
logger.warning("Failed to decode cover URL: %s", e)
return jsonify({"error": "Invalid cover URL encoding"}), 400
# The entry is keyed by the URL, never by the caller-supplied id: the id would
# let any user cache an image of their choosing under another book's link.
cache = get_image_cache()
cache_key = cover_cache_key(original_url)
cached = cache.get(cache_key)
if cached:
image_data, content_type = cached
response = app.response_class(
response=image_data, status=200, mimetype=content_type
)
response.headers["Cache-Control"] = "public, max-age=86400"
response.headers["X-Cache"] = "HIT"
return response
# Fetch and cache the image
result = cache.fetch_and_cache(cache_key, original_url)
if not result:
return jsonify({"error": "Failed to fetch cover image"}), 404
image_data, content_type = result
response = app.response_class(response=image_data, status=200, mimetype=content_type)
response.headers["Cache-Control"] = "public, max-age=86400"
response.headers["X-Cache"] = "MISS"
except _IMPORT_OPERATIONAL_ERRORS as e:
logger.error_trace(f"Cover fetch error: {e}")
return jsonify({"error": "Could not fetch the cover image"}), 500
else:
return response
@app.route("/api/download/<path:book_id>/cancel", methods=["DELETE"])
@login_required
def api_cancel_download(book_id: str) -> Response | tuple[Response, int]:
"""Cancel a download.
Path Parameters:
book_id (str): Book identifier to cancel
Returns:
flask.Response: JSON status indicating success or failure.
"""
viewer_book_id = book_id
book_id = _resolve_viewer_download_id(book_id)
try:
task = backend.book_queue.get_task(book_id)
if task is None:
return jsonify({"error": "Failed to cancel download or book not found"}), 404
is_admin, db_user_id, can_access_status = resolve_status_scope()
if not is_admin:
if not can_access_status or db_user_id is None:
return jsonify(
{"error": "User identity unavailable", "code": "user_identity_unavailable"}
), 403
actor_username = session.get("user_id")
normalized_actor_username = (
actor_username if isinstance(actor_username, str) else None
)
if not _task_owned_by_actor(
task,
actor_user_id=db_user_id,
actor_username=normalized_actor_username,
):
return jsonify({"error": "Forbidden", "code": "download_not_owned"}), 403
if getattr(task, "request_id", None) is not None:
return jsonify(
{"error": "Forbidden", "code": "requested_download_cancel_forbidden"}
), 403
success = backend.cancel_download(book_id)
if success:
return jsonify({"status": "cancelled", "book_id": viewer_book_id})
return jsonify({"error": "Failed to cancel download or book not found"}), 404
except _OPERATIONAL_ERRORS as e:
logger.error_trace(f"Cancel download error: {e}")
return jsonify({"error": "Could not cancel the download"}), 500
@app.route("/api/download/<path:book_id>/retry", methods=["POST"])
@login_required
def api_retry_download(book_id: str) -> Response | tuple[Response, int]:
"""Retry a failed download."""
download_history_service = get_download_history_service()
viewer_book_id = book_id
book_id = _resolve_viewer_download_id(book_id)
try:
task = backend.book_queue.get_task(book_id)
history_row = None
if task is None and download_history_service is not None:
history_row = download_history_service.get_by_task_id(book_id)
if task is None and history_row is None:
return jsonify({"error": "Download not found"}), 404
is_admin, db_user_id, can_access_status = resolve_status_scope()
actor_username = session.get("user_id")
normalized_actor_username = actor_username if isinstance(actor_username, str) else None
if not is_admin:
if not can_access_status or db_user_id is None:
return jsonify(
{"error": "User identity unavailable", "code": "user_identity_unavailable"}
), 403
if task is not None:
if not _task_owned_by_actor(
task,
actor_user_id=db_user_id,
actor_username=normalized_actor_username,
):
return jsonify({"error": "Forbidden", "code": "download_not_owned"}), 403
elif history_row is None or not _download_row_owned_by_actor(
history_row,
actor_user_id=db_user_id,
actor_username=normalized_actor_username,
):
return jsonify({"error": "Forbidden", "code": "download_not_owned"}), 403
if task is not None:
task_status = backend.book_queue.get_task_status(book_id)
if getattr(
task, "request_id", None
) is not None and not backend.can_retry_download_task(task, task_status):
return jsonify(
{"error": "Forbidden", "code": "requested_download_retry_forbidden"}
), 403
success, error = backend.retry_download(book_id)
else:
if history_row is None:
logger.error("Download history row disappeared while retrying task %s", book_id)
return jsonify({"error": "Download history not found"}), 404
request_id = normalize_positive_int(history_row.get("request_id"))
retry_payload = history_row.get("retry_payload")
final_status = history_row.get("final_status")
if request_id is not None:
history_service = download_history_service
if history_service is None:
logger.error(
"Download history service unavailable while retrying task %s", book_id
)
return jsonify({"error": "Download history unavailable"}), 500
if not history_service.is_retry_available(history_row):
return jsonify(
{"error": "Forbidden", "code": "requested_download_retry_forbidden"}
), 403
success, error = backend.retry_persisted_download(
retry_payload,
final_status=final_status,
)
if success:
return jsonify({"status": "queued", "book_id": viewer_book_id})
if error == "Download not found":
return jsonify({"error": error}), 404
return jsonify({"error": error or "Download cannot be retried"}), 409
except _OPERATIONAL_ERRORS as e:
logger.error_trace(f"Retry download error: {e}")
return jsonify({"error": "Could not retry the download"}), 500
@app.route("/api/queue/<path:book_id>/priority", methods=["PUT"])
@login_required
def api_set_priority(book_id: str) -> Response | tuple[Response, int]:
"""Set priority for a queued book.
Path Parameters:
book_id (str): Book identifier
Request Body:
priority (int): New priority level (lower number = higher priority)
Returns:
flask.Response: JSON status indicating success or failure.
"""
try:
data = request.get_json(silent=True)
if not data or "priority" not in data:
return jsonify({"error": "Priority not provided"}), 400
priority = int(data["priority"])
is_admin, db_user_id, actor_username, identity_error = _resolve_queue_actor()
if identity_error is not None:
return identity_error, 403
viewer_book_id = book_id
book_id = _resolve_viewer_download_id(book_id)
task = backend.book_queue.get_task(book_id)
if task is None:
return jsonify({"error": "Failed to update priority or book not found"}), 404
if not is_admin and not _task_owned_by_actor(
task,
actor_user_id=db_user_id,
actor_username=actor_username,
):
return jsonify({"error": "Forbidden", "code": "download_not_owned"}), 403
success = backend.set_book_priority(book_id, priority)
if success:
return jsonify(
{"status": "updated", "book_id": viewer_book_id, "priority": priority}
)
return jsonify({"error": "Failed to update priority or book not found"}), 404
except ValueError:
return jsonify({"error": "Invalid priority value"}), 400
except _OPERATIONAL_ERRORS as e:
logger.error_trace(f"Set priority error: {e}")
return jsonify({"error": "Could not set the priority"}), 500
@app.route("/api/queue/reorder", methods=["POST"])
@login_required
def api_reorder_queue() -> Response | tuple[Response, int]:
"""Bulk reorder queue by setting new priorities.
Request Body:
book_priorities (dict): Mapping of book_id to new priority
Returns:
flask.Response: JSON status indicating success or failure.
"""
try:
data = request.get_json(silent=True)
if not data or "book_priorities" not in data:
return jsonify({"error": "book_priorities not provided"}), 400
book_priorities = data["book_priorities"]
if not isinstance(book_priorities, dict):
return jsonify({"error": "book_priorities must be a dictionary"}), 400
# Validate all priorities are integers
for book_id, priority in book_priorities.items():
if not isinstance(priority, int):
return jsonify({"error": f"Invalid priority for book {book_id}"}), 400
is_admin, db_user_id, actor_username, identity_error = _resolve_queue_actor()
if identity_error is not None:
return identity_error, 403
if not is_admin:
book_priorities = {
_resolve_viewer_download_id(str(book_id)): priority
for book_id, priority in book_priorities.items()
}
owned_book_priorities = {}
for book_id in book_priorities:
task = backend.book_queue.get_task(str(book_id))
if task is None:
continue
if not _task_owned_by_actor(
task, actor_user_id=db_user_id, actor_username=actor_username
):
return jsonify({"error": "Forbidden", "code": "download_not_owned"}), 403
owned_book_priorities[book_id] = book_priorities[book_id]
book_priorities = owned_book_priorities
success = backend.reorder_queue(book_priorities)
if success:
return jsonify({"status": "reordered", "updated_count": len(book_priorities)})
return jsonify({"error": "Failed to reorder queue"}), 500
except _OPERATIONAL_ERRORS as e:
logger.error_trace(f"Reorder queue error: {e}")
return jsonify({"error": "Could not reorder the queue"}), 500
@app.route("/api/queue/order", methods=["GET"])
@login_required
def api_queue_order() -> Response | tuple[Response, int]:
"""Get current queue order for display.
Returns:
flask.Response: JSON array of queued books with their order and priorities.
"""
try:
queue_order = backend.get_queue_order()
is_admin, db_user_id, actor_username, identity_error = _resolve_queue_actor()
if identity_error is not None:
return identity_error, 403
if not is_admin:
queue_order = [
{**item, "id": _viewer_queue_task_id(str(item.get("id", "")))}
for item in queue_order
if _queue_task_visible_to_actor(
str(item.get("id", "")),
is_admin=False,
actor_user_id=db_user_id,
actor_username=actor_username,
)
]
return jsonify({"queue": queue_order})
except _OPERATIONAL_ERRORS as e:
logger.error_trace(f"Queue order error: {e}")
return jsonify({"error": "Could not read the queue order"}), 500
@app.route("/api/downloads/active", methods=["GET"])
@login_required
def api_active_downloads() -> Response | tuple[Response, int]:
"""Get list of currently active downloads.
Returns:
flask.Response: JSON array of active download book IDs.
"""
try:
active_downloads = backend.get_active_downloads()
is_admin, db_user_id, actor_username, identity_error = _resolve_queue_actor()
if identity_error is not None:
return identity_error, 403
if not is_admin:
active_downloads = [
_viewer_queue_task_id(task_id)
for task_id in active_downloads
if _queue_task_visible_to_actor(
task_id,
is_admin=False,
actor_user_id=db_user_id,
actor_username=actor_username,
)
]
return jsonify({"active_downloads": active_downloads})
except _OPERATIONAL_ERRORS as e:
logger.error_trace(f"Active downloads error: {e}")
return jsonify({"error": "Could not read the active downloads"}), 500
+112
View File
@@ -0,0 +1,112 @@
"""Taking credentials and links out of text.
Three jobs, which used to live in three places with three sets of patterns:
* ``redact_secrets`` masks credentials where they stand and leaves the rest of a URL readable.
The log formatter uses it, so nothing logged carries a key.
* ``safe_url`` and ``redact_urls_in_text`` keep a URL's scheme, host and path and drop its query,
for an error message or a log line that names a link.
* ``strip_links`` removes links altogether, for text shown to someone who has no use for them.
This module imports nothing from the rest of the app, so the logger can depend on it.
"""
from __future__ import annotations
import re
from urllib.parse import urlparse, urlunparse
# Query parameters that carry tracker/indexer credentials. Prowlarr guids are often
# the download URL itself (apikey included), and they end up in task ids that are
# logged all over the download pipeline, so mask them at the formatter instead.
_SECRET_QUERY_PARAM_PATTERN = re.compile(
r"(?<=[?&;])"
r"([a-z0-9_-]*api[_-]?key|api|apitoken|access_token|token|passkey|authkey|rsskey"
r"|torrent_pass|auth|key|password|passwd|pwd|pass|secret|sid|uid)"
r"=[^&\s#'\"<>;:,)\]]+",
re.IGNORECASE,
)
# Credentials in a URL's userinfo, as in a proxy's socks5://user:pass@host:port. The authority
# ends at the first "/", "?" or "#" (RFC 3986), so an "@" after one of those is not userinfo.
_URL_USERINFO_PATTERN = re.compile(r"(://)[^/?#@\s'\"<>]+@")
# A credential carried as a path segment: a tracker passkey (/rss/<key>), or the token in the
# URL of a chat service's bot or webhook, which notification URLs end up logging. Only segments
# that name a secret are matched, so ids in our own routes (/api/download/<md5>) stay readable.
_SECRET_PATH_SEGMENT_PATTERN = re.compile(
r"(?<=/)((?:passkey|apikey|api[_-]key|rsskey|authkey|token|secret|rss|torznab)/)"
r"[A-Za-z0-9_.~-]{16,}",
re.IGNORECASE,
)
_CHAT_SERVICE_TOKEN_PATTERNS = (
# https://api.telegram.org/bot<id>:<token>/sendMessage
re.compile(r"(/bot)\d+:[A-Za-z0-9_-]{20,}"),
# https://discord.com/api/webhooks/<id>/<token>
re.compile(r"(/webhooks/\d+/)[A-Za-z0-9_-]{20,}"),
# https://hooks.slack.com/services/T000/B000/<token>
re.compile(r"(/services/[A-Z0-9]+/[A-Z0-9]+/)[A-Za-z0-9]{16,}"),
)
# A link inside a message. A client error quotes the request: "401 Client Error: Unauthorized
# for url: <full url>", or for a refused connection "Max retries exceeded with url: <path and
# query>" with no scheme. Schemes are case-insensitive, and a closing quote or bracket is not
# part of the link.
_LINK_IN_TEXT_PATTERN = re.compile(
r"https?://[^\s'\"<>)\]]+|(?<=url: )/[^\s'\"<>)\]]*",
re.IGNORECASE,
)
_REMOVED_LINK = "(link removed)"
def redact_secrets(text: str) -> str:
"""Mask credentials in text, keeping the rest of a URL readable.
Covers credential query parameters, userinfo (``user:pass@host``), path segments that
name a secret and the tokens of Telegram, Discord and Slack URLs.
"""
text = _SECRET_QUERY_PARAM_PATTERN.sub(r"\1=(removed)", text)
text = _URL_USERINFO_PATTERN.sub(r"\1(removed)@", text)
text = _SECRET_PATH_SEGMENT_PATTERN.sub(r"\1(removed)", text)
for pattern in _CHAT_SERVICE_TOKEN_PATTERNS:
text = pattern.sub(r"\1(removed)", text)
return text
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]
def _link_without_query(link: str) -> str:
if link.startswith("/"):
return link.split("?", 1)[0].split("#", 1)[0]
return safe_url(link)
def redact_urls_in_text(text: str) -> str:
"""Scrub credential-bearing links out of free-form text such as exception messages.
A full URL keeps its scheme, host and path, and the path a failed request prints after
``url: `` keeps everything before its query.
"""
return _LINK_IN_TEXT_PATTERN.sub(lambda match: _link_without_query(match.group(0)), text)
def strip_links(text: str) -> str:
"""Replace every link in text with a marker, keeping the rest of the text.
A link is a full http(s) URL, or the scheme-less path after ``url: `` that requests
prints for a failed connection. The link can carry an indexer key or a tracker passkey in
its query string or path, and a non-admin has no use for it.
"""
return _LINK_IN_TEXT_PATTERN.sub(_REMOVED_LINK, text)
+807
View File
@@ -0,0 +1,807 @@
"""Release routes: listing, download, release sources and source records."""
from __future__ import annotations
import re
import sqlite3
from typing import TYPE_CHECKING, Any
from flask import jsonify, request, session
from shelfmark.core import search_deadline
from shelfmark.core.config import config as app_config
from shelfmark.core.logger import setup_logger
from shelfmark.core.models import SearchFilters
from shelfmark.core.release_inspect_routes import register_release_inspect_routes
from shelfmark.core.request_helpers import (
coerce_bool,
get_session_db_user_id,
)
from shelfmark.core.request_policy import (
DEFAULT_SUPPORTED_CONTENT_TYPES,
PolicyMode,
get_source_content_type_capabilities,
merge_request_policy_settings,
normalize_content_type,
normalize_source,
resolve_policy_mode,
)
from shelfmark.core.utils import AUDIOBOOK_FORMATS
from shelfmark.download import orchestrator as backend
from shelfmark.download import warmup
from shelfmark.release_sources import BrowseRecord, Release, SourceUnavailableError
if TYPE_CHECKING:
from collections.abc import Callable
from flask import Flask
from werkzeug.wrappers import Response
from shelfmark.core.user_db import UserDB
from shelfmark.metadata_providers import BookMetadata
logger = setup_logger(__name__)
_OPERATIONAL_ERRORS = (OSError, RuntimeError, TypeError, ValueError, sqlite3.Error)
_IMPORT_OPERATIONAL_ERRORS = (ImportError, *_OPERATIONAL_ERRORS)
_AUDIOBOOK_CATEGORY_RANGE = (3030, 3049)
_AUDIOBOOK_FORMAT_HINTS = frozenset(AUDIOBOOK_FORMATS)
def _contains_audiobook_format_hint(value: Any) -> bool:
"""Whether a string names an audiobook format as one of its words (m4b, mp3 and the like)."""
if not isinstance(value, str):
return False
normalized = value.strip().lower()
if not normalized:
return False
tokens = [token for token in re.split(r"[^a-z0-9]+", normalized) if token]
return any(token in _AUDIOBOOK_FORMAT_HINTS for token in tokens)
def _resolve_release_content_type(data: dict[str, Any], source: Any) -> tuple[str, bool]:
"""Resolve release content type for policy checks and queue payload normalization."""
extra = data.get("extra")
if not isinstance(extra, dict):
extra = {}
explicit_content_type = data.get("content_type")
if explicit_content_type is None:
explicit_content_type = extra.get("content_type")
if explicit_content_type is not None:
return normalize_content_type(explicit_content_type), False
categories = extra.get("categories")
if isinstance(categories, list):
min_cat, max_cat = _AUDIOBOOK_CATEGORY_RANGE
for raw_category in categories:
try:
category_id = int(raw_category)
except TypeError, ValueError:
continue
if min_cat <= category_id <= max_cat:
return "audiobook", True
candidates: list[Any] = [
data.get("format"),
extra.get("format"),
extra.get("formats_display"),
data.get("title"),
]
formats = extra.get("formats")
if isinstance(formats, list):
candidates.extend(formats)
else:
candidates.append(formats)
if any(_contains_audiobook_format_hint(candidate) for candidate in candidates):
return "audiobook", True
capabilities = get_source_content_type_capabilities()
supported = capabilities.get(normalize_source(source))
if supported and len(supported) == 1:
return normalize_content_type(next(iter(supported))), True
return "ebook", False
def _policy_block_response(
mode: PolicyMode, *, error: str = "Download not allowed by policy"
) -> tuple[Response, int]:
"""Build the 403 for a request the user's policy mode refuses, with the mode they need."""
logger.debug(
"download policy guard user=%s db_user_id=%s mode=%s",
session.get("user_id"),
session.get("db_user_id"),
mode.value,
)
if mode == PolicyMode.BLOCKED:
return (
jsonify(
{
"error": error,
"code": "policy_blocked",
"required_mode": PolicyMode.BLOCKED.value,
}
),
403,
)
return (
jsonify(
{
"error": error,
"code": "policy_requires_request",
"required_mode": mode.value,
}
),
403,
)
# Modes under which the user never sees a release list: request_book asks for the
# book as a whole and blocked gets nothing. request_release still lists releases,
# since that user picks the one they ask for. Listing goes through the shared
# source credentials (Prowlarr, torrent clients, scrapers), so the routes that
# list have to refuse these two modes just as the download route does.
_RELEASE_LISTING_REFUSED_MODES = frozenset({PolicyMode.REQUEST_BOOK, PolicyMode.BLOCKED})
def _release_listing_block_response(mode: PolicyMode) -> tuple[Response, int]:
"""Build the 403 for a release listing the user's policy mode refuses."""
return _policy_block_response(mode, error="Release listing not allowed by policy")
class _ReleasePolicy:
"""Request-policy checks for the current session, shared by the release routes."""
def __init__(
self,
*,
resolve_auth_mode: Callable[[], str],
get_user_db: Callable[[], UserDB | None],
load_policy_settings: Callable[[], dict[str, Any]],
) -> None:
"""Take the lookups as callables, so each check reads the current settings."""
self._resolve_auth_mode = resolve_auth_mode
self._get_user_db = get_user_db
self._load_policy_settings = load_policy_settings
def mode_for_current_user(self, *, source: Any, content_type: Any) -> PolicyMode | None:
"""Resolve policy mode for current session, or None when policy guard is bypassed."""
user_db = self._get_user_db()
auth_mode = self._resolve_auth_mode()
if auth_mode == "none":
return None
if session.get("is_admin", False):
return None
if user_db is None:
return None
global_settings = self._load_policy_settings()
db_user_id = session.get("db_user_id")
user_settings: dict[str, Any] | None = None
if db_user_id is not None:
try:
user_settings = user_db.get_user_settings(int(db_user_id))
except TypeError, ValueError:
user_settings = None
effective = merge_request_policy_settings(global_settings, user_settings)
if not coerce_bool(effective.get("REQUESTS_ENABLED"), default=False):
return None
resolved_mode = resolve_policy_mode(
source=source,
content_type=content_type,
global_settings=global_settings,
user_settings=user_settings,
)
logger.debug(
"download policy resolve user=%s db_user_id=%s is_admin=%s source=%s content_type=%s mode=%s",
session.get("user_id"),
db_user_id,
bool(session.get("is_admin", False)),
source,
content_type,
resolved_mode.value,
)
return resolved_mode
def listing_refusal(self, *, source: Any, content_type: Any) -> PolicyMode | None:
"""Return the policy mode when the current user may not list releases from `source`."""
mode = self.mode_for_current_user(source=source, content_type=content_type)
if mode is not None and mode in _RELEASE_LISTING_REFUSED_MODES:
return mode
return None
def sources_allowed_for_listing(
self, sources: list[str], content_type: Any
) -> tuple[list[str], PolicyMode | None]:
"""Drop the sources the current user may not list releases from.
Returns the sources left and, when any was dropped, the mode of the first one
dropped, which is what the response reports when nothing is left.
"""
allowed: list[str] = []
refused_mode: PolicyMode | None = None
for source_name in sources:
refusal = self.listing_refusal(source=source_name, content_type=content_type)
if refusal is None:
allowed.append(source_name)
elif refused_mode is None:
refused_mode = refusal
return allowed, refused_mode
def source_record_refusal(self, source_name: str) -> PolicyMode | None:
"""Policy refusal for resolving one browse record of a release source.
The record's content type is not known before it is fetched, so the user is
refused only when every content type the source supports is refused.
"""
capabilities = get_source_content_type_capabilities()
content_types = capabilities.get(normalize_source(source_name)) or set(
DEFAULT_SUPPORTED_CONTENT_TYPES
)
refused_mode: PolicyMode | None = None
for content_type in sorted(content_types):
refusal = self.listing_refusal(source=source_name, content_type=content_type)
if refusal is None:
return None
if refused_mode is None:
refused_mode = refusal
return refused_mode
def payload_listing_gate(self, data: dict[str, Any]) -> tuple[Response, int] | None:
"""Policy gate for routes that take a release payload, shared with the inspect route."""
source = data["source"]
resolved_content_type, _inferred = _resolve_release_content_type(data, source)
refusal = self.listing_refusal(source=source, content_type=resolved_content_type)
if refusal is None:
return None
return _release_listing_block_response(refusal)
def resolve_download_user_context(
self,
db_user_id: Any,
username: Any,
on_behalf_of_user_id: Any,
) -> tuple[Any, Any, tuple[Response, int] | None]:
"""Resolve download queue user context, including optional admin on-behalf overrides."""
user_db = self._get_user_db()
if on_behalf_of_user_id in (None, ""):
return db_user_id, username, None
if not session.get("is_admin", False):
return db_user_id, username, (jsonify({"error": "Admin required"}), 403)
if user_db is None:
return db_user_id, username, (jsonify({"error": "User database unavailable"}), 503)
try:
target_user_id = int(on_behalf_of_user_id)
except TypeError, ValueError:
return db_user_id, username, (jsonify({"error": "Invalid on_behalf_of_user_id"}), 400)
if target_user_id <= 0:
return db_user_id, username, (jsonify({"error": "Invalid on_behalf_of_user_id"}), 400)
target_user = user_db.get_user(user_id=target_user_id)
if not target_user:
return db_user_id, username, (jsonify({"error": "User not found"}), 404)
return target_user["id"], target_user["username"], None
def _parse_search_filters_from_request() -> SearchFilters:
"""Parse direct/source browse filters from query parameters."""
return SearchFilters(
isbn=request.args.getlist("isbn"),
author=request.args.getlist("author"),
title=request.args.getlist("title"),
lang=request.args.getlist("lang"),
sort=request.args.get("sort"),
content=request.args.getlist("content"),
format=request.args.getlist("format"),
)
def _build_source_query_book(query_text: str, filters: SearchFilters) -> BookMetadata:
"""Build a synthetic book context for source-native browse searches."""
from shelfmark.metadata_providers import BookMetadata
author_values = [value.strip() for value in (filters.author or []) if str(value).strip()]
title_values = [value.strip() for value in (filters.title or []) if str(value).strip()]
isbn_values = [value.strip() for value in (filters.isbn or []) if str(value).strip()]
title = (
title_values[0]
if title_values
else query_text
or (isbn_values[0] if isbn_values else "")
or (author_values[0] if author_values else "Direct Search")
)
author = author_values[0] if author_values else ""
return BookMetadata(
provider="manual",
provider_id=query_text or title,
provider_display_name="Manual Search",
title=title,
search_title=title,
search_author=author or None,
authors=author_values,
)
def _serialize_browse_record(record: BrowseRecord) -> dict:
"""Serialize a source-native browse record for the frontend."""
result = {key: value for key, value in record.__dict__.items() if value is not None}
preview = result.get("preview")
if isinstance(preview, str) and preview:
from shelfmark.core.utils import transform_cover_url
result["preview"] = transform_cover_url(preview, record.id)
return result
def _serialize_release(release: Release) -> dict:
"""Serialize a release for the frontend, normalizing preview URLs."""
from dataclasses import asdict
from shelfmark.core.utils import transform_cover_url
result = asdict(release)
extra = result.get("extra")
if isinstance(extra, dict):
preview = extra.get("preview")
if isinstance(preview, str) and preview:
extra = dict(extra)
extra["preview"] = transform_cover_url(preview, release.source_id)
result["extra"] = extra
return result
def register_release_routes(
app: Flask,
login_required: Callable[..., Any],
*,
resolve_auth_mode: Callable[[], str],
get_user_db: Callable[[], UserDB | None],
load_policy_settings: Callable[[], dict[str, Any]],
resolve_status_scope: Callable[..., tuple[bool, int | None, bool]],
attach_library_ownership: Callable[[Any, dict[str, Any]], None],
) -> None:
"""Register the release listing, download, source list and source record routes."""
policy = _ReleasePolicy(
resolve_auth_mode=resolve_auth_mode,
get_user_db=get_user_db,
load_policy_settings=load_policy_settings,
)
register_release_inspect_routes(app, login_required, policy_gate=policy.payload_listing_gate)
@app.route("/api/releases/download", methods=["POST"])
@login_required
def api_download_release() -> Response | tuple[Response, int]:
"""Queue a release for download.
This endpoint is used when downloading from the ReleaseModal where the
frontend already has all the release data from the search results.
Request Body (JSON):
source (str): Release source (e.g., "direct_download")
source_id (str): ID within the source (e.g., AA MD5 hash)
title (str): Book title
format (str, optional): File format
size (str, optional): Human-readable size
extra (dict, optional): Additional metadata
Returns:
flask.Response: JSON status object indicating success or failure.
"""
try:
data = request.get_json(silent=True)
if not data:
return jsonify({"error": "No data provided"}), 400
if "source_id" not in data:
return jsonify({"error": "source_id is required"}), 400
if "source" not in data:
return jsonify({"error": "source is required"}), 400
source = data["source"]
resolved_content_type, inferred_content_type = _resolve_release_content_type(
data, source
)
policy_mode = policy.mode_for_current_user(
source=source,
content_type=resolved_content_type,
)
if policy_mode is not None and policy_mode != PolicyMode.DOWNLOAD:
return _policy_block_response(policy_mode)
release_payload = data
if inferred_content_type and data.get("content_type") is None:
release_payload = dict(data)
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)
# Per-user download overrides
db_user_id = session.get("db_user_id")
_username = session.get("user_id")
db_user_id, _username, on_behalf_error = policy.resolve_download_user_context(
db_user_id,
_username,
data.get("on_behalf_of_user_id"),
)
if on_behalf_error:
return on_behalf_error
success, error_msg = backend.queue_release(
release_payload,
priority,
user_id=db_user_id,
username=_username,
)
if success:
return jsonify({"status": "queued", "priority": priority})
return jsonify({"error": error_msg or "Failed to queue release"}), 500
except _OPERATIONAL_ERRORS as e:
logger.error_trace(f"Release download error: {e}")
return jsonify({"error": "Could not queue the release"}), 500
@app.route("/api/releases", methods=["GET"])
@login_required
def api_releases() -> Response | tuple[Response, int]:
"""Search for downloadable releases of a book.
This endpoint takes book metadata and searches available release sources
(e.g., Anna's Archive, Libgen) for downloadable files.
Query Parameters:
provider (str): Metadata provider name (required)
book_id (str): Book ID from metadata provider (required)
source (str): Release source to search (optional, default: all)
Returns:
flask.Response: JSON with list of available releases.
"""
try:
from dataclasses import asdict
from shelfmark.core.release_search import search_source_releases
from shelfmark.metadata_providers import (
BookMetadata,
get_provider,
get_provider_kwargs,
is_provider_registered,
)
from shelfmark.release_sources import (
apply_column_visibility,
browse_record_to_book_metadata,
get_source,
list_available_sources,
serialize_column_config,
source_results_are_releases,
)
def _search_source_releases(
source_name: str, search_book: BookMetadata
) -> tuple[Any | None, list[Any], str | None]:
"""Search one source and return any error message instead of raising."""
return search_source_releases(
source_name,
search_book,
languages=(
browse_filters.lang if source_query_filters is not None else languages
),
manual_query=(query_text if source_query_filters is not None else manual_query),
indexers=indexers,
expand_search=expand_search,
content_type=content_type,
source_filters=source_query_filters,
user_id=db_user_id,
include_error_detail=resolve_status_scope(require_authenticated=False)[0],
)
provider = request.args.get("provider", "").strip()
book_id = request.args.get("book_id", "").strip()
source_filter = request.args.get("source", "").strip()
query_text = request.args.get("query", "").strip()
# Accept title/author from frontend to avoid re-fetching metadata
title_param = request.args.get("title", "").strip()
author_param = request.args.get("author", "").strip()
expand_search = request.args.get("expand_search", "").lower() == "true"
# Accept language codes for filtering (comma-separated)
languages_param = request.args.get("languages", "").strip()
languages = (
[lang.strip() for lang in languages_param.split(",") if lang.strip()]
if languages_param
else None
)
# Without an explicit filter the plan falls back to this user's default languages.
db_user_id = get_session_db_user_id(session)
# Content type for audiobook vs ebook search
content_type = request.args.get("content_type", "ebook").strip()
manual_query = request.args.get("manual_query", "").strip()
# Accept indexer names for Prowlarr filtering (comma-separated)
indexers_param = request.args.get("indexers", "").strip()
indexers = (
[idx.strip() for idx in indexers_param.split(",") if idx.strip()]
if indexers_param
else None
)
browse_filters = _parse_search_filters_from_request()
has_browse_filters = bool(query_text or any(vars(browse_filters).values()))
source_query_filters = None
is_source_provider = bool(provider) and source_results_are_releases(provider)
if not provider or not book_id:
if not source_filter or not has_browse_filters:
return jsonify(
{"error": "Parameters 'provider' and 'book_id' are required"}
), 400
if not source_results_are_releases(source_filter):
return jsonify(
{"error": f"Source does not support browse release search: {source_filter}"}
), 400
source_query_filters = browse_filters
# Determine which release sources to search
if source_filter:
sources_to_search = [source_filter]
elif is_source_provider:
# Source-backed browse flows stay within the source that produced the record.
sources_to_search = [provider]
else:
# Search only enabled sources
sources_to_search = [
src["name"] for src in list_available_sources() if src["enabled"]
]
# The policy gate comes before anything is asked of a source: listing goes
# through the shared source credentials, so a user whose policy never shows
# a release list (request_book, blocked) must not reach them.
sources_to_search, refused_mode = policy.sources_allowed_for_listing(
sources_to_search, content_type
)
if not sources_to_search and refused_mode is not None:
return _release_listing_block_response(refused_mode)
book: BookMetadata
if source_query_filters is not None:
book = _build_source_query_book(query_text, browse_filters)
elif is_source_provider:
# Source-backed browse flows can reopen the release modal with provider=<source name>.
# In that flow, treat the source-native record as release-search context instead of
# requiring a metadata provider registration.
# A source named in `source` was gated above. When provider names another one,
# its record lookup needs the refusal the record route applies.
if normalize_source(provider) not in {
normalize_source(s) for s in sources_to_search
}:
refused_record_mode = policy.source_record_refusal(provider)
if refused_record_mode is not None:
return _release_listing_block_response(refused_record_mode)
source = get_source(provider)
direct_record = source.get_record(book_id)
if direct_record is None:
return jsonify({"error": "Book not found in release source"}), 404
book = browse_record_to_book_metadata(
direct_record,
title_override=title_param or None,
author_override=author_param or None,
)
elif provider == "manual":
resolved_title = title_param or manual_query or "Manual Search"
resolved_author = author_param or ""
# The release modal sends `authors.join(', ')` as `author`, so the commas here
# are joins between contributors, not part of one name. This split is the only
# place that knows that, so `search_author` comes from it rather than from the
# joined text - see issue #1252.
authors = [a.strip() for a in resolved_author.split(",") if a.strip()]
book = BookMetadata(
provider="manual",
provider_id=book_id,
provider_display_name="Manual Search",
title=resolved_title,
search_title=resolved_title,
search_author=authors[0] if authors else None,
authors=authors,
)
else:
if not is_provider_registered(provider):
return jsonify({"error": f"Unknown metadata provider: {provider}"}), 400
# Get book metadata from provider
kwargs = get_provider_kwargs(provider)
prov = get_provider(provider, **kwargs)
resolved_book = prov.get_book(book_id)
if not resolved_book:
return jsonify({"error": "Book not found in metadata provider"}), 404
book = resolved_book
# Override title from frontend if available (search results may have better data)
# Note: We intentionally DON'T override authors here - get_book() now returns
# filtered authors (primary authors only, excluding translators/narrators),
# which gives better release search results than the unfiltered search data
if title_param:
if title_param != book.title:
# A different title is one the search result carried and the book
# record does not, typically a translation's ("Kinder des Nebels" for
# "Mistborn: The Final Empire"). The provider's search_title derives
# from the original and would otherwise win the release search.
book.search_title = title_param
book.title = title_param
# Search each source for releases.
#
# Under a wall-clock budget: this endpoint is synchronous, and the bypass path it
# can reach used to be allowed minutes per URL with nothing bounding the request
# as a whole. A search that ran into an unsolvable protection challenge therefore
# outlived every reverse proxy in front of it and surfaced to the user as
# "Server unavailable (504)" - a gateway timeout that blames their proxy for a
# challenge failure. The budget is shared across sources, so a stuck first source
# cannot spend the whole request on its own. See issue #1276.
all_releases = []
errors = []
source_instances = {} # Keep source instances for column config
# A real search is under way, so a warm-up still sitting on its start-up delay
# should stand down rather than queue its throwaway solve in front of this one.
warmup.note_user_search()
with search_deadline.search_deadline():
for index, source_name in enumerate(sources_to_search):
if search_deadline.expired():
logger.warning("Release search budget spent; %s not searched", source_name)
errors.append(f"{source_name}: {search_deadline.deadline_message()}")
continue
with search_deadline.unit_deadline(waiting=len(sources_to_search) - index - 1):
source, releases, error = _search_source_releases(source_name, book)
if source is not None:
source_instances[source_name] = source
all_releases.extend(releases)
if error is not None:
errors.append(error)
# Convert Release objects to dicts
releases_data = [_serialize_release(release) for release in all_releases]
# Get column config from the first source searched
# Reuse the same instance to get any dynamic data (e.g., online_servers for IRC)
column_config = None
if sources_to_search and sources_to_search[0] in source_instances:
try:
first_source = source_instances[sources_to_search[0]]
column_config = serialize_column_config(
apply_column_visibility(
first_source.get_column_config(),
content_type=normalize_content_type(content_type),
is_setting_enabled=lambda key: bool(
app_config.get(key, True, user_id=db_user_id)
),
)
)
except _OPERATIONAL_ERRORS as e:
logger.warning("Failed to get column config: %s", e)
# Convert book to dict and transform cover_url
book_dict = asdict(book)
attach_library_ownership(book, book_dict)
from shelfmark.core.utils import transform_cover_url
if book_dict.get("cover_url"):
cache_id = f"{provider}_{book_id}"
book_dict["cover_url"] = transform_cover_url(book_dict["cover_url"], cache_id)
search_info = {}
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
):
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 = {
"releases": releases_data,
"book": book_dict,
"sources_searched": sources_to_search,
"column_config": column_config,
"search_info": search_info,
}
if errors:
response["errors"] = errors
# If no releases found and there were errors, return 503 with the first
# source failure message so direct-mode source query searches surface the
# same unavailable-state messaging as release modal searches.
if not releases_data and errors:
# Use the first error message (typically the most relevant)
error_message = errors[0]
# Strip the source prefix if present (e.g., "direct_download: message" -> "message")
if ": " in error_message:
error_message = error_message.split(": ", 1)[1]
return jsonify({"error": error_message}), 503
return jsonify(response)
except SourceUnavailableError as e:
logger.warning("Release search unavailable: %s", e)
return jsonify({"error": str(e)}), 503
except _IMPORT_OPERATIONAL_ERRORS as e:
logger.error_trace(f"Releases search error: {e}")
return jsonify({"error": "Could not search for releases"}), 500
@app.route("/api/release-sources", methods=["GET"])
@login_required
def api_release_sources() -> Response | tuple[Response, int]:
"""Get available release sources from the plugin registry.
Returns:
flask.Response: JSON list of available release sources.
"""
try:
from shelfmark.release_sources import list_available_sources
sources = list_available_sources()
return jsonify(sources)
except _IMPORT_OPERATIONAL_ERRORS as e:
logger.error_trace(f"Release sources error: {e}")
return jsonify({"error": "Could not list the release sources"}), 500
@app.route("/api/release-sources/<source_name>/records/<path:record_id>", methods=["GET"])
@login_required
def api_release_source_record(
source_name: str, record_id: str
) -> Response | tuple[Response, int]:
"""Resolve a source-native browse record for a release source."""
try:
from shelfmark.release_sources import get_source
source = get_source(source_name)
refused_mode = policy.source_record_refusal(source_name)
if refused_mode is not None:
return _release_listing_block_response(refused_mode)
record = source.get_record(record_id)
if record is None:
return jsonify({"error": "Record not found"}), 404
return jsonify(_serialize_browse_record(record))
except ValueError:
return jsonify({"error": f"Unknown release source: {source_name}"}), 400
except SourceUnavailableError as e:
logger.warning("Release source record unavailable: %s", e)
return jsonify({"error": str(e)}), 503
except _OPERATIONAL_ERRORS as e:
logger.error_trace(f"Release source record error: {e}")
return jsonify({"error": "Could not resolve the record"}), 500
+7 -1
View File
@@ -36,12 +36,16 @@ def search_source_releases(
content_type: str = "ebook",
source_filters: SearchFilters | None = None,
user_id: int | None = None,
include_error_detail: bool = True,
) -> 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.
``include_error_detail=False`` keeps the text of an unexpected error out of the returned
message, for callers that hand it to a non-admin. It is still logged in full. The message
of a ``SourceUnavailableError`` is written for the user and is always kept.
"""
from shelfmark.release_sources import SourceUnavailableError, get_source
@@ -89,6 +93,8 @@ def search_source_releases(
)
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}"
if include_error_detail or isinstance(exc, SourceUnavailableError):
return None, [], f"{source_name}: {exc!s}"
return None, [], f"{source_name}: search failed"
else:
return source, releases, None
+17
View File
@@ -180,6 +180,23 @@ def populate_request_usernames(rows: list[dict[str, Any]], user_db: object) -> N
row["username"] = cache[requester_id]
def populate_request_reviewers(rows: list[dict[str, Any]], user_db: object) -> None:
"""Add 'reviewer_username' to each reviewed request row. Admin views only."""
if not _is_user_db_like(user_db):
return
cache: dict[int, str] = {}
for row in rows:
reviewer_id = normalize_positive_int(row.get("reviewed_by"))
if reviewer_id is None:
row["reviewer_username"] = ""
continue
if reviewer_id not in cache:
reviewer = user_db.get_user(user_id=reviewer_id)
cache[reviewer_id] = reviewer.get("username", "") if reviewer else ""
row["reviewer_username"] = cache[reviewer_id]
def extract_release_source_id(release_data: object) -> str | None:
"""Extract and normalize release_data.source_id."""
if not isinstance(release_data, dict):
+1
View File
@@ -70,6 +70,7 @@ REQUEST_POLICY_KEYS = frozenset(
"REQUEST_POLICY_RULES",
"MAX_PENDING_REQUESTS_PER_USER",
"REQUESTS_ALLOW_NOTES",
"REQUESTS_ALLOW_BOOK_REQUEST_WITH_DOWNLOAD",
}
)
+56
View File
@@ -21,6 +21,7 @@ from shelfmark.core.request_helpers import (
load_users_request_policy_settings,
normalize_optional_text,
normalize_positive_int,
populate_request_reviewers,
populate_request_usernames,
)
from shelfmark.core.request_policy import (
@@ -304,6 +305,10 @@ def _prepare_request_create_arguments(
user_db: UserDB,
data: dict[str, Any],
) -> dict[str, Any]:
"""Check a request payload against the policy and build create_request's arguments.
A book-level request in Download mode becomes a pending one when the actor may ask for it.
"""
db_user_id, db_gate = _require_db_user_id()
if db_gate is not None or db_user_id is None:
msg = "User identity is unavailable for request workflow"
@@ -404,6 +409,21 @@ def _prepare_request_create_arguments(
)
requested_level = str(request_level).strip().lower() if isinstance(request_level, str) else ""
if (
resolved_mode == PolicyMode.DOWNLOAD
and requested_level == "book"
and release_data is None
and (
bool(session.get("is_admin", False))
or coerce_bool(
effective.get("REQUESTS_ALLOW_BOOK_REQUEST_WITH_DOWNLOAD"), default=False
)
)
):
# Everything can be downloaded here, but the user asked for the book itself (nothing to
# download yet, or no source has it): leave it for an admin instead of refusing it.
resolved_mode = PolicyMode.REQUEST_BOOK
if resolved_mode == PolicyMode.REQUEST_BOOK and requested_level != "book":
msg = "Policy requires book-level requests"
raise RequestServiceError(
@@ -571,6 +591,7 @@ def register_request_routes(
@app.route("/api/request-policy", methods=["GET"])
def api_request_policy() -> ResponseReturnValue:
"""The signed-in user's request policy, as the frontend needs it to label actions."""
auth_gate = _require_request_endpoints_available(resolve_auth_mode)
if auth_gate is not None:
return auth_gate
@@ -624,6 +645,9 @@ def register_request_routes(
"requests_enabled": requests_enabled,
"is_admin": is_admin,
"allow_notes": coerce_bool(effective.get("REQUESTS_ALLOW_NOTES"), default=True),
"allow_book_request_with_download": coerce_bool(
effective.get("REQUESTS_ALLOW_BOOK_REQUEST_WITH_DOWNLOAD"), default=False
),
"defaults": {
"ebook": (
default_ebook_mode.value
@@ -915,6 +939,7 @@ def register_request_routes(
@app.route("/api/admin/requests", methods=["GET"])
def api_admin_list_requests() -> ResponseReturnValue:
"""List requests for an admin, filtered by ?status and paged by ?limit and ?offset."""
auth_gate = _require_request_endpoints_available(resolve_auth_mode)
if auth_gate is not None:
return auth_gate
@@ -931,6 +956,7 @@ def register_request_routes(
return jsonify({"error": str(exc)}), 400
populate_request_usernames(rows, user_db)
populate_request_reviewers(rows, user_db)
return jsonify(rows)
@@ -1080,6 +1106,36 @@ def register_request_routes(
return jsonify(updated)
@app.route("/api/admin/requests/rejected-hidden", methods=["GET"])
def api_admin_list_hidden_rejected() -> ResponseReturnValue:
"""Ids of the rejected requests an admin hid from the Rejected view."""
auth_gate = _require_request_endpoints_available(resolve_auth_mode)
if auth_gate is not None:
return auth_gate
if not session.get("is_admin", False):
return jsonify({"error": "Admin access required"}), 403
return jsonify({"request_ids": user_db.list_hidden_rejected_request_ids()})
@app.route("/api/admin/requests/<int:request_id>/rejected-hidden", methods=["POST"])
def api_admin_set_rejected_hidden(request_id: int) -> ResponseReturnValue:
"""Hide or show a rejected request in the Rejected view; 409 if it is not rejected."""
auth_gate = _require_request_endpoints_available(resolve_auth_mode)
if auth_gate is not None:
return auth_gate
_admin_user_id, admin_gate = _require_admin_user_id()
if admin_gate is not None:
return admin_gate
data = request.get_json(silent=True) or {}
hidden = data.get("hidden") if isinstance(data, dict) else None
if not isinstance(hidden, bool):
return jsonify({"error": "hidden must be true or false"}), 400
if not user_db.set_rejected_request_hidden(request_id, hidden=hidden):
return _error_response(
"Only a rejected request can be hidden", 409, code="stale_transition"
)
return jsonify({"request_id": request_id, "hidden": hidden})
@app.route("/api/admin/requests/<int:request_id>/reopen", methods=["POST"])
def api_admin_reopen_request(request_id: int) -> ResponseReturnValue:
auth_gate = _require_request_endpoints_available(resolve_auth_mode)
+47 -5
View File
@@ -18,6 +18,7 @@ from shelfmark.core.request_validation import (
normalize_policy_mode,
validate_request_level_payload,
)
from shelfmark.core.status_messages import client_problem
MAX_REQUEST_NOTE_LENGTH = 1000
MAX_REQUEST_JSON_BLOB_BYTES = 10 * 1024
@@ -204,6 +205,11 @@ def sync_delivery_states_from_queue_status(
user_id: int | None = None,
) -> list[dict[str, Any]]:
"""Persist delivery-state transitions for fulfilled requests based on queue status."""
# Runs on every status poll. With no task in the queue nothing can change, so skip
# reading (and parsing) the whole fulfilled-request history.
if not any(queue_status.get(status_key) for status_key in QueueStatus):
return []
fulfilled_rows = user_db.list_requests(user_id=user_id, status=RequestStatus.FULFILLED)
if not fulfilled_rows:
return []
@@ -266,9 +272,12 @@ def sync_delivery_states_from_queue_status(
if isinstance(raw_status_message, str) and raw_status_message.strip()
else "Download failed"
)
# Reopening clears the release, so note on the request that this one failed:
# the next search skips it.
reopened = user_db.reopen_failed_request(
request_id,
failure_reason=failure_reason,
remember_failed_release=not client_problem(failure_reason),
)
if reopened is not None:
updated.append(reopened)
@@ -502,6 +511,17 @@ def reject_request(
normalized_admin_note = _normalize_admin_note(admin_note)
extra: dict[str, Any] = {}
book_data = request_row.get("book_data")
if isinstance(book_data, dict):
from shelfmark.core.existence_check import without_state
# The audiobook existence check's pass count ends with the review, so a request
# that is reopened later counts its passes afresh.
cleared = without_state(book_data)
if cleared is not None:
extra["book_data"] = cleared
try:
return user_db.update_request(
request_id,
@@ -510,6 +530,7 @@ def reject_request(
admin_note=normalized_admin_note,
reviewed_by=admin_user_id,
reviewed_at=_now_timestamp(),
**extra,
)
except TypeError as exc:
raise RequestServiceError(str(exc), status_code=400) from exc
@@ -524,15 +545,18 @@ def reopen_request(
admin_user_id: int,
admin_note: object = None,
) -> dict[str, Any]:
"""Move a rejected request back to pending as admin, so the rejection can be undone."""
"""Move a rejected request, or a fulfilled one whose delivery failed or was cancelled, back to pending."""
request_row = ensure_request_access(
user_db,
request_id=request_id,
actor_user_id=admin_user_id,
is_admin=True,
)
if request_row["status"] != RequestStatus.REJECTED:
msg = "Only a rejected request can be reopened"
delivery_failed = request_row["status"] == RequestStatus.FULFILLED and request_row.get(
"delivery_state"
) in {QueueStatus.ERROR, QueueStatus.CANCELLED}
if request_row["status"] != RequestStatus.REJECTED and not delivery_failed:
msg = "Only a rejected request, or one whose download failed or was cancelled, can be reopened"
raise RequestServiceError(msg, status_code=409, code="stale_transition")
normalized_admin_note = _normalize_admin_note(admin_note)
@@ -554,7 +578,19 @@ def reopen_request(
msg = "Duplicate pending request exists for this title/author/content_type"
raise RequestServiceError(msg, status_code=409, code="duplicate_pending_request")
reopened = user_db.reopen_rejected_request(request_id, admin_note=normalized_admin_note)
if delivery_failed:
# The chosen release is cleared, so the next pass searches again; the failure
# reason says who asked for it. The release that failed is remembered so the next
# automatic search skips it; an admin can still pick it by hand. The note lands in
# the same write, so a reopened request is never left without it.
reopened = user_db.reopen_failed_request(
request_id,
failure_reason=normalized_admin_note or "Reopened by an admin after a failed download",
remember_failed_release=True,
admin_note=normalized_admin_note,
)
else:
reopened = user_db.reopen_rejected_request(request_id, admin_note=normalized_admin_note)
if reopened is None:
msg = "Request state changed before update"
raise RequestServiceError(msg, status_code=409, code="stale_transition")
@@ -683,8 +719,14 @@ def reopen_failed_request(
request_id: int,
failure_reason: str | None = None,
) -> dict[str, Any] | None:
"""Reopen a failed fulfilled request so admins can re-approve with a new release."""
"""Reopen a failed fulfilled request so admins can re-approve with a new release.
The release that failed is noted on the request, so the next search does not pick it again,
unless the failure was the client being unreachable or refusing the login, which says nothing
about the release.
"""
return user_db.reopen_failed_request(
request_id,
failure_reason=failure_reason,
remember_failed_release=not client_problem(failure_reason),
)
+28
View File
@@ -39,6 +39,12 @@ logger = setup_logger(__name__)
DEFAULT_SEARCH_BUDGET_SECONDS = 300.0
_MIN_SEARCH_BUDGET_SECONDS = 30.0
# What each unit still waiting its turn keeps back from the one running now, and the
# least a unit is given while the parent has that much left. Thirty seconds covers a
# plain search; it is not enough for a bypass solve, which is why the unit running
# keeps everything else.
DEFAULT_UNIT_RESERVE_SECONDS = 30.0
_MAX_SEARCH_BUDGET_SECONDS = 1800.0
# Raised to the caller when the budget runs out, so the API can say so plainly.
@@ -110,6 +116,28 @@ def search_deadline(budget: float | None = None) -> Iterator[SearchDeadline]:
_current.reset(token)
@contextmanager
def unit_deadline(
waiting: int = 0, *, reserve: float = DEFAULT_UNIT_RESERVE_SECONDS
) -> Iterator[SearchDeadline | None]:
"""Budget for one unit of work inside a search, keeping time back for those after it.
The unit gets the parent's remaining time minus `reserve` for each of the `waiting`
units still to run, but never less than `reserve` and never more than the parent has
left. A lone unit gets everything, and time a fast unit does not use rolls on to the
next. A stuck unit stops at its own limit, so it cannot spend the seconds the next
one needs. Outside a search there is no budget to divide, so nothing is applied.
"""
parent = _current.get()
if parent is None:
yield None
return
remaining = parent.remaining
budget = min(remaining, max(reserve, remaining - reserve * max(waiting, 0)))
with search_deadline(budget) as child:
yield child
def current() -> SearchDeadline | None:
"""The budget in force, or None outside a search."""
return _current.get()
+15
View File
@@ -110,6 +110,19 @@ def _serialize_self_user(user: Mapping[str, Any], auth_mode: str) -> dict[str, A
return payload
def _admin_default_routes_set() -> bool:
"""Whether the admin has set a default notification route with a URL.
The routes themselves stay hidden from a self-service user because their URLs can carry
tokens or credentials. The fact that a default exists is what the settings page needs to say
that it applies to them.
"""
routes = app_config.get("USER_NOTIFICATION_ROUTES", [])
if not isinstance(routes, list):
return False
return any(isinstance(row, dict) and str(row.get("url") or "").strip() for row in routes)
def _build_optional_user_preferences(
user_db: UserDB,
*,
@@ -311,6 +324,8 @@ def register_self_user_routes(
)
if error_response:
return error_response
if notification_preferences is not None:
notification_preferences["adminDefaultRoutesSet"] = _admin_default_routes_set()
user_overridable_keys = sorted(
set(delivery_preferences.get("keys", []) if delivery_preferences else [])
+34
View File
@@ -2,6 +2,8 @@
import json
import os
from contextlib import contextmanager
from contextvars import ContextVar
from dataclasses import dataclass, field
from pathlib import Path
from threading import Lock
@@ -419,8 +421,40 @@ def _ensure_config_dir(tab_name: str) -> None:
config_path.parent.mkdir(parents=True, exist_ok=True)
# Set while the whole settings cache is being rebuilt. Every field of a tab used to
# re-read and re-parse that tab's file, so a rebuild parsed each file once per field.
_config_file_memo: ContextVar[dict[str, dict[str, Any]] | None] = ContextVar(
"config_file_memo", default=None
)
@contextmanager
def config_files_read_once() -> Iterator[None]:
"""Read each tab's config file at most once inside the block.
Only for a read-only pass such as rebuilding the settings cache; a save inside the
block would not be seen by reads after it.
"""
token = _config_file_memo.set({})
try:
yield
finally:
_config_file_memo.reset(token)
def load_config_file(tab_name: str) -> dict[str, Any]:
"""Load a settings tab config file, returning an empty dict on failure."""
memo = _config_file_memo.get()
if memo is None:
return _read_config_file(tab_name)
config_path = _get_config_file_path(tab_name)
key = str(config_path) # several tabs share settings.json
if key not in memo:
memo[key] = _read_config_file(tab_name)
return memo[key]
def _read_config_file(tab_name: str) -> dict[str, Any]:
config_path = _get_config_file_path(tab_name)
if not config_path.exists():
+177
View File
@@ -0,0 +1,177 @@
"""Settings: ``/api/settings`` and its per-tab read, update and action routes."""
from __future__ import annotations
import sqlite3
from importlib import import_module
from typing import TYPE_CHECKING
from flask import jsonify, request
from shelfmark.core.logger import setup_logger
if TYPE_CHECKING:
from collections.abc import Callable
from flask import Flask
from werkzeug.wrappers import Response
logger = setup_logger(__name__)
_SETTINGS_ERRORS = (ImportError, OSError, RuntimeError, TypeError, ValueError, sqlite3.Error)
def register_settings_routes(
app: Flask,
login_required: Callable[..., Callable[..., Response | tuple[Response, int]]],
) -> None:
"""Register the settings routes.
``login_required`` is the app's decorator. It is also what limits these paths to an
admin session when authentication is on, so it has to be the same one the other routes use.
"""
@app.route("/api/settings", methods=["GET"])
@login_required
def api_settings_get_all() -> Response | tuple[Response, int]:
"""Get all settings tabs with their fields and current values.
Returns:
flask.Response: JSON with all settings tabs.
"""
try:
import_module("shelfmark.config.notifications_settings")
import_module("shelfmark.config.security")
# Ensure settings are registered by importing settings modules
# This triggers the @register_settings decorators
import_module("shelfmark.config.settings")
import_module("shelfmark.config.users_settings")
from shelfmark.core.settings_registry import serialize_all_settings
data = serialize_all_settings(include_values=True)
return jsonify(data)
except _SETTINGS_ERRORS as e:
logger.error_trace(f"Settings get error: {e}")
return jsonify({"error": str(e)}), 500
@app.route("/api/settings/<tab_name>", methods=["GET"])
@login_required
def api_settings_get_tab(tab_name: str) -> Response | tuple[Response, int]:
"""Get settings for a specific tab.
Path Parameters:
tab_name (str): Settings tab name (e.g., "general", "hardcover")
Returns:
flask.Response: JSON with tab settings and values.
"""
try:
import_module("shelfmark.config.notifications_settings")
import_module("shelfmark.config.security")
# Ensure settings are registered
import_module("shelfmark.config.settings")
import_module("shelfmark.config.users_settings")
from shelfmark.core.settings_registry import (
get_settings_tab,
serialize_tab,
)
tab = get_settings_tab(tab_name)
if not tab:
return jsonify({"error": f"Unknown settings tab: {tab_name}"}), 404
return jsonify(serialize_tab(tab, include_values=True))
except _SETTINGS_ERRORS as e:
logger.error_trace(f"Settings get tab error: {e}")
return jsonify({"error": str(e)}), 500
@app.route("/api/settings/<tab_name>", methods=["PUT"])
@login_required
def api_settings_update_tab(tab_name: str) -> Response | tuple[Response, int]:
"""Update settings for a specific tab.
Path Parameters:
tab_name (str): Settings tab name
Request Body:
JSON object with setting keys and values to update.
Returns:
flask.Response: JSON with update result.
"""
try:
import_module("shelfmark.config.notifications_settings")
import_module("shelfmark.config.security")
# Ensure settings are registered
import_module("shelfmark.config.settings")
import_module("shelfmark.config.users_settings")
from shelfmark.core.settings_registry import (
get_settings_tab,
update_settings,
)
tab = get_settings_tab(tab_name)
if not tab:
return jsonify({"error": f"Unknown settings tab: {tab_name}"}), 404
values = request.get_json(silent=True)
if values is None or not isinstance(values, dict):
return jsonify({"error": "Request body must be a JSON object"}), 400
# If no values to update, return success with empty updated list
if not values:
return jsonify({"success": True, "message": "No changes to save", "updated": []})
result = update_settings(tab_name, values)
if result["success"]:
return jsonify(result)
return jsonify(result), 400
except _SETTINGS_ERRORS as e:
logger.error_trace(f"Settings update error: {e}")
return jsonify({"error": str(e)}), 500
@app.route("/api/settings/<tab_name>/action/<action_key>", methods=["POST"])
@login_required
def api_settings_execute_action(
tab_name: str, action_key: str
) -> Response | tuple[Response, int]:
"""Execute a settings action (e.g., test connection).
Path Parameters:
tab_name (str): Settings tab name
action_key (str): Action key to execute
Request Body (optional):
JSON object with current form values (unsaved)
Returns:
flask.Response: JSON with action result.
"""
try:
import_module("shelfmark.config.notifications_settings")
import_module("shelfmark.config.security")
# Ensure settings are registered
import_module("shelfmark.config.settings")
import_module("shelfmark.config.users_settings")
from shelfmark.core.settings_registry import execute_action
# Get current form values if provided (for testing with unsaved values)
current_values = request.get_json(silent=True) or {}
result = execute_action(tab_name, action_key, current_values)
if result["success"]:
return jsonify(result)
return jsonify(result), 400
except _SETTINGS_ERRORS as e:
logger.error_trace(f"Settings action error: {e}")
return jsonify({"error": str(e)}), 500
+106
View File
@@ -0,0 +1,106 @@
"""Socket.IO handlers: connect, disconnect and a manual status request."""
from __future__ import annotations
import sqlite3
from typing import TYPE_CHECKING
from flask import request
from flask_socketio import emit
from shelfmark.core.logger import setup_logger
from shelfmark.core.viewer_redaction import redact_status
from shelfmark.download import orchestrator as backend
if TYPE_CHECKING:
from collections.abc import Callable
from flask_socketio import SocketIO
from shelfmark.api.websocket import WebSocketManager
logger = setup_logger(__name__)
_OPERATIONAL_ERRORS = (OSError, RuntimeError, TypeError, ValueError, sqlite3.Error)
def _get_request_sid() -> str | None:
"""Return the Socket.IO session id for the active request when available."""
sid = getattr(request, "sid", None)
return sid if isinstance(sid, str) and sid else None
def register_socket_handlers(
socketio: SocketIO,
*,
ws_manager: WebSocketManager,
resolve_status_scope: Callable[..., tuple[bool, int | None, bool]],
) -> None:
"""Register the connect, disconnect and request_status handlers.
``resolve_status_scope`` stays in ``main.py`` because the HTTP routes use it too.
"""
@socketio.on("connect")
def handle_connect() -> None:
"""Handle client connection."""
logger.info("WebSocket client connected")
# Track the connection (triggers warmup callbacks on first connect)
ws_manager.client_connected()
# Join appropriate room based on authenticated user session
is_admin, db_user_id, can_access_status = resolve_status_scope()
sid = _get_request_sid()
if sid is None:
logger.warning("Socket.IO connect event missing sid")
return
ws_manager.join_user_room(sid, is_admin=is_admin, db_user_id=db_user_id)
# Send initial status to the newly connected client (filtered)
try:
if not can_access_status:
emit("status_update", {})
return
user_id = None if is_admin else db_user_id
status = backend.queue_status(user_id=user_id)
emit("status_update", status if is_admin else redact_status(status))
except _OPERATIONAL_ERRORS:
logger.exception("Error sending initial status")
@socketio.on("disconnect")
def handle_disconnect() -> None:
"""Handle client disconnection."""
logger.info("WebSocket client disconnected")
# Leave room
sid = _get_request_sid()
if sid is not None:
ws_manager.leave_user_room(sid)
# Track the disconnection
ws_manager.client_disconnected()
@socketio.on("request_status")
def handle_status_request() -> None:
"""Handle manual status request from client."""
try:
is_admin, db_user_id, can_access_status = resolve_status_scope()
sid = _get_request_sid()
if sid is None:
logger.warning("Socket.IO request_status event missing sid")
emit("status_update", {})
return
ws_manager.sync_user_room(sid, is_admin=is_admin, db_user_id=db_user_id)
if not can_access_status:
emit("status_update", {})
return
user_id = None if is_admin else db_user_id
status = backend.queue_status(user_id=user_id)
emit("status_update", status if is_admin else redact_status(status))
except _OPERATIONAL_ERRORS:
logger.exception("Error handling status request")
emit("error", {"message": "Failed to get status"})
+55
View File
@@ -0,0 +1,55 @@
"""Dashboard counters: ``GET /api/stats``."""
from __future__ import annotations
import sqlite3
from typing import TYPE_CHECKING
from flask import g, jsonify, session
from shelfmark.core import stats
from shelfmark.core.logger import setup_logger
if TYPE_CHECKING:
from collections.abc import Callable
from flask import Flask
from werkzeug.wrappers import Response
from shelfmark.core.user_db import UserDB
logger = setup_logger(__name__)
STATS_PATH = "/api/stats"
def register_stats_routes(
app: Flask,
*,
get_user_db: Callable[[], UserDB | None],
resolve_auth_mode: Callable[[], str],
) -> None:
"""Register ``/api/stats``.
Both arguments are called per request so a runtime patch of either still takes effect.
"""
@app.route(STATS_PATH, methods=["GET"])
def api_stats() -> Response | tuple[Response, int]:
"""Counters for dashboards. Reachable with the read-only API key, an admin key or an admin session."""
scope = g.get("api_key_scope")
if resolve_auth_mode() != "none" and scope is None:
if "user_id" not in session:
return jsonify({"error": "Unauthorized"}), 401
if not session.get("is_admin", False):
return jsonify({"error": "Admin access required"}), 403
user_db = get_user_db()
if user_db is None:
return jsonify({"error": "User database unavailable"}), 503
try:
payload = stats.collect(user_db.db_path)
except sqlite3.Error:
logger.exception("stats: could not read the database")
return jsonify({"error": "Internal Server Error"}), 500
return jsonify(payload)
+134
View File
@@ -0,0 +1,134 @@
"""Plain wording for a download that failed or stopped: what happened and what comes next.
The saved message is a log line ("Peer transfer failed: Errored"). Auto-download reads it to
tell a failed release from an interrupted one, so it is never rewritten; only what the activity
list shows is.
"""
from __future__ import annotations
from typing import Any
from shelfmark.core.config import config as app_config
from shelfmark.core.request_helpers import coerce_int
FAILURE_RETRY_MINUTES_SETTING = "AUTO_DOWNLOAD_FAILURE_RETRY_MINUTES"
_SHOWN_FOR = frozenset({"error", "cancelled"})
# What a download records when its client could not be reached or refused Shelfmark's login.
# Bare "forbidden" or "unauthorized" is not on the list: a dead .torrent link fails that way too,
# and that one is the release's fault. A client's login failure is reworded to say so.
# Neither says anything about the release: the same one is fine once the client is back or its
# settings are fixed, and it may already be downloading there.
_CLIENT_PROBLEM_MARKERS = (
"failed to connect",
"cannot connect",
"connection refused",
"connection aborted",
"authentication failed",
)
def client_problem(message: object) -> bool:
"""Whether a failure message blames the download client rather than the release."""
text = str(message or "").lower()
return any(marker in text for marker in _CLIENT_PROBLEM_MARKERS)
def _next_step(*, request_linked: bool, retry_minutes: int) -> str:
if request_linked and retry_minutes > 0:
return f" Another copy will be tried in about {retry_minutes} minutes."
if request_linked:
return " Use Retry to try again, or reopen the request to try another copy."
return " Use Retry to try again, or pick another copy."
def explain(
message: str | None,
*,
request_linked: bool,
retry_minutes: int | None = None,
) -> str | None:
"""The message in plain words with the next step, or it unchanged when nothing is known."""
if not isinstance(message, str) or not message.strip():
return message
text = message.strip()
lowered = text.lower()
minutes = (
retry_minutes
if retry_minutes is not None
else coerce_int(app_config.get(FAILURE_RETRY_MINUTES_SETTING, 0), 0)
)
nxt = _next_step(request_linked=request_linked, retry_minutes=minutes)
if lowered.startswith("superseded"):
return "This book was already delivered by another download."
if lowered.startswith("interrupted"):
return "Interrupted by a restart. Use Retry to run it again."
if lowered.startswith("peer transfer failed"):
detail = text.split(":", 1)[1].strip() if ":" in text else ""
suffix = f" ({detail})" if detail else ""
return f"The Soulseek peer dropped the transfer{suffix}." + nxt
if lowered.startswith("failed to add to slskd"):
if "direct or indirect" in lowered:
return (
"Could not reach the Soulseek peer, directly or through the Soulseek server "
"(tried twice)." + nxt
)
if '("' in text and "timed out" not in lowered:
# slskd gave a reason, and it is not a timeout.
return "slskd would not queue the download (tried twice)." + nxt
# A timeout, or the bare 500 an older attempt saved before slskd's reason was kept; every
# one of those that slskd's own log explained was a peer that did not answer in time.
return "The Soulseek peer did not answer slskd's queue request (tried twice)." + nxt
if lowered.startswith("download failed:"):
detail = text.split(":", 1)[1].strip()
return f"The download hit an internal error ({detail})." + nxt
if lowered.startswith("retrying after"):
return "Retrying automatically after a failed download."
if lowered.startswith("download stalled"):
return "Nothing was downloading, so it was stopped." + nxt
if lowered.startswith("no seeders"):
return text.rstrip(".") + "; the torrent was removed." + nxt
if lowered.startswith("all download sources failed"):
return "None of the download sources could provide this book." + nxt
return text
def explain_request_rows(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""The request rows with ``last_failure_reason`` in plain words (copies; the stored text stays)."""
minutes = coerce_int(app_config.get(FAILURE_RETRY_MINUTES_SETTING, 0), 0)
explained: list[dict[str, Any]] = []
for row in rows:
reason = row.get("last_failure_reason")
if not isinstance(reason, str) or not reason.strip():
explained.append(row)
continue
# A request that was reopened after a restart is searched again by the next pass.
text = (
"Interrupted by a restart; it will be tried again."
if reason.strip().lower().startswith("interrupted by a restart")
else explain(reason, request_linked=True, retry_minutes=minutes)
)
explained.append({**row, "last_failure_reason": text})
return explained
def explain_buckets(status: dict[str, dict[str, Any]]) -> None:
"""Rewrite the messages of the error and cancelled entries of a status dict, in place."""
minutes = coerce_int(app_config.get(FAILURE_RETRY_MINUTES_SETTING, 0), 0)
for bucket in _SHOWN_FOR:
entries = status.get(bucket, {})
for task_id, payload in list(entries.items()):
if not isinstance(payload, dict):
continue
# A copy, so a payload the queue hands out again is never explained twice.
entries[task_id] = {
**payload,
"status_message": explain(
payload.get("status_message"),
request_linked=bool(payload.get("request_id")),
retry_minutes=minutes,
),
}
+333
View File
@@ -0,0 +1,333 @@
"""System routes: the config and health endpoints, the debug tools, the frontend files and the SPA fallback."""
from __future__ import annotations
import os
import sqlite3
import subprocess
from functools import wraps
from pathlib import Path
from typing import TYPE_CHECKING, Any, NamedTuple, NoReturn
from flask import jsonify, send_file, send_from_directory, session
from werkzeug.wrappers import Response
from shelfmark.config.env import BUILD_VERSION, RELEASE_VERSION
from shelfmark.config.settings import _SUPPORTED_BOOK_LANGUAGE
from shelfmark.core import search_deadline
from shelfmark.core.config import config as app_config
from shelfmark.core.logger import setup_logger
from shelfmark.core.request_helpers import normalize_optional_text
from shelfmark.download import orchestrator as backend
if TYPE_CHECKING:
from collections.abc import Callable
from flask import Flask
logger = setup_logger(__name__)
_OPERATIONAL_ERRORS = (OSError, RuntimeError, TypeError, ValueError, sqlite3.Error)
_IMPORT_OPERATIONAL_ERRORS = (ImportError, *_OPERATIONAL_ERRORS)
_BASE_TAG = '<base href="/" data-shelfmark-base />'
def _raise_runtime_error(message: str) -> NoReturn:
"""Raise RuntimeError with the message, for raising inside a try block (ruff TRY301)."""
raise RuntimeError(message)
class DebugHooks(NamedTuple):
"""What the DEBUG-only routes need from the app: stop the GUI, then run the debug script.
``run_script`` waits a second for the GUI to stop before it starts the script.
"""
stop_gui: Callable[[], None]
run_script: Callable[[], subprocess.CompletedProcess[str]]
def register_system_routes(
app: Flask,
login_required: Callable[..., Callable[..., Response | tuple[Response, int]]],
*,
resolve_auth_mode: Callable[[], str],
resolve_db_user_id: Callable[[], int | None],
get_base_path: Callable[[], str],
get_frontend_dist: Callable[[], Path],
debug_hooks: DebugHooks | None,
) -> None:
"""Register the system routes, the frontend files and the catch-all for client-side routes.
``login_required`` is the app's decorator. ``resolve_auth_mode``, ``resolve_db_user_id``,
``get_base_path`` and ``get_frontend_dist`` are called per request so a runtime patch of any of them still takes
effect. ``/api/debug`` and ``/api/restart`` are registered only when ``debug_hooks`` is given.
"""
def admin_required(
f: Callable[..., Response | tuple[Response, int]],
) -> Callable[..., Response | tuple[Response, int]]:
"""Require an authenticated admin session for a Flask route.
Builds on `login_required`, so auth mode "none" (no users, the single
operator owns the instance) still has access.
"""
@login_required
@wraps(f)
def decorated_function(*args: object, **kwargs: object) -> Response | tuple[Response, int]:
"""Return 403 to a signed-in user who is not an admin, otherwise run the route."""
if resolve_auth_mode() != "none" and not session.get("is_admin", False):
return jsonify({"error": "Admin access required"}), 403
return f(*args, **kwargs)
return decorated_function
def _base_href() -> str:
"""The href for the page's base tag: the configured base path with a trailing slash."""
base_path = get_base_path()
if not base_path:
return "/"
return f"{base_path}/"
def _serve_index_html() -> Response:
"""Serve index.html with an adjusted base tag for subpath deployments."""
frontend_dist = get_frontend_dist()
index_path = frontend_dist / "index.html"
try:
with index_path.open(encoding="utf-8") as handle:
html = handle.read()
except OSError:
return send_from_directory(frontend_dist, "index.html")
if get_base_path() and _BASE_TAG in html:
html = html.replace(_BASE_TAG, f'<base href="{_base_href()}" data-shelfmark-base />', 1)
return Response(html, mimetype="text/html")
# Serve frontend static files
@app.route("/assets/<path:filename>")
def serve_frontend_assets(filename: str) -> Response:
"""Serve static assets from the built frontend."""
return send_from_directory(get_frontend_dist() / "assets", filename)
@app.route("/")
def index() -> Response:
"""Serve the React frontend application.
Authentication is handled by the React app itself.
"""
return _serve_index_html()
@app.route("/theme-init.js")
def theme_init_js() -> Response:
"""Serve the blocking theme-init script."""
return send_from_directory(
get_frontend_dist(), "theme-init.js", mimetype="application/javascript"
)
@app.route("/logo.png")
def logo() -> Response:
"""Serve logo from built frontend assets."""
return send_from_directory(get_frontend_dist(), "logo.png", mimetype="image/png")
@app.route("/favicon.ico")
@app.route("/favico<path:_>")
def favicon(_: Any = None) -> Response:
"""Serve favicon from built frontend assets."""
return send_from_directory(
get_frontend_dist(), "favicon.ico", mimetype="image/vnd.microsoft.icon"
)
@app.route("/api/config", methods=["GET"])
@login_required
def api_config() -> Response | tuple[Response, int]:
"""Get application configuration for frontend.
Uses the dynamic config singleton to ensure settings changes
are reflected without requiring a container restart.
"""
try:
from shelfmark.config.env import _is_config_dir_writable
from shelfmark.core.onboarding import is_onboarding_complete as _get_onboarding_complete
from shelfmark.metadata_providers import (
get_provider_default_sort,
get_provider_search_fields,
get_provider_sort_options,
)
db_user_id = resolve_db_user_id()
search_mode = app_config.get("SEARCH_MODE", "universal", user_id=db_user_id)
default_release_source = app_config.get(
"DEFAULT_RELEASE_SOURCE",
"",
user_id=db_user_id,
)
default_release_source_audiobook = app_config.get(
"DEFAULT_RELEASE_SOURCE_AUDIOBOOK",
"",
user_id=db_user_id,
)
configured_metadata_provider = normalize_optional_text(
app_config.get(
"METADATA_PROVIDER",
"",
user_id=db_user_id,
)
)
_configured_metadata_provider_audiobook = normalize_optional_text(
app_config.get(
"METADATA_PROVIDER_AUDIOBOOK",
"",
user_id=db_user_id,
)
)
metadata_ui_provider = (
configured_metadata_provider or _configured_metadata_provider_audiobook
)
config = {
"calibre_web_url": app_config.get("CALIBRE_WEB_URL", ""),
"audiobook_library_url": app_config.get("AUDIOBOOK_LIBRARY_URL", ""),
"search_page_title": app_config.get("SEARCH_PAGE_TITLE", "Shelfmark"),
"debug": app_config.get("DEBUG", False),
"build_version": BUILD_VERSION,
"release_version": RELEASE_VERSION,
"book_languages": _SUPPORTED_BOOK_LANGUAGE,
"default_language": app_config.get("BOOK_LANGUAGE", ["en"], user_id=db_user_id),
"default_content_type": app_config.get(
"DEFAULT_CONTENT_TYPE", "ebook", user_id=db_user_id
),
"supported_formats": app_config.SUPPORTED_FORMATS,
"supported_audiobook_formats": app_config.SUPPORTED_AUDIOBOOK_FORMATS,
"search_mode": search_mode,
"metadata_sort_options": get_provider_sort_options(metadata_ui_provider),
"metadata_search_fields": get_provider_search_fields(metadata_ui_provider),
"default_release_source": default_release_source,
"default_release_source_audiobook": default_release_source_audiobook,
"show_release_source_links": app_config.get("SHOW_RELEASE_SOURCE_LINKS", True),
"show_combined_selector": app_config.get(
"SHOW_COMBINED_SELECTOR", True, user_id=db_user_id
),
"force_combined_search": app_config.get(
"FORCE_COMBINED_SEARCH", False, user_id=db_user_id
),
"books_output_mode": app_config.get("BOOKS_OUTPUT_MODE", "folder"),
"auto_open_downloads_sidebar": app_config.get("AUTO_OPEN_DOWNLOADS_SIDEBAR", True),
"hardcover_auto_remove_on_download": app_config.get(
"HARDCOVER_AUTO_REMOVE_ON_DOWNLOAD", True
),
"download_to_browser_content_types": app_config.get(
"DOWNLOAD_TO_BROWSER_CONTENT_TYPES",
[],
user_id=db_user_id,
),
# The client must not give up before this budget does. `/api/releases`
# answers a spent budget with a message naming the real cause (a protection
# challenge nobody could solve); a browser that aborted first replaces it
# with a generic network/proxy error and RELEASE_SEARCH_TIMEOUT becomes a
# setting the user can raise with no visible effect. See issue #1285.
"release_search_timeout": search_deadline.budget_seconds(),
"settings_enabled": _is_config_dir_writable(),
"onboarding_complete": _get_onboarding_complete(),
# Default sort orders
"default_sort": app_config.get(
"AA_DEFAULT_SORT", ""
), # For direct mode (Anna's Archive) — empty means use local downloads sort
"metadata_default_sort": get_provider_default_sort(
metadata_ui_provider
), # For universal mode
}
return jsonify(config)
except _IMPORT_OPERATIONAL_ERRORS as e:
logger.error_trace(f"Config error: {e}")
return jsonify({"error": "Could not load the configuration"}), 500
@app.route("/api/health", methods=["GET"])
def api_health() -> Response | tuple[Response, int]:
"""Health check endpoint for container orchestration.
No authentication required.
Returns:
flask.Response: JSON with status "ok" and optional degraded features. Answers 503 with
status "error" when the download coordinator is not running, since nothing is
downloaded without it.
"""
response: dict[str, object] = {"status": "ok"}
# Report degraded features
if not backend.WEBSOCKET_AVAILABLE:
response["degraded"] = {
"websocket": "WebSocket unavailable - real-time updates disabled"
}
coordinator_up = backend.coordinator_running()
response["coordinator"] = "running" if coordinator_up else "stopped"
if not coordinator_up:
response["status"] = "error"
return jsonify(response), 503
return jsonify(response)
if debug_hooks is not None:
@app.route("/api/debug", methods=["GET"])
@admin_required
def debug() -> Response | tuple[Response, int]:
"""Run `/app/genDebug.sh`, generate a debug zip, and return it.
The file is written to `/tmp/shelfmark-debug.zip` before being returned.
"""
try:
logger.info("Debug endpoint called, stopping GUI and generating debug info...")
debug_hooks.stop_gui()
result = debug_hooks.run_script()
if result.returncode != 0:
_raise_runtime_error(f"Debug script failed: {result.stderr}")
logger.info("Debug script executed: %s", result.stdout)
debug_file_path = result.stdout.strip().split("\n")[-1]
if not Path(debug_file_path).exists():
logger.error("Debug zip file not found at: %s", debug_file_path)
return jsonify({"error": "Failed to generate debug information"}), 500
logger.info("Sending debug file: %s", debug_file_path)
return send_file(
debug_file_path,
mimetype="application/zip",
download_name=Path(debug_file_path).name,
as_attachment=True,
)
except subprocess.CalledProcessError as e:
logger.error_trace(
f"Debug script error: {e}, stdout: {e.stdout}, stderr: {e.stderr}"
)
return jsonify({"error": f"Debug script failed: {e.stderr}"}), 500
except _OPERATIONAL_ERRORS as e:
logger.error_trace(f"Debug endpoint error: {e}")
return jsonify({"error": "Could not create the debug file"}), 500
@app.route("/api/restart", methods=["GET"])
@admin_required
def restart() -> Response | tuple[Response, int]:
"""Restart the application."""
os._exit(0)
# Catch-all route for React Router. Werkzeug tries static rules before dynamic ones, so it
# does not have to be registered last.
# This handles client-side routing by serving index.html for any unmatched routes
@app.route("/<path:path>")
def catch_all(path: str) -> Response | tuple[Response, int]:
"""Serve the React app for any route not matched by API endpoints.
This allows React Router to handle client-side routing.
Authentication is handled by the React app itself.
"""
# If the request is for an API endpoint or static file, let it 404
if path.startswith(("api/", "assets/")):
return jsonify({"error": "Resource not found"}), 404
# Otherwise serve the React app
return _serve_index_html()
+243 -13
View File
@@ -7,6 +7,11 @@ library matches titles, authors and ISBNs the same way.
from __future__ import annotations
import re
import unicodedata
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from collections.abc import Collection
DEFAULT_TITLE_MATCH_THRESHOLD = 0.85
@@ -30,25 +35,193 @@ STOPWORDS = frozenset(
)
# Letters NFKD leaves whole, folded by hand so "Nesbø" and "Straße" compare as "nesbo" and
# "strasse", which is how a release name or a shelf usually spells them.
_LETTER_FOLD = str.maketrans(
{
"ß": "ss",
"æ": "ae",
"œ": "oe",
"ø": "o",
"ł": "l",
"đ": "d",
"ð": "d",
"þ": "th",
"ı": "i",
}
)
def fold(text: str) -> str:
"""Lowercase text with its diacritics dropped, so "Húrin" and "Hurin" read the same.
Decomposes (NFKD) and discards the combining marks, which also covers a title that
arrives already decomposed (a "u" followed by a combining acute). A metadata
provider and a release name rarely agree on accents; without this, "The Children of
Húrin" split into "hu" and "rin" and scored half a match against its own audiobook.
"""
decomposed = unicodedata.normalize("NFKD", text.lower())
return "".join(ch for ch in decomposed if not unicodedata.combining(ch)).translate(_LETTER_FOLD)
# Small numbers the way titles write them. "Part II", "Part Two" and "Part 2" name the
# same part, and a shelf, a provider and a release rarely agree on which to use.
_ROMAN = [
"i",
"ii",
"iii",
"iv",
"v",
"vi",
"vii",
"viii",
"ix",
"x",
"xi",
"xii",
"xiii",
"xiv",
"xv",
"xvi",
"xvii",
"xviii",
"xix",
"xx",
]
_NUMBER_WORDS = [
"one",
"two",
"three",
"four",
"five",
"six",
"seven",
"eight",
"nine",
"ten",
"eleven",
"twelve",
"thirteen",
"fourteen",
"fifteen",
"sixteen",
"seventeen",
"eighteen",
"nineteen",
"twenty",
]
NUMERALS: dict[str, str] = {
**{roman: str(n) for n, roman in enumerate(_ROMAN, 1)},
**{word: str(n) for n, word in enumerate(_NUMBER_WORDS, 1)},
}
def canonical_numeral(tok: str) -> str:
"""The digit form of a small number written as a word, a roman numeral or with a
leading zero ("two", "ii", "02" all give "2"); any other token unchanged."""
if tok.isdigit():
return str(int(tok)) if len(tok) <= 2 else tok
return NUMERALS.get(tok, tok)
def tokens(text: str | None) -> list[str]:
"""Lowercase alphanumeric tokens from arbitrary text."""
"""Lowercase alphanumeric tokens from arbitrary text, diacritics folded away."""
if not text:
return []
return [tok for tok in re.split(r"[^a-z0-9]+", text.lower()) if tok]
return [tok for tok in re.split(r"[^a-z0-9]+", fold(text)) if tok]
def numeral_tokens(text: str | None) -> list[str]:
"""``tokens`` with small numbers in their digit form, for comparing titles.
Kept apart from ``tokens`` because a shelf title's "Book Three" is a word that
names another work to the library matcher, where "Book 3" is a volume number it
deliberately lets pass (see ``extra_work_tokens``).
"""
return [canonical_numeral(tok) for tok in tokens(text)]
def significant_tokens(text: str | None) -> list[str]:
"""Tokens with stopwords and 1-char noise removed."""
return [tok for tok in tokens(text) if len(tok) >= 2 and tok not in STOPWORDS]
"""Title words for comparison: stopwords and single letters removed, small numbers
in their digit form.
Numbers always count, however short: "Part 2" is not "Part 1".
"""
return [
tok
for tok in numeral_tokens(text)
if tok not in STOPWORDS and (len(tok) >= 2 or tok.isdigit())
]
def author_surname(author: str | None) -> str | None:
"""Return the most distinctive author token (the surname), or None."""
"""Return the most distinctive author token (the surname), or None.
A generational suffix is skipped: "Henry Louis Gates III" is Gates.
"""
value = author or ""
if "," in value: # "Last, First" -> keep the "Last" portion
value = value.split(",")[0]
toks = significant_tokens(value)
return toks[-1] if toks else None
toks = [tok for tok in tokens(value) if len(tok) >= 2 and tok not in STOPWORDS]
words = [tok for tok in toks if not canonical_numeral(tok).isdigit()]
return (words or toks)[-1] if toks else None
# Number words and roman numerals of two letters or more, as whole words. The single
# letters (I, V, X) are left out here: in a query they are words far more often than
# numbers ("What I Loved", "Malcolm X").
_NUMERAL_WORD = re.compile(
r"\b(" + "|".join(sorted((k for k in NUMERALS if len(k) > 1), key=len, reverse=True)) + r")\b",
re.IGNORECASE,
)
def normalize_numerals(text: str) -> str:
"""Text with its small numbers written as digits: "Part II" and "Part Two" become
"Part 2". For queries; the token comparison already treats the forms alike."""
return _NUMERAL_WORD.sub(lambda match: NUMERALS[match.group(1).lower()], text or "")
# ", Part II", ": Volume 3", " Vol. IV" at the end of a title. "Book" is deliberately not
# a part word: "The Jungle Book 2" is a title, not a title and a part.
_PART_SUFFIX = re.compile(
r"^(?P<main>.+?)\s*[,:;]?\s+(?:part|pt\.?|volume|vol\.?)\s+(?:\d{1,2}|[ivx]{1,5}|[a-z]+)\.?\s*$",
re.IGNORECASE,
)
def strip_part_suffix(title: str) -> str:
""" "The Book of Lost Tales, Part II" without its ", Part II"; anything else unchanged.
Only when what follows the part word is a small number in some form; "Part" followed
by a name ("Part of Your World") is left alone.
"""
match = _PART_SUFFIX.match(title or "")
if not match:
return title
number = numeral_tokens(title[match.end("main") :])
if not number or not number[-1].isdigit() or not significant_tokens(match.group("main")):
return title
return match.group("main")
# "The Hobbit, or There and Back Again", "Moby-Dick; or, The Whale": a main title, a
# comma or semicolon, "or", and the alternate title. "Dead or Alive" has no separator
# before the "or" and is left alone.
_ALTERNATE_TITLE = re.compile(r"^(?P<main>.+?)\s*[,;]\s*or\b[,:]?\s+(?P<alt>\S.*)$", re.IGNORECASE)
def strip_alternate_title(title: str) -> str:
"""The main title of a "Title, or Alternate Title" pair; anything else unchanged.
Releases and shelves carry the main title alone far more often than the pair, so
the alternate title only costs recall when searching and matching. The full title
stays on the book as display data.
"""
match = _ALTERNATE_TITLE.match(title or "")
if not match or not significant_tokens(match.group("main")):
return title
return match.group("main")
def title_tokens_match(
@@ -57,10 +230,23 @@ def title_tokens_match(
threshold: float = DEFAULT_TITLE_MATCH_THRESHOLD,
) -> bool:
"""True when enough significant title tokens appear in ``haystack_tokens``."""
title_toks = significant_tokens(title)
return significant_tokens_match(significant_tokens(title), haystack_tokens, threshold)
def significant_tokens_match(
title_toks: list[str],
haystack_tokens: Collection[str],
threshold: float = DEFAULT_TITLE_MATCH_THRESHOLD,
) -> bool:
"""``title_tokens_match`` for a title already run through ``significant_tokens``.
Matching one title against many entries should tokenise it once, not per entry.
"""
if not title_toks:
return False
present = sum(1 for tok in title_toks if tok in haystack_tokens)
# Either side may write a number as a word, a roman numeral or digits.
haystack = {canonical_numeral(tok) for tok in haystack_tokens}
present = sum(1 for tok in title_toks if tok in haystack)
return (present / len(title_toks)) >= threshold
@@ -77,6 +263,7 @@ COLLECTION_MARKERS = frozenset(
"box",
"set",
"bundle",
"megapack",
"anthology",
"compendium",
"trilogy",
@@ -112,6 +299,45 @@ EDITION_MARKERS = frozenset(
PACKAGING_MARKERS = COLLECTION_MARKERS | EDITION_MARKERS
def bundle_markers(
title_tokens: set[str] | frozenset[str], search_title: str | None = None
) -> set[str]:
"""Collection words in a title that the searched title does not carry itself.
"The Complete Maus" searched against a shelf holding "The Complete Maus" is the
book itself, not a collection containing it.
"""
return (set(title_tokens) & COLLECTION_MARKERS) - set(tokens(search_title))
# Numbered forms a bundle title takes, which single words cannot express: "Books 1-6",
# "(#1-24)", "1-28 +", "All 5 Books", "+ Short Stories".
_BUNDLE_RANGE = re.compile(
r"\bbooks?\s*\d{1,3}\s*(?:-|\u2013|to|thru|through)\s*\d{1,3}\b"
r"|\(#?\d{1,3}\s*[-\u2013]\s*\d{1,3}\)"
r"|\b\d{1,3}\s*[-\u2013]\s*\d{1,3}\s*\+"
r"|\ball\s+\d+\s+(?:audio)?books\b"
r"|\+\s*short\s+stories\b",
re.IGNORECASE,
)
# "Complete and Unabridged" describes one book's recording, not a set of them.
_UNABRIDGED_PHRASE = re.compile(r"\bcomplete\s*(?:&|and)\s*unabridged\b", re.IGNORECASE)
def is_bundle_title(title: str | None, search_title: str | None = None) -> bool:
"""True when a raw title (a release name, say) bundles several works.
Checks the collection words and the numbered ranges, ignoring any the searched
title carries itself. Use ``bundle_markers`` when only tokens are available.
"""
text = _UNABRIDGED_PHRASE.sub(" ", title or "")
if bundle_markers(set(tokens(text)), search_title):
return True
wanted = (search_title or "").lower()
return any(match.group(0).lower() not in wanted for match in _BUNDLE_RANGE.finditer(text))
def extra_work_tokens(
shelf_title_tokens: set[str],
search_title: str | None,
@@ -120,14 +346,18 @@ def extra_work_tokens(
"""Significant words the shelf title adds that suggest a different work.
Packaging words are ignored, since "Illustrated Edition" and "Books 1-6" describe
the same work differently wrapped, while "Messiah" or "Chapter Two" name another
the same work differently wrapped, while "Messiah" or "Chapter Two" name another.
Only alphabetic words of three or more characters count, so volume numbers and
structural words do not make a shelf entry look like a different book.
structural words do not make a shelf entry look like a different book. A number
word the searched title carries in any form is its own: "Part Two" on the shelf
adds nothing to a search for "Part II".
``context_tokens`` are the entry's series and author words, which shelf titles
often repeat ("Alex Cross 25: Cross Kill") without meaning another book.
"""
search_toks = set(tokens(search_title)) | (context_tokens or set())
search_toks = {
canonical_numeral(tok) for tok in set(tokens(search_title)) | (context_tokens or set())
}
return {
tok
for tok in shelf_title_tokens
@@ -139,7 +369,7 @@ def extra_work_tokens(
and len(tok) >= 3
and tok not in STOPWORDS
and tok not in PACKAGING_MARKERS
and tok not in search_toks
and canonical_numeral(tok) not in search_toks
}
+151 -3
View File
@@ -4,6 +4,7 @@ import json
import os
import sqlite3
import threading
import time
from pathlib import Path
from typing import Any, ClassVar
@@ -21,9 +22,15 @@ from shelfmark.core.request_validation import (
validate_request_level_payload,
validate_status_transition,
)
from shelfmark.core.utils import DB_BUSY_TIMEOUT_SECONDS
logger = setup_logger(__name__)
# How long get_user_cached may answer from memory. Writes through the same UserDB drop
# the cache at once, so this only bounds changes made by another process or instance.
USER_CACHE_TTL_SECONDS = 5.0
_USER_CACHE_MAX_ENTRIES = 256
_CREATE_TABLES_SQL = """
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -114,6 +121,13 @@ WHERE dismissed_at IS NOT NULL AND cleared_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_activity_view_state_hidden
ON activity_view_state (viewer_scope, item_type, item_key)
WHERE dismissed_at IS NOT NULL;
-- Fork: rejected requests an admin hid from the Rejected view. Separate from
-- activity_view_state, which is the activity list's own clear/dismiss state.
CREATE TABLE IF NOT EXISTS rejected_request_hidden (
request_id INTEGER PRIMARY KEY REFERENCES download_requests(id) ON DELETE CASCADE,
hidden_at TEXT NOT NULL
);
"""
@@ -125,6 +139,31 @@ def _require_loaded_user(user: dict[str, Any] | None) -> dict[str, Any]:
return user
def _book_data_with_failed_release(request_row: dict[str, Any]) -> dict[str, Any] | None:
"""The request's book data with the release it was fulfilled with added to ``failed_releases``.
None when there is nothing to add: no readable release, or it is already listed.
"""
book_data = request_row.get("book_data")
release_data = request_row.get("release_data")
if not isinstance(book_data, dict) or not isinstance(release_data, dict):
return None
source = str(release_data.get("source") or "").strip()
source_id = str(release_data.get("source_id") or "").strip()
if not source or not source_id:
return None
tried = book_data.get("failed_releases")
tried = list(tried) if isinstance(tried, list) else []
if any(
isinstance(entry, dict)
and entry.get("source") == source
and entry.get("source_id") == source_id
for entry in tried
):
return None
return {**book_data, "failed_releases": [*tried, {"source": source, "source_id": source_id}]}
def get_users_db_path(config_dir: str | None = None) -> str:
"""Return the configured users database path."""
root = config_dir or os.environ.get("CONFIG_DIR", "/config")
@@ -187,6 +226,9 @@ class UserDB:
"""Initialize the user database wrapper for the given SQLite path."""
self._db_path = db_path
self._lock = threading.Lock()
self._user_cache: dict[tuple[str, object], tuple[float, dict[str, Any] | None]] = {}
self._user_cache_lock = threading.Lock()
self._user_cache_generation = 0
@property
def db_path(self) -> str:
@@ -194,7 +236,7 @@ class UserDB:
return self._db_path
def _connect(self) -> sqlite3.Connection:
conn = sqlite3.connect(self._db_path)
conn = sqlite3.connect(self._db_path, timeout=DB_BUSY_TIMEOUT_SECONDS)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA foreign_keys = ON")
return conn
@@ -317,6 +359,7 @@ class UserDB:
),
)
conn.commit()
self._drop_user_cache()
user_id = cursor.lastrowid
if not isinstance(user_id, int):
msg = "Failed to create user"
@@ -352,6 +395,36 @@ class UserDB:
finally:
conn.close()
def get_user_cached(
self, *, user_id: int | None = None, username: str | None = None
) -> dict[str, Any] | None:
"""Get a user like get_user, answering repeats from memory for a few seconds.
For hot paths such as per-request proxy auth, where every API call (cover
images included) would otherwise open a connection for the same row.
"""
key = ("id", user_id) if user_id is not None else ("username", username)
now = time.monotonic()
with self._user_cache_lock:
cached = self._user_cache.get(key)
if cached is not None and cached[0] > now:
return dict(cached[1]) if cached[1] else None
generation = self._user_cache_generation
user = self.get_user(user_id=user_id, username=username)
with self._user_cache_lock:
# A write that landed during the read may have made this row stale.
if generation == self._user_cache_generation:
if len(self._user_cache) >= _USER_CACHE_MAX_ENTRIES:
self._user_cache.clear()
self._user_cache[key] = (now + USER_CACHE_TTL_SECONDS, user)
return dict(user) if user else None
def _drop_user_cache(self) -> None:
with self._user_cache_lock:
self._user_cache.clear()
self._user_cache_generation += 1
def _get_user_by_id(self, conn: sqlite3.Connection, user_id: int) -> dict[str, Any] | None:
row = conn.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone()
return dict(row) if row else None
@@ -398,6 +471,7 @@ class UserDB:
for column, value in kwargs.items():
conn.execute(self._USER_UPDATE_STATEMENTS[column], (value, user_id))
conn.commit()
self._drop_user_cache()
finally:
conn.close()
@@ -426,6 +500,7 @@ class UserDB:
)
conn.execute("DELETE FROM users WHERE id = ?", (user_id,))
conn.commit()
self._drop_user_cache()
finally:
conn.close()
@@ -876,11 +951,22 @@ class UserDB:
request_id: int,
*,
failure_reason: str | None = None,
remember_failed_release: bool = False,
admin_note: str | None = None,
) -> dict[str, Any] | None:
"""Reopen a failed fulfilled request so admins can re-approve it."""
"""Reopen a failed fulfilled request so admins can re-approve it.
With ``remember_failed_release``, the release it was fulfilled with is added to
``book_data["failed_releases"]`` in the same write that reopens it, so a search that
picks the request up as soon as it is pending already skips that release. An
``admin_note`` is written in that same write; None leaves the current note alone.
"""
normalized_failure_reason = None
if isinstance(failure_reason, str):
normalized_failure_reason = failure_reason.strip() or None
normalized_admin_note = None
if isinstance(admin_note, str):
normalized_admin_note = admin_note.strip() or None
with self._lock:
conn = self._connect()
@@ -908,6 +994,16 @@ class UserDB:
):
return None
failed_book_data = (
_book_data_with_failed_release(current_request)
if remember_failed_release
else None
)
book_data_json = (
None
if failed_book_data is None
else self._serialize_json(failed_book_data, "book_data")
)
conn.execute(
"""
UPDATE download_requests
@@ -915,12 +1011,14 @@ class UserDB:
delivery_state = 'none',
delivery_updated_at = NULL,
release_data = NULL,
book_data = COALESCE(?, book_data),
last_failure_reason = ?,
admin_note = COALESCE(?, admin_note),
reviewed_by = NULL,
reviewed_at = NULL
WHERE id = ?
""",
(normalized_failure_reason, request_id),
(book_data_json, normalized_failure_reason, normalized_admin_note, request_id),
)
updated_row = conn.execute(
"SELECT * FROM download_requests WHERE id = ?",
@@ -931,6 +1029,52 @@ class UserDB:
finally:
conn.close()
def list_hidden_rejected_request_ids(self) -> list[int]:
"""Ids of rejected requests hidden from the admin Rejected view."""
conn = self._connect()
try:
rows = conn.execute(
"""
SELECT h.request_id FROM rejected_request_hidden h
JOIN download_requests r ON r.id = h.request_id
WHERE r.status = 'rejected'
ORDER BY h.request_id
"""
).fetchall()
return [int(row["request_id"]) for row in rows]
finally:
conn.close()
def set_rejected_request_hidden(self, request_id: int, *, hidden: bool) -> bool:
"""Hide or show a rejected request in the Rejected view. False if it is not rejected."""
with self._lock:
conn = self._connect()
try:
row = conn.execute(
"SELECT status FROM download_requests WHERE id = ?",
(request_id,),
).fetchone()
if row is None or row["status"] != RequestStatus.REJECTED:
return False
if hidden:
conn.execute(
"""
INSERT INTO rejected_request_hidden (request_id, hidden_at)
VALUES (?, datetime('now'))
ON CONFLICT(request_id) DO NOTHING
""",
(request_id,),
)
else:
conn.execute(
"DELETE FROM rejected_request_hidden WHERE request_id = ?",
(request_id,),
)
conn.commit()
return True
finally:
conn.close()
def reopen_rejected_request(
self,
request_id: int,
@@ -968,6 +1112,10 @@ class UserDB:
""",
(admin_note, request_id),
)
conn.execute(
"DELETE FROM rejected_request_hidden WHERE request_id = ?",
(request_id,),
)
updated_row = conn.execute(
"SELECT * FROM download_requests WHERE id = ?",
(request_id,),
+30 -1
View File
@@ -7,7 +7,7 @@ import re
import sqlite3
from pathlib import Path
from threading import Lock
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Any
from urllib.parse import urlparse
from shelfmark.core.logger import setup_logger
@@ -18,6 +18,11 @@ if TYPE_CHECKING:
logger = setup_logger(__name__)
# How long an app database connection waits for another writer before it raises. SQLite's
# own default is 5 s, which a start-up migration or a restart reconcile can outlast when a
# backup or a second process holds the file.
DB_BUSY_TIMEOUT_SECONDS = 30
def normalize_http_url(
url: str | None,
@@ -137,6 +142,30 @@ AUDIOBOOK_FORMATS = ("m4b", "mp3", "m4a", "mp4", "flac", "ogg", "wma", "aac", "w
# what comes out of one after extraction.
ARCHIVE_FORMATS = ("zip", "rar")
# What the Supported Book Formats setting holds until someone changes it. The settings
# registry, post-processing and every release source fall back to this one list.
DEFAULT_EBOOK_FORMATS = ("epub", "mobi", "azw3", "fb2", "djvu", "cbz", "cbr")
# What the Supported Audiobook Formats setting holds until someone changes it: every audio
# format, and the archives a multi-file audiobook usually comes in. Same rule as above.
DEFAULT_AUDIOBOOK_FORMATS = (*AUDIOBOOK_FORMATS, *ARCHIVE_FORMATS)
def normalize_format_list(value: object, default: tuple[str, ...] | list[str]) -> list[str]:
"""Read a configured format list, or a comma or space separated string of them.
Lower-cases, drops a leading dot and empty items, and returns ``default`` when the
value is not a list or string or nothing is left of it.
"""
if isinstance(value, str):
items: list[Any] = value.replace(",", " ").split()
elif isinstance(value, (list, tuple, set, frozenset)):
items = list(value)
else:
return list(default)
formats = [fmt for fmt in (str(item).strip().lower().lstrip(".") for item in items) if fmt]
return formats or list(default)
CONTENT_TYPES = [
"book (fiction)",
+16 -1
View File
@@ -6,7 +6,8 @@ Its task id is that release's ``source_id``, which for Prowlarr is
The requester never browsed those releases, so for them a request-linked
download is addressed by an opaque id instead. Request rows keep only the
release fields the activity cards display, and a download path shrinks to the
file name, which the browser download reveals anyway.
file name, which the browser download reveals anyway. A status message that quotes
a client error loses the links in it, which can carry an indexer key or a tracker passkey.
Downloads a user queued directly keep their real id: the user picked that
release from search results that already showed its ``source_id``, and the
@@ -21,6 +22,7 @@ import os
from pathlib import PurePath
from typing import TYPE_CHECKING, Any
from shelfmark.core.redaction import strip_links
from shelfmark.core.request_helpers import normalize_positive_int
if TYPE_CHECKING:
@@ -28,6 +30,7 @@ if TYPE_CHECKING:
PUBLIC_DOWNLOAD_ID_PREFIX = "dl_"
# Release fields a non-admin's activity cards display. Everything else (source_id,
# indexer, info and download URLs, torrent attributes) stays server-side.
_RELEASE_DISPLAY_FIELDS = frozenset(
@@ -108,9 +111,21 @@ def redact_request_row(row: dict[str, Any]) -> dict[str, Any]:
return {**row, "release_data": redact_release_data(row["release_data"])}
def redact_status_message(message: str) -> str:
"""Replace every link in a status message, keeping the rest of the text.
A client error is stored as the download's status message and quotes the request, so it
can carry an indexer key or a tracker passkey that a non-admin has no use for.
"""
return strip_links(message)
def redact_download_payload(payload: dict[str, Any], task_id: str | None = None) -> dict[str, Any]:
"""Return a copy of a download payload that is safe to show its non-admin owner."""
redacted = dict(payload)
status_message = payload.get("status_message")
if isinstance(status_message, str):
redacted["status_message"] = redact_status_message(status_message)
raw_id = task_id if task_id is not None else payload.get("id")
if isinstance(raw_id, str) and raw_id:
viewer_id = viewer_download_id(raw_id, payload.get("request_id"))
+15
View File
@@ -35,6 +35,7 @@ uploads, and the Anna's Archive countdown in `release_sources.direct_download` (
refreshes the stall clock on every tick of a loop that proves nothing about the remote).
"""
import re
from collections.abc import Callable
# Seconds without a changed status or progress value before the orchestrator cancels a
@@ -48,6 +49,20 @@ ACTIVITY_GRACE_STATUS = "__activity_grace__"
StatusCallback = Callable[[str, str | None], None]
# The note a torrent handler appends to its progress message: " · 2 seeds", or
# " · 0 seeds, giving up in 7m". It follows the swarm and counts down on a timer, and neither
# is progress, so a change in it alone must not refresh the stall clock. The handler builds
# it in `ExternalClientHandler._seed_note`; a test keeps the two in step.
_SEED_NOTE_SUFFIX = re.compile(r" · \d+ seeds?(?:, giving up in \d+m)?$")
def activity_key(message: str | None) -> str | None:
"""The part of a status message whose change counts as activity."""
if message is None:
return None
return _SEED_NOTE_SUFFIX.sub("", message)
# A status_callback is caller-supplied and may raise; a failed liveness hint must never
# break the operation it was protecting. Mirrors http._STATUS_CALLBACK_ERRORS.
_CALLBACK_ERRORS = (AttributeError, KeyError, OSError, RuntimeError, TypeError, ValueError)
+20 -2
View File
@@ -9,7 +9,6 @@ This module provides:
Clients register themselves via the @register_client decorator.
"""
import logging
import os
import random
import time
@@ -23,10 +22,12 @@ from typing import TYPE_CHECKING, TypeVar, cast
import requests
from shelfmark.core.logger import setup_logger
if TYPE_CHECKING:
from collections.abc import Callable
_logger = logging.getLogger(__name__)
_logger = setup_logger(__name__)
# Type variable for generic return type
T = TypeVar("T")
@@ -178,6 +179,14 @@ class DownloadStatus:
return self.state
@dataclass(frozen=True)
class ExpectedFile:
"""A file the client will have on disk when a download is complete."""
name: str # Path relative to the client's save directory, "/" separated
size: int # Final size in bytes
class DownloadClient(ABC):
"""Base class for external download clients.
@@ -357,6 +366,15 @@ class DownloadClient(ABC):
"""
def get_expected_files(self, download_id: str) -> list[ExpectedFile] | None:
"""The files a finished download holds, or None when the client cannot say.
Lets the handler tell a folder that has all of its files from one that stopped changing
while a sync to this host is still copying them in. Only files the client downloads
count: a file the user skipped is not expected. Default: the client cannot say.
"""
return None
def find_existing(
self, url: str, category: str | None = None
) -> tuple[str, DownloadStatus] | None:
+24 -15
View File
@@ -19,6 +19,7 @@ import requests
from shelfmark.config.env import TMP_DIR
from shelfmark.core.config import config
from shelfmark.core.logger import setup_logger
from shelfmark.core.redaction import safe_url
from shelfmark.download.clients import (
DownloadClient,
DownloadState,
@@ -37,6 +38,11 @@ from shelfmark.download.network import get_ssl_verify
logger = setup_logger(__name__)
_API_BASE = "https://api.alldebrid.com/v4"
# One session for every client instance, so the status poll every couple of seconds
# reuses a keep-alive connection instead of opening a new TLS connection each time.
_session = requests.Session()
_AGENT = "shelfmark"
_ALLDEBRID_CLIENT_ERRORS = (
@@ -177,7 +183,7 @@ class AllDebridClient(DownloadClient):
return False, "AllDebrid API Key is required"
try:
url = f"{_API_BASE}/user"
resp = requests.get(
resp = _session.get(
url,
headers=self._auth_headers(),
timeout=_STATUS_TIMEOUT,
@@ -261,7 +267,7 @@ class AllDebridClient(DownloadClient):
if isinstance(upload, DebridMagnet):
api_url = f"{_API_BASE}/magnet/upload"
entries_key = "magnets"
resp = requests.post(
resp = _session.post(
api_url,
headers=self._auth_headers(),
data={"magnets[]": upload.magnet_url},
@@ -271,7 +277,7 @@ class AllDebridClient(DownloadClient):
else:
api_url = f"{_API_BASE}/magnet/upload/file"
entries_key = "files"
resp = requests.post(
resp = _session.post(
api_url,
headers=self._auth_headers(),
files={
@@ -335,7 +341,7 @@ class AllDebridClient(DownloadClient):
# Ask AllDebrid for the current magnet status.
try:
status_url = f"{_API_BASE.replace('/v4', '/v4.1')}/magnet/status"
resp = requests.post(
resp = _session.post(
status_url,
headers=self._auth_headers(),
data={"id": download_id},
@@ -369,7 +375,7 @@ class AllDebridClient(DownloadClient):
"""Delete the magnet from AllDebrid and clean up local files."""
try:
url = f"{_API_BASE}/magnet/delete"
requests.post(
_session.post(
url,
headers=self._auth_headers(),
data={"id": download_id},
@@ -516,7 +522,7 @@ class AllDebridClient(DownloadClient):
# 2. POST unlock (primary method).
try:
resp = requests.post(
resp = _session.post(
unlock_url,
headers=headers,
data={"link": link},
@@ -545,7 +551,7 @@ class AllDebridClient(DownloadClient):
get_url = (
f"{_API_BASE}/link/unlock?agent={_AGENT}&apikey={self._api_key}&link={encoded}"
)
resp = requests.get(
resp = _session.get(
get_url,
headers=headers,
timeout=_API_TIMEOUT,
@@ -559,13 +565,14 @@ class AllDebridClient(DownloadClient):
return direct
err_msg = body.get("error", {}).get("message", err_msg)
except _ALLDEBRID_CLIENT_ERRORS as e:
logger.debug("GET unlock exception: %s", e)
# The request URL carries the API key and requests puts it in the message.
logger.debug("GET unlock exception: %s", type(e).__name__)
# 4. Last-resort: append apikey to alldebrid.com/f/ links.
if "alldebrid.com/f/" in link:
logger.info(
"Using apikey fallback for AllDebrid file link: %s",
link,
safe_url(link),
)
if "apikey=" not in link:
sep = "&" if "?" in link else "?"
@@ -574,7 +581,7 @@ class AllDebridClient(DownloadClient):
logger.error(
"AllDebrid unlock failed for '%s': %s",
link,
safe_url(link),
err_msg,
)
msg = f"AllDebrid unlock failed: {err_msg}"
@@ -601,7 +608,7 @@ class AllDebridClient(DownloadClient):
for _ in range(_DELAYED_POLL_MAX_ATTEMPTS):
time.sleep(_DELAYED_POLL_INTERVAL)
try:
resp = requests.post(
resp = _session.post(
delayed_url,
headers=headers,
data={"id": delayed_id},
@@ -655,13 +662,15 @@ class AllDebridClient(DownloadClient):
buf = download_url(
direct_link,
referer="https://alldebrid.com/",
spool_to_disk=True,
)
if not buf:
msg = f"Failed to download from {direct_link}"
msg = f"Failed to download from {safe_url(direct_link)}"
_raise_runtime_error(msg)
with dest.open("wb") as fh:
fh.write(buf.getvalue())
with buf, dest.open("wb") as fh:
buf.seek(0)
shutil.copyfileobj(buf, fh)
with state.lock:
state.progress = 50.0 + (idx + 1) / total * 50.0
@@ -693,7 +702,7 @@ class AllDebridClient(DownloadClient):
) -> list[dict[str, Any]]:
"""Retrieve and flatten the file tree for a magnet."""
url = f"{_API_BASE}/magnet/files"
resp = requests.post(
resp = _session.post(
url,
headers=self._auth_headers(),
data={"id[]": magnet_id},
+165 -16
View File
@@ -14,12 +14,14 @@ from typing import TYPE_CHECKING, Protocol, TypeGuard
from shelfmark.core.config import config
from shelfmark.core.logger import setup_logger
from shelfmark.core.request_helpers import coerce_int, normalize_optional_text
from shelfmark.core.utils import is_audiobook
from shelfmark.core.utils import AUDIOBOOK_FORMATS, DEFAULT_EBOOK_FORMATS, is_audiobook
from shelfmark.download import torrent_ledger
from shelfmark.download.activity import release_activity_grace, request_activity_grace
from shelfmark.download.clients import (
DownloadClient,
DownloadState,
DownloadStatus,
ExpectedFile,
get_client,
list_configured_clients,
)
@@ -34,7 +36,7 @@ from shelfmark.download.permissions_debug import log_path_permission_context
from shelfmark.release_sources import DownloadHandler, HandoffResult
if TYPE_CHECKING:
from collections.abc import Callable
from collections.abc import Callable, Sequence
from threading import Event
from shelfmark.core.models import DownloadTask
@@ -123,7 +125,7 @@ class _CompletedPathResolution:
retryable: bool
def _coerce_completed_path_timeout_seconds(value: object, default: float) -> float:
def coerce_completed_path_timeout_seconds(value: object, default: float) -> float:
if isinstance(value, bool) or value is None:
return default
if isinstance(value, (int, float)):
@@ -251,6 +253,92 @@ def _completed_file_signature(
return tuple(sorted(signature)) or None
def _count_expected_files_present(path: Path, expected: Sequence[ExpectedFile]) -> int:
"""How many of the client's files are at ``path`` with their final size.
``path`` is the completed download: the torrent's top-level folder, or the file itself for a
single-file torrent. The client names files relative to its save directory, so they start
with that top-level folder, and the folder is tried with and without it.
"""
try:
single_file = path.is_file()
if single_file:
local = {path.name: path.stat().st_size}
elif path.is_dir():
local = {}
for candidate in path.rglob("*"):
try:
if candidate.is_file():
local[candidate.relative_to(path).as_posix()] = candidate.stat().st_size
except OSError:
continue
else:
return 0
except OSError:
return 0
present = 0
for item in expected:
names = [item.name]
if "/" in item.name:
names.append(item.name.split("/", 1)[1])
if single_file:
names.append(item.name.rsplit("/", 1)[1])
if any(local.get(name) == item.size for name in names):
present += 1
return present
# Extensions that mean a finished book or recording, whichever content type the task is for.
# A file with one of these that the task does not accept (an .m4b in an ebook task, a .pdf
# with the default format list) is a wrong-type result, not a sidecar still waiting for the
# real files to sync.
_PAYLOAD_SUFFIXES = frozenset(
f".{fmt.lstrip('.').lower()}" for fmt in (*AUDIOBOOK_FORMATS, *DEFAULT_EBOOK_FORMATS, "pdf")
)
def _unsupported_payload_signature(
path: Path, content_type: str | None
) -> tuple[tuple[str, int, int], ...] | None:
"""Sizes and mtimes of book or audio files in a path that this task does not accept.
None when there are none. Used to tell a finished directory that holds the wrong kind of
files apart from one that has only cover art and notes so far.
"""
from shelfmark.download.postprocess.policy import (
get_supported_audiobook_formats,
get_supported_formats,
)
formats = (
get_supported_audiobook_formats() if is_audiobook(content_type) else get_supported_formats()
)
supported_suffixes = {f".{fmt.lstrip('.').lower()}" for fmt in formats}
try:
if not path.is_dir():
return None
signature = []
for candidate in path.rglob("*"):
suffix = candidate.suffix.lower()
if suffix not in _PAYLOAD_SUFFIXES or suffix in supported_suffixes:
continue
try:
stat = candidate.stat()
except OSError:
continue
if not candidate.is_file() or stat.st_size <= 0:
continue
signature.append(
(candidate.relative_to(path).as_posix(), stat.st_size, stat.st_mtime_ns)
)
except OSError:
return None
return tuple(sorted(signature)) or None
class ExternalClientHandler(DownloadHandler, ABC):
"""Shared lifecycle handler for sources that hand off to torrent/usenet clients."""
@@ -332,7 +420,7 @@ class ExternalClientHandler(DownloadHandler, ABC):
"""Total time to wait for completed files to appear on disk."""
fallback = self._completed_path_retry_interval() * self._completed_path_max_attempts()
configured = config.get(COMPLETED_PATH_TIMEOUT_SETTING, fallback)
return _coerce_completed_path_timeout_seconds(configured, fallback)
return coerce_completed_path_timeout_seconds(configured, fallback)
def _refresh_download_request_after_add_failure(
self,
@@ -864,9 +952,17 @@ class ExternalClientHandler(DownloadHandler, ABC):
status_callback: Callable[[str, str | None], None],
content_type: str | None = None,
) -> tuple[Path | None, str | None]:
"""Wait for supported files to appear and stop changing on disk."""
"""Wait for supported files to appear and stop changing on disk.
When the client can list the files of the download, every one of them must also be
present at its final size. A sync that copies the files in batches can pause long
enough for two samples to match while most of the files are still missing.
"""
last_error: str | None = None
previous_signature: tuple[tuple[str, int, int], ...] | None = None
expected_files: list[ExpectedFile] | None = None
expected_files_asked = False
previous_unsupported: tuple[tuple[str, int, int], ...] | None = None
retry_interval = self._completed_path_retry_interval()
timeout_seconds = self._completed_path_timeout_seconds()
if retry_interval <= 0 or timeout_seconds <= 0:
@@ -885,18 +981,52 @@ class ExternalClientHandler(DownloadHandler, ABC):
log_details=log_details,
)
if result.path:
if run_blocking_io(result.path.is_file):
return result.path, None
signature = run_blocking_io(
_completed_file_signature,
result.path,
content_type,
if not expected_files_asked:
expected_files = self._expected_files(client, download_id)
expected_files_asked = True
files_on_disk = (
run_blocking_io(_count_expected_files_present, result.path, expected_files)
if expected_files
else None
)
# With no wait configured there is no second sample to compare against.
if signature is not None and (signature == previous_signature or max_attempts == 1):
return result.path, None
previous_signature = signature
last_error = "Completed download has no stable supported files yet."
if (
expected_files
and files_on_disk is not None
and files_on_disk < len(expected_files)
):
last_error = (
"Completed download is not fully on disk yet "
f"({files_on_disk} of {len(expected_files)} files)."
)
else:
if run_blocking_io(result.path.is_file):
return result.path, None
signature = run_blocking_io(
_completed_file_signature,
result.path,
content_type,
)
# With no wait configured there is no second sample to compare against.
if signature is not None and (
signature == previous_signature or max_attempts == 1
):
return result.path, None
previous_signature = signature
if signature is None:
# Nothing this task accepts. If the directory instead holds finished
# files of another kind and has stopped changing, waiting longer will
# not help: hand it on so post-processing names the format problem.
unsupported = run_blocking_io(
_unsupported_payload_signature,
result.path,
content_type,
)
if unsupported is not None and (
unsupported == previous_unsupported or max_attempts == 1
):
return result.path, None
previous_unsupported = unsupported
last_error = "Completed download has no stable supported files yet."
if attempt < max_attempts:
status_callback("locating", "Waiting for completed files...")
logger.debug(
@@ -943,6 +1073,18 @@ class ExternalClientHandler(DownloadHandler, ABC):
return None, last_error
@staticmethod
def _expected_files(client: DownloadClient, download_id: str) -> list[ExpectedFile] | None:
"""The client's list of files for this download, or None when it has none to give."""
try:
expected = client.get_expected_files(download_id)
except _CLIENT_CLEANUP_ERRORS:
logger.debug("Could not read the file list of %s from %s", download_id, client.name)
return None
if not isinstance(expected, list) or not expected:
return None
return [item for item in expected if isinstance(item, ExpectedFile)] or None
@staticmethod
def _seed_note(seeds: int | None, give_up_in: float | None) -> str:
"""What the card shows beside progress so nobody has to open the client to see the swarm."""
@@ -1008,6 +1150,9 @@ class ExternalClientHandler(DownloadHandler, ABC):
status_callback("resolving", f"Checking {client.name}")
category = self._get_category_for_task(client, task)
existing = client.find_existing(request.url, category=category)
# A torrent the client already had is joined, not added again; the card says so.
# Set here so every path below, including a copy of a finished download, carries it.
task.reattached = existing is not None
if existing:
download_id, existing_status = existing
@@ -1053,6 +1198,8 @@ class ExternalClientHandler(DownloadHandler, ABC):
return result
# Existing but still downloading - join the progress polling
if request.protocol == "torrent":
torrent_ledger.claim(download_id, task.task_id)
logger.info("Existing download in progress, joining poll loop")
status_callback("downloading", "Resuming existing download")
else:
@@ -1096,6 +1243,8 @@ class ExternalClientHandler(DownloadHandler, ABC):
logger.info(
"Added to %s: %s for '%s'", client.name, download_id, request.release_name
)
if request.protocol == "torrent":
torrent_ledger.record(download_id, task.task_id)
if getattr(client, "handoff_only", False) is True:
if cancel_flag.is_set():
# The file is published and unpublishing it would race a watcher
+9 -2
View File
@@ -49,6 +49,10 @@ logger = setup_logger(__name__)
_API_BASE = "https://debrid-link.com/api/v2"
# One session for every client instance, so the status poll every couple of seconds
# reuses a keep-alive connection instead of opening a new TLS connection each time.
_session = requests.Session()
_DEBRIDLINK_CLIENT_ERRORS = (
AttributeError,
OSError,
@@ -428,7 +432,7 @@ class DebridLinkClient(DownloadClient):
"""
url = f"{_API_BASE}{path}"
try:
resp = requests.request(
resp = _session.request(
method,
url,
headers=self._auth_headers(),
@@ -736,8 +740,11 @@ class DebridLinkClient(DownloadClient):
str(file_info.get("downloadUrl", "")),
referer="https://debrid-link.com/",
cancel_flag=state.cancel_event,
spool_to_disk=True,
)
if state.cancel_event.is_set():
if buf is not None:
buf.close()
return
if not buf:
# The URL is signed and stays out of the message, which ends up
@@ -745,7 +752,7 @@ class DebridLinkClient(DownloadClient):
msg = f"Failed to download {rel_path} from Debrid-Link"
_raise_runtime_error(msg)
with dest.open("wb") as fh:
with buf, dest.open("wb") as fh:
buf.seek(0)
shutil.copyfileobj(buf, fh)
+105 -11
View File
@@ -16,6 +16,7 @@ from shelfmark.core.logger import setup_logger
from shelfmark.download.clients import (
DownloadClient,
DownloadStatus,
ExpectedFile,
register_client,
)
from shelfmark.download.clients._coercion import (
@@ -33,6 +34,7 @@ from shelfmark.download.network import get_ssl_verify
try:
import qbittorrentapi as _qbittorrentapi
except ImportError:
_qbittorrentapi = None
_ImportedQBittorrentApiError = RuntimeError
_ImportedQBittorrentLoginFailed = RuntimeError
else:
@@ -44,6 +46,7 @@ logger = setup_logger(__name__)
_HASH_LENGTH_40 = 40
_HASH_LENGTH_ED2K = 32
_HTTP_STATUS_FORBIDDEN = HTTPStatus.FORBIDDEN
_HTTP_STATUS_UNAUTHORIZED = HTTPStatus.UNAUTHORIZED
_HTTP_STATUS_NOT_FOUND = HTTPStatus.NOT_FOUND
_METADATA_DOWNLOAD_STATES = {"forcedMetaDL", "metaDL"}
# How long add_download waits for magnet metadata before falling back to the info
@@ -102,6 +105,35 @@ def _resolve_qbittorrent_exception_type(candidate: object) -> type[Exception]:
_QBittorrentApiError = _resolve_qbittorrent_exception_type(_ImportedQBittorrentApiError)
_QBittorrentLoginFailed = _resolve_qbittorrent_exception_type(_ImportedQBittorrentLoginFailed)
def _qbittorrent_auth_errors(module: object) -> tuple[type[Exception], ...]:
"""The qbittorrent-api errors that mean qBittorrent refused Shelfmark's login.
Empty when the library is missing, so nothing is mistaken for a refused login.
"""
found = (
getattr(module, name, None) for name in ("LoginFailed", "HTTP401Error", "HTTP403Error")
)
return tuple(
error for error in found if isinstance(error, type) and issubclass(error, Exception)
)
_QBITTORRENT_AUTH_ERRORS = _qbittorrent_auth_errors(_qbittorrentapi)
def _auth_failure_message(error: Exception | None = None, *, status: int | None = None) -> str:
"""Name a refused login as one, so the failure is not held against the release.
qbittorrent-api says nothing for a wrong password and only "Forbidden" or "Unauthorized"
for a bad session or API key, which reads like a problem with the torrent.
"""
code = status if status is not None else getattr(error, "http_status_code", None)
detail = f"HTTP {code}" if code else "login rejected"
return f"qBittorrent authentication failed ({detail})"
_QBITTORRENT_CLIENT_ERRORS = (
_QBittorrentLoginFailed,
_QBittorrentApiError,
@@ -301,6 +333,10 @@ class QBittorrentClient(DownloadClient):
return [SimpleNamespace(**t) for t in torrents], None
except requests.exceptions.HTTPError as e:
status = getattr(getattr(e, "response", None), "status_code", None)
if status == _HTTP_STATUS_UNAUTHORIZED:
msg = _auth_failure_message(status=status)
logger.warning(msg)
return [], msg
if status:
logger.warning("qBittorrent API error (HTTP %s): %s", status, e)
return [], f"qBittorrent API request failed (HTTP {status})"
@@ -320,6 +356,10 @@ class QBittorrentClient(DownloadClient):
"qBittorrent URL is invalid (missing http:// or https://). "
f"Configured: {self._base_url}",
)
except _QBITTORRENT_AUTH_ERRORS as e:
msg = _auth_failure_message(e)
logger.warning(msg)
return [], msg
except _QBITTORRENT_CLIENT_ERRORS as e:
logger.debug("Failed to get torrents info: %s", e)
return [], f"qBittorrent API error: {type(e).__name__}: {e}"
@@ -349,6 +389,15 @@ class QBittorrentClient(DownloadClient):
params = {"category": category} if category else {}
return self._request_torrent_info_records(params)
def list_torrents(self, category: str | None = None) -> list[SimpleNamespace] | None:
"""Every torrent record in ``category`` (all when unset); None when qBittorrent fails.
An empty list means qBittorrent answered and holds nothing, which a caller pruning
what it remembers has to tell apart from a read that failed.
"""
torrents, error = self._list_torrents_by_category(category)
return None if error else torrents
def _remember_primary_hash(self, download_id: str, torrent: SimpleNamespace) -> None:
"""Note the primary hash a listing scan found, so later lookups skip the scan."""
torrent_hash = getattr(torrent, "hash", None)
@@ -607,6 +656,10 @@ class QBittorrentClient(DownloadClient):
expected_hash,
_METADATA_WAIT_POLLS * _METADATA_WAIT_INTERVAL_SECONDS,
)
except _QBITTORRENT_AUTH_ERRORS as e:
msg = _auth_failure_message(e)
logger.warning(msg)
raise RuntimeError(msg) from e
except _QBITTORRENT_CLIENT_ERRORS:
logger.exception("qBittorrent add failed")
raise
@@ -638,6 +691,7 @@ class QBittorrentClient(DownloadClient):
), # None = use default progress message
"stalledDL": ("downloading", "Stalled"),
"metaDL": ("downloading", "Fetching metadata"),
"forcedMetaDL": ("downloading", "Fetching metadata"),
"forcedDL": ("downloading", None),
"allocating": ("downloading", "Allocating space"),
"uploading": ("seeding", "Seeding"),
@@ -645,6 +699,9 @@ class QBittorrentClient(DownloadClient):
"forcedUP": ("seeding", "Seeding"),
"pausedDL": ("paused", "Paused"),
"pausedUP": ("paused", "Paused"),
# qBittorrent 5 renamed "paused" to "stopped"
"stoppedDL": ("paused", "Paused"),
"stoppedUP": ("paused", "Paused"),
"queuedDL": ("queued", "Queued"),
"queuedUP": ("queued", "Queued"),
"checkingDL": ("checking", "Checking files"),
@@ -800,6 +857,52 @@ class QBittorrentClient(DownloadClient):
getattr(torrent, "name", ""),
)
def _get_with_auth(self, url: str, params: dict[str, str]) -> requests.Response:
"""GET a Web API endpoint, signing in again once if the session was dropped."""
self._ensure_authenticated()
resp = self._client._session.get(url, params=params, timeout=10)
if resp.status_code == _HTTP_STATUS_FORBIDDEN and self._can_reauthenticate:
logger.debug("qBittorrent returned 403; re-authenticating and retrying")
self._ensure_authenticated()
resp = self._client._session.get(url, params=params, timeout=10)
return resp
def get_expected_files(self, download_id: str) -> list[ExpectedFile] | None:
"""The files this torrent holds when finished, from `/api/v2/torrents/files`.
Files with priority 0 are not downloaded and BEP 47 padding files are not real, so
neither is expected. None when the client does not answer or lists nothing.
"""
try:
resp = self._get_with_auth(
f"{self._base_url}/api/v2/torrents/files", {"hash": download_id}
)
if resp.status_code == _HTTP_STATUS_NOT_FOUND:
return None
resp.raise_for_status()
payload = resp.json()
except _QBITTORRENT_CLIENT_ERRORS as e:
self._log_error("get_expected_files", e, level="debug")
return None
if not isinstance(payload, list):
return None
expected: list[ExpectedFile] = []
for entry in payload:
if not isinstance(entry, dict):
continue
name = entry.get("name")
size = entry.get("size")
if not isinstance(name, str) or not name or isinstance(size, bool):
continue
if not isinstance(size, int) or size <= 0 or entry.get("priority") == 0:
continue
normalized = name.replace("\\", "/")
if ".pad" in normalized.split("/")[:-1]:
continue
expected.append(ExpectedFile(normalized, size))
return expected or None
def _derive_download_path_from_files(
self, download_id: str
) -> str | _UnsafeQBittorrentPath | None:
@@ -810,20 +913,11 @@ class QBittorrentClient(DownloadClient):
"""
import os
def get_with_auth(url: str, params: dict[str, str]) -> requests.Response:
self._ensure_authenticated()
resp = self._client._session.get(url, params=params, timeout=10)
if resp.status_code == _HTTP_STATUS_FORBIDDEN and self._can_reauthenticate:
logger.debug("qBittorrent returned 403; re-authenticating and retrying")
self._ensure_authenticated()
resp = self._client._session.get(url, params=params, timeout=10)
return resp
try:
properties_url = f"{self._base_url}/api/v2/torrents/properties"
files_url = f"{self._base_url}/api/v2/torrents/files"
props_resp = get_with_auth(properties_url, {"hash": download_id})
props_resp = self._get_with_auth(properties_url, {"hash": download_id})
if props_resp.status_code == _HTTP_STATUS_NOT_FOUND:
return None
props_resp.raise_for_status()
@@ -833,7 +927,7 @@ class QBittorrentClient(DownloadClient):
if not isinstance(save_path, str) or not save_path:
return None
files_resp = get_with_auth(files_url, {"hash": download_id})
files_resp = self._get_with_auth(files_url, {"hash": download_id})
if files_resp.status_code == _HTTP_STATUS_NOT_FOUND:
return None
files_resp.raise_for_status()
+15 -9
View File
@@ -36,6 +36,10 @@ logger = setup_logger(__name__)
_API_BASE = "https://api.real-debrid.com/rest/1.0"
# One session for every client instance, so the status poll every couple of seconds
# reuses a keep-alive connection instead of opening a new TLS connection each time.
_session = requests.Session()
_REALDEBRID_CLIENT_ERRORS = (
AttributeError,
OSError,
@@ -151,7 +155,7 @@ class RealDebridClient(DownloadClient):
return False, "Real-Debrid API Key is required"
try:
url = f"{_API_BASE}/user"
resp = requests.get(
resp = _session.get(
url,
headers=self._auth_headers(),
timeout=_STATUS_TIMEOUT,
@@ -201,7 +205,7 @@ class RealDebridClient(DownloadClient):
# Select all files so Real-Debrid starts downloading the torrent
select_url = f"{_API_BASE}/torrents/selectFiles/{torrent_id}"
sel_resp = requests.post(
sel_resp = _session.post(
select_url,
headers=self._auth_headers(),
data={"files": "all"},
@@ -239,7 +243,7 @@ class RealDebridClient(DownloadClient):
"""Hand the torrent to Real-Debrid, as a magnet or as a file upload."""
if isinstance(upload, DebridMagnet):
add_url = f"{_API_BASE}/torrents/addMagnet"
resp = requests.post(
resp = _session.post(
add_url,
headers=self._auth_headers(),
data={"magnet": upload.magnet_url},
@@ -250,7 +254,7 @@ class RealDebridClient(DownloadClient):
# addTorrent is a PUT that takes the raw file as the request body,
# not a form field: https://api.real-debrid.com/
add_url = f"{_API_BASE}/torrents/addTorrent"
resp = requests.put(
resp = _session.put(
add_url,
headers={
**self._auth_headers(),
@@ -294,7 +298,7 @@ class RealDebridClient(DownloadClient):
# Query Real-Debrid for torrent info.
try:
info_url = f"{_API_BASE}/torrents/info/{download_id}"
resp = requests.get(
resp = _session.get(
info_url,
headers=self._auth_headers(),
timeout=_STATUS_TIMEOUT,
@@ -320,7 +324,7 @@ class RealDebridClient(DownloadClient):
"""Delete the torrent from Real-Debrid and clean up local files."""
try:
url = f"{_API_BASE}/torrents/delete/{download_id}"
requests.delete(
_session.delete(
url,
headers=self._auth_headers(),
timeout=_STATUS_TIMEOUT,
@@ -477,7 +481,7 @@ class RealDebridClient(DownloadClient):
# Unrestrict the Real-Debrid link to get direct CDN download URL
unrestrict_url = f"{_API_BASE}/unrestrict/link"
unl_resp = requests.post(
unl_resp = _session.post(
unrestrict_url,
headers=self._auth_headers(),
data={"link": link},
@@ -513,13 +517,15 @@ class RealDebridClient(DownloadClient):
buf = download_url(
direct_url,
referer="https://real-debrid.com/",
spool_to_disk=True,
)
if not buf:
msg = f"Failed to download from {direct_url}"
_raise_runtime_error(msg)
with dest.open("wb") as fh:
fh.write(buf.getvalue())
with buf, dest.open("wb") as fh:
buf.seek(0)
shutil.copyfileobj(buf, fh)
with state.lock:
state.progress = 50.0 + (idx + 1) / total * 50.0
+27 -4
View File
@@ -815,7 +815,10 @@ def prowlarr_clients_settings() -> list[SettingsField]:
TextField(
key="TRANSMISSION_URL",
label="Transmission URL",
description="URL of your Transmission instance (use https:// for TLS)",
description=(
"URL of your Transmission instance (use https:// for TLS). Without a port, "
"https uses 443 and http uses 9091, so add :9091 to an https URL served on 9091"
),
placeholder="http://transmission:9091",
show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "transmission"},
),
@@ -1084,16 +1087,19 @@ def prowlarr_clients_settings() -> list[SettingsField]:
default=0,
min_value=0,
max_value=240,
show_when={"field": "PROWLARR_TORRENT_CLIENT", "notEmpty": True},
show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "qbittorrent"},
),
NumberField(
key="TORRENT_MIN_SEEDERS",
label="Minimum Seeders",
description="Seeds the client must see for a motionless torrent to keep waiting. 0 also turns the no-seed check off.",
description=(
"Seeds the client must see for a motionless torrent to keep waiting. 0 also "
"turns the no-seed check off. Only qBittorrent reports seed counts."
),
default=1,
min_value=0,
max_value=1000,
show_when={"field": "PROWLARR_TORRENT_CLIENT", "notEmpty": True},
show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "qbittorrent"},
),
TextField(
key="PROWLARR_TORRENT_POST_IMPORT_CATEGORY",
@@ -1117,6 +1123,23 @@ def prowlarr_clients_settings() -> list[SettingsField]:
max_value=STALL_TIMEOUT_MAX_MINUTES,
show_when={"field": "PROWLARR_TORRENT_CLIENT", "notEmpty": True},
),
NumberField(
key="STALE_TORRENT_HOURS",
label="Remove dead torrents after (hours)",
description=(
"Remove a torrent Shelfmark added when it is stalled or cannot fetch its "
"metadata, has had no activity for this many hours, and no running download "
"owns it. Its partial data goes with it. Only torrents Shelfmark itself added "
"are ever considered, whatever category they are in; torrents still "
"downloading, queued, or finished are never touched. 3 to 72 hours (a value "
"outside that is held to it); 0 keeps them. Checked every 15 minutes, "
"independent of the Hardcover sync; only qBittorrent is supported."
),
default=6,
min_value=0,
max_value=72,
show_when={"field": "PROWLARR_TORRENT_CLIENT", "notEmpty": True},
),
# --- Usenet Client Selection ---
HeadingField(
key="usenet_heading",
+10 -2
View File
@@ -37,6 +37,11 @@ if TYPE_CHECKING:
logger = setup_logger(__name__)
_API_BASE = "https://api.torbox.app/v1/api"
# One session for every client instance, so the status poll every couple of seconds
# reuses a keep-alive connection instead of opening a new TLS connection each time.
_session = requests.Session()
_API_TIMEOUT = 30
_STATUS_TIMEOUT = 15
_WORKER_JOIN_TIMEOUT = 5.0
@@ -320,7 +325,7 @@ class TorBoxClient(DownloadClient):
"verify": get_ssl_verify(url),
**kwargs,
}
request: Any = requests.get if method == "GET" else requests.post
request: Any = _session.get if method == "GET" else _session.post
try:
response = request(url, **request_kwargs)
except requests.exceptions.RequestException as e:
@@ -523,8 +528,11 @@ class TorBoxClient(DownloadClient):
direct_url,
referer="https://torbox.app/",
cancel_flag=state.cancel_event,
spool_to_disk=True,
)
if state.cancel_event.is_set():
if buffer is not None:
buffer.close()
return
if not buffer:
_raise_runtime_error(
@@ -533,7 +541,7 @@ class TorBoxClient(DownloadClient):
destination = state.target_dir / relative_path
destination.parent.mkdir(parents=True, exist_ok=True)
with destination.open("wb") as output:
with buffer, destination.open("wb") as output:
buffer.seek(0)
shutil.copyfileobj(buffer, output)
with state.lock:
+21 -34
View File
@@ -12,12 +12,13 @@ from binascii import Error as BinasciiError
from dataclasses import dataclass
from pathlib import Path, PurePosixPath, PureWindowsPath
from threading import Lock
from urllib.parse import ParseResult, parse_qs, urljoin, urlparse, urlunparse
from urllib.parse import ParseResult, parse_qs, urljoin, urlparse
import requests
from shelfmark.core.config import config
from shelfmark.core.logger import setup_logger
from shelfmark.core.redaction import redact_urls_in_text, safe_url
from shelfmark.core.utils import normalize_http_url
from shelfmark.download.network import get_ssl_verify
from shelfmark.download.postprocess.packs import PackFile
@@ -40,7 +41,8 @@ _TORRENT_FETCH_ERRORS = (
ValueError,
)
_TORRENT_PARSE_ERRORS = (IndexError, KeyError, TypeError, ValueError)
_TRUSTED_TORRENT_FETCH_URL_CONFIG_KEYS = ("PROWLARR_URL", "NEWZNAB_URL")
_PROWLARR_KEY_URL_CONFIG_KEYS = ("PROWLARR_URL", "NEWZNAB_URL")
_TRUSTED_TORRENT_FETCH_URL_CONFIG_KEYS = (*_PROWLARR_KEY_URL_CONFIG_KEYS, "JACKETT_URL")
# Successful torrent fetches are reused for a short window so one add attempt
# hits the download link only once. Tracker download links (e.g. private
@@ -54,28 +56,6 @@ _torrent_fetch_cache: dict[str, tuple[float, TorrentInfo]] = {}
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
class TorrentInfo:
"""Parsed information from a torrent URL."""
@@ -158,7 +138,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}")
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 {_safe_url(url)} ({reason})"
msg = f"Could not resolve a torrent to send from {safe_url(url)} ({reason})"
raise ValueError(msg)
@@ -210,7 +190,7 @@ def _get_cached_torrent_fetch(url: str) -> TorrentInfo | None:
if time.monotonic() - fetched_at > _TORRENT_FETCH_CACHE_TTL_SECONDS:
del _torrent_fetch_cache[url]
return None
logger.debug("Reusing recently fetched torrent data for: %s...", _safe_url(url))
logger.debug("Reusing recently fetched torrent data for: %s...", safe_url(url))
return info
@@ -256,7 +236,7 @@ def _fetch_torrent_info(url: str) -> TorrentInfo:
return urljoin(current, location)
try:
logger.debug("Fetching torrent file from: %s...", _safe_url(url))
logger.debug("Fetching torrent file from: %s...", safe_url(url))
# Redirects are followed manually: some indexers redirect download URLs
# to magnet links, and each hop must decide anew whether it may see the
@@ -267,7 +247,10 @@ def _fetch_torrent_info(url: str) -> TorrentInfo:
trusted = _is_trusted_torrent_fetch_url(current_url)
_check_torrent_fetch_hop(current_url, trusted=trusted)
request_headers = dict(headers)
if not trusted:
# Jackett links carry their own key; Prowlarr's must never reach Jackett.
if not trusted or not _is_trusted_torrent_fetch_url(
current_url, _PROWLARR_KEY_URL_CONFIG_KEYS
):
request_headers.pop("X-Api-Key", None)
resp = requests.get(
@@ -291,7 +274,7 @@ def _fetch_torrent_info(url: str) -> TorrentInfo:
magnet_url=redirect_url,
)
if redirects_remaining <= 0:
logger.warning("Too many redirects fetching torrent file: %s...", _safe_url(url))
logger.warning("Too many redirects fetching torrent file: %s...", safe_url(url))
return TorrentInfo(
info_hash=None,
torrent_data=None,
@@ -299,7 +282,7 @@ def _fetch_torrent_info(url: str) -> TorrentInfo:
fetch_error="too many redirects",
)
redirects_remaining -= 1
logger.debug("Following redirect to: %s...", _safe_url(redirect_url))
logger.debug("Following redirect to: %s...", safe_url(redirect_url))
current_url = redirect_url
resp.raise_for_status()
@@ -327,7 +310,7 @@ def _fetch_torrent_info(url: str) -> TorrentInfo:
except _TORRENT_FETCH_ERRORS as e:
# Exception messages can repeat the source or redirect URL, including its
# credentials; scrub them before logging or storing the reason.
message = _redact_urls_in_text(str(e))
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)
@@ -360,13 +343,15 @@ def _check_torrent_fetch_hop(url: str, *, trusted: bool) -> None:
raise ValueError("torrent fetch refused: address is not public")
def _is_trusted_torrent_fetch_url(url: str) -> bool:
def _is_trusted_torrent_fetch_url(
url: str, config_keys: tuple[str, ...] = _TRUSTED_TORRENT_FETCH_URL_CONFIG_KEYS
) -> bool:
parsed = urlparse(url)
origin = _url_origin(parsed)
if origin is None:
return False
for key in _TRUSTED_TORRENT_FETCH_URL_CONFIG_KEYS:
for key in config_keys:
configured_url = str(config.get(key, "") or "").strip()
if not configured_url:
continue
@@ -397,7 +382,9 @@ def parse_transmission_url(url: str) -> tuple[str, str, int, str]:
if protocol not in ("http", "https"):
protocol = "http"
host = parsed.hostname or "localhost"
port = parsed.port or 9091
# An omitted port is scheme-specific: HTTPS behind a reverse proxy is 443,
# while HTTP (including the empty-URL default) stays on Transmission's RPC port.
port = parsed.port if parsed.port is not None else (443 if protocol == "https" else 9091)
path = parsed.path or "/transmission/rpc"
# Ensure path ends with /rpc
+105
View File
@@ -0,0 +1,105 @@
"""Periodic housekeeping for downloads, independent of the Hardcover sync.
One in-process daemon thread (the same pattern as the Hardcover scheduler) runs
two sweeps every ``INTERVAL_SECONDS``: the dead-torrent sweep and the Soulseek
leftovers sweep. Each is a no-op when its feature is off or its client is not
configured, so the thread costs nothing for installs that use neither.
"""
from __future__ import annotations
import threading
import time
from typing import TYPE_CHECKING, Any
from shelfmark.core.logger import setup_logger
if TYPE_CHECKING:
from shelfmark.core.download_history_service import DownloadHistoryService
from shelfmark.core.user_db import UserDB
logger = setup_logger(__name__)
STARTUP_DELAY_SECONDS = 30 # Lets the download coordinator and startup reconcile run first.
INTERVAL_SECONDS = 15 * 60
_thread: threading.Thread | None = None
_thread_lock = threading.Lock()
_ctx: dict[str, Any] = {}
def run_once(
user_db: UserDB | None = None, history_service: DownloadHistoryService | None = None
) -> dict[str, Any]:
"""Run both sweeps. One failing never skips the other.
Returns what was done, keyed by sweep; a sweep that did nothing is absent.
A sweep that raised is reported under ``errors``.
"""
summary: dict[str, Any] = {}
errors: list[str] = []
try:
from shelfmark.download import stale_torrents
removed = stale_torrents.run_for_shelfmark().removed
if removed:
summary["torrents_removed"] = list(removed)
except Exception:
logger.exception("Housekeeping: dead-torrent sweep failed")
errors.append("stale_torrents")
try:
if user_db is not None and history_service is not None:
from shelfmark.release_sources.slskd import leftovers
report = leftovers.run_with_backend(history_service=history_service, user_db=user_db)
slskd = {
name: list(items)
for name, items in (
("adopted", report.adopted),
("removed", report.removed),
("reattached", report.reattached),
("cancelled", report.cancelled),
)
if items
}
if slskd:
summary["slskd"] = slskd
except Exception:
logger.exception("Housekeeping: slskd leftovers sweep failed")
errors.append("slskd_leftovers")
if errors:
summary["errors"] = errors
return summary
def _loop() -> None:
"""Run a housekeeping cycle every interval for the life of the process.
A failed cycle is logged and the loop carries on.
"""
logger.info("Housekeeping thread started")
time.sleep(STARTUP_DELAY_SECONDS)
while True:
try:
summary = run_once(_ctx.get("user_db"), _ctx.get("history_service"))
if summary:
logger.info("Housekeeping: %s", summary)
except Exception:
logger.exception("Housekeeping: cycle failed")
time.sleep(INTERVAL_SECONDS)
def start(user_db: UserDB, history_service: DownloadHistoryService) -> None:
"""Start the housekeeping thread (idempotent)."""
global _thread
with _thread_lock:
_ctx["user_db"] = user_db
_ctx["history_service"] = history_service
if _thread is not None and _thread.is_alive():
logger.debug("Housekeeping already running")
return
_thread = threading.Thread(target=_loop, daemon=True, name="DownloadHousekeeping")
_thread.start()
+182 -125
View File
@@ -4,7 +4,8 @@ import random
import time
from http import HTTPStatus
from io import BytesIO
from typing import TYPE_CHECKING, NoReturn
from tempfile import SpooledTemporaryFile
from typing import TYPE_CHECKING, Literal, NoReturn, overload
from urllib.parse import parse_qsl, urlencode, urljoin, urlparse, urlunparse
import requests
@@ -873,6 +874,7 @@ def html_get_page(
)
@overload
def download_url(
link: str,
size: str = "",
@@ -881,8 +883,51 @@ def download_url(
_selector: network.AAMirrorSelector | None = None,
status_callback: Callable[[str, str | None], None] | None = None,
referer: str | None = None,
) -> BytesIO | None:
"""Download content from URL with automatic retry and resume support."""
spool_to_disk: Literal[False] = False,
) -> BytesIO | None: ...
@overload
def download_url(
link: str,
size: str = "",
progress_callback: Callable[[float], None] | None = None,
cancel_flag: Event | None = None,
_selector: network.AAMirrorSelector | None = None,
status_callback: Callable[[str, str | None], None] | None = None,
referer: str | None = None,
*,
spool_to_disk: Literal[True],
) -> SpooledTemporaryFile[bytes] | None: ...
@overload
def download_url(
link: str,
size: str,
progress_callback: Callable[[float], None] | None,
cancel_flag: Event | None,
_selector: network.AAMirrorSelector | None,
status_callback: Callable[[str, str | None], None] | None,
referer: str | None,
spool_to_disk: Literal[True],
) -> SpooledTemporaryFile[bytes] | None: ...
def download_url(
link: str,
size: str = "",
progress_callback: Callable[[float], None] | None = None,
cancel_flag: Event | None = None,
_selector: network.AAMirrorSelector | None = None,
status_callback: Callable[[str, str | None], None] | None = None,
referer: str | None = None,
spool_to_disk: bool = False,
) -> BytesIO | SpooledTemporaryFile[bytes] | None:
"""Download content from URL with automatic retry and resume support.
Returns a ``BytesIO``, or a ``SpooledTemporaryFile`` when ``spool_to_disk`` is set.
"""
selector = _selector or network.AAMirrorSelector()
current_url = selector.rewrite(link)
try:
@@ -904,134 +949,146 @@ def download_url(
if cancel_flag and cancel_flag.is_set():
return None
buffer = BytesIO()
buffer = (
SpooledTemporaryFile(max_size=8 * 1024 * 1024, mode="w+b") # noqa: SIM115 - caller owns successful downloads
if spool_to_disk
else BytesIO()
)
bytes_downloaded = 0
returned = False
try:
if attempt > 0 and status_callback:
status_callback(
"resolving",
f"Connecting (Attempt {attempt + 1}/{MAX_DOWNLOAD_RETRIES})",
)
try:
if attempt > 0 and status_callback:
status_callback(
"resolving",
f"Connecting (Attempt {attempt + 1}/{MAX_DOWNLOAD_RETRIES})",
)
logger.info("Downloading (attempt %s/%s)", attempt + 1, MAX_DOWNLOAD_RETRIES)
# Try with CF cookies/UA if available
cookies = _apply_cf_bypass(current_url, headers)
response = requests.get(
current_url,
stream=True,
proxies=get_proxies(current_url),
timeout=REQUEST_TIMEOUT,
cookies=cookies,
headers=headers,
verify=get_ssl_verify(current_url),
hooks=network.REDIRECT_GUARD_HOOKS,
)
response.raise_for_status()
if status_callback:
status_callback("downloading", "")
total_size = total_size or float(response.headers.get("content-length", 0))
pbar = tqdm(total=total_size, unit="B", unit_scale=True, desc="Downloading")
for chunk in response.iter_content(chunk_size=8192):
if chunk:
buffer.write(chunk)
bytes_downloaded += len(chunk)
pbar.update(len(chunk))
if progress_callback and total_size > 0:
progress_callback(bytes_downloaded * 100.0 / total_size)
if cancel_flag and cancel_flag.is_set():
pbar.close()
return None
pbar.close()
# Validate - check we didn't get HTML instead of file
if (
total_size > 0
and bytes_downloaded < total_size * 0.9
and response.headers.get("content-type", "").startswith("text/html")
):
logger.warning("Received HTML instead of file")
return None
logger.debug("Download completed: %s bytes", bytes_downloaded)
except requests.exceptions.RequestException as e:
status = _get_status_code(e)
retryable = _is_retryable_error(e)
# Z-Library 403 - try refreshing cookies via bypasser once before giving up
if (
status == _HTTP_STATUS_FORBIDDEN
and _is_cf_bypass_enabled()
and not zlib_cookie_refresh_attempted
):
parsed = urlparse(current_url)
if _is_configured_zlib_host(parsed.hostname) and referer:
zlib_cookie_refresh_attempted = True
logger.info("Z-Library 403 - refreshing cookies via referer")
try:
get_bypassed_page(referer, selector, cancel_flag)
time.sleep(0.5)
# Retry with fresh cookies (don't increment attempt)
continue
except _BYPASSER_ERRORS as cookie_err:
logger.warning(
"Z-Library cookie refresh failed: %s",
type(cookie_err).__name__,
)
# Non-retryable errors
if status in _HTTP_STATUS_NON_RETRYABLE:
logger.warning("Download failed (%s)", status)
return None
# Rate limited - skip to next source immediately
# (waiting doesn't help with concurrent downloads hitting the same server)
if status == _HTTP_STATUS_RATE_LIMITED:
network.note_rate_limited(current_url)
logger.info("Rate limited (429) - trying next source")
if status_callback:
status_callback("resolving", "Server busy, trying next")
return None
# Timeout - don't retry, server likely overloaded
if isinstance(e, requests.exceptions.Timeout):
logger.warning("Timeout - skipping to next source")
if status_callback:
status_callback("resolving", "Server timed out, trying next")
return None
# Try to resume if we got some data
if bytes_downloaded > 0 and retryable:
resumed = _try_resume(
logger.info("Downloading (attempt %s/%s)", attempt + 1, MAX_DOWNLOAD_RETRIES)
# Try with CF cookies/UA if available
cookies = _apply_cf_bypass(current_url, headers)
response = requests.get(
current_url,
buffer,
bytes_downloaded,
total_size,
progress_callback,
cancel_flag,
headers,
stream=True,
proxies=get_proxies(current_url),
timeout=REQUEST_TIMEOUT,
cookies=cookies,
headers=headers,
verify=get_ssl_verify(current_url),
hooks=network.REDIRECT_GUARD_HOOKS,
)
if resumed:
return resumed
response.raise_for_status()
# Try mirror/DNS rotation if nothing downloaded yet
if bytes_downloaded == 0 and retryable:
new_url = _try_rotation(link, current_url, selector)
if new_url:
current_url = new_url
attempt += 1
continue
if status_callback:
status_callback("downloading", "")
logger.warning("Download error: %s", type(e).__name__)
if attempt < MAX_DOWNLOAD_RETRIES - 1:
time.sleep(_backoff_delay(attempt + 1))
attempt += 1
else:
return buffer
total_size = total_size or float(response.headers.get("content-length", 0))
pbar = tqdm(total=total_size, unit="B", unit_scale=True, desc="Downloading")
for chunk in response.iter_content(chunk_size=8192):
if chunk:
buffer.write(chunk)
bytes_downloaded += len(chunk)
pbar.update(len(chunk))
if progress_callback and total_size > 0:
progress_callback(bytes_downloaded * 100.0 / total_size)
if cancel_flag and cancel_flag.is_set():
pbar.close()
return None
pbar.close()
# Validate - check we didn't get HTML instead of file
if (
total_size > 0
and bytes_downloaded < total_size * 0.9
and response.headers.get("content-type", "").startswith("text/html")
):
logger.warning("Received HTML instead of file")
return None
logger.debug("Download completed: %s bytes", bytes_downloaded)
except requests.exceptions.RequestException as e:
status = _get_status_code(e)
retryable = _is_retryable_error(e)
# Z-Library 403 - try refreshing cookies via bypasser once before giving up
if (
status == _HTTP_STATUS_FORBIDDEN
and _is_cf_bypass_enabled()
and not zlib_cookie_refresh_attempted
):
parsed = urlparse(current_url)
if _is_configured_zlib_host(parsed.hostname) and referer:
zlib_cookie_refresh_attempted = True
logger.info("Z-Library 403 - refreshing cookies via referer")
try:
get_bypassed_page(referer, selector, cancel_flag)
time.sleep(0.5)
# Retry with fresh cookies (don't increment attempt)
continue
except _BYPASSER_ERRORS as cookie_err:
logger.warning(
"Z-Library cookie refresh failed: %s",
type(cookie_err).__name__,
)
# Non-retryable errors
if status in _HTTP_STATUS_NON_RETRYABLE:
logger.warning("Download failed (%s)", status)
return None
# Rate limited - skip to next source immediately
# (waiting doesn't help with concurrent downloads hitting the same server)
if status == _HTTP_STATUS_RATE_LIMITED:
network.note_rate_limited(current_url)
logger.info("Rate limited (429) - trying next source")
if status_callback:
status_callback("resolving", "Server busy, trying next")
return None
# Timeout - don't retry, server likely overloaded
if isinstance(e, requests.exceptions.Timeout):
logger.warning("Timeout - skipping to next source")
if status_callback:
status_callback("resolving", "Server timed out, trying next")
return None
# Try to resume if we got some data
if bytes_downloaded > 0 and retryable:
resumed = _try_resume(
current_url,
buffer,
bytes_downloaded,
total_size,
progress_callback,
cancel_flag,
headers,
)
if resumed:
returned = True
return resumed
# Try mirror/DNS rotation if nothing downloaded yet
if bytes_downloaded == 0 and retryable:
new_url = _try_rotation(link, current_url, selector)
if new_url:
current_url = new_url
attempt += 1
continue
logger.warning("Download error: %s", type(e).__name__)
if attempt < MAX_DOWNLOAD_RETRIES - 1:
time.sleep(_backoff_delay(attempt + 1))
attempt += 1
else:
returned = True
return buffer
finally:
if not returned:
buffer.close()
logger.error("Download failed after %s attempts", MAX_DOWNLOAD_RETRIES)
return None
@@ -1057,13 +1114,13 @@ def _is_configured_zlib_host(hostname: str | None) -> bool:
def _try_resume(
url: str,
buffer: BytesIO,
buffer: BytesIO | SpooledTemporaryFile[bytes],
start_byte: int,
total_size: float,
progress_callback: Callable[[float], None] | None,
cancel_flag: Event | None,
base_headers: dict | None = None,
) -> BytesIO | None:
) -> BytesIO | SpooledTemporaryFile[bytes] | None:
"""Try to resume an interrupted download."""
for attempt in range(MAX_RESUME_ATTEMPTS):
logger.info(
+1 -1
View File
@@ -53,7 +53,7 @@ def should_bypass_proxy(url: str) -> bool:
parsed = urllib.parse.urlparse(url)
hostname = (parsed.hostname or "").lower()
except ValueError as e:
logger.debug("Failed to parse URL for proxy bypass check: %s - %s", url, e)
logger.debug("Failed to parse URL for proxy bypass check: %s", e)
return False
if not hostname:
+37 -6
View File
@@ -25,7 +25,7 @@ from shelfmark.core.request_helpers import (
)
from shelfmark.core.utils import is_audiobook as check_audiobook
from shelfmark.core.utils import transform_cover_url
from shelfmark.download.activity import STALL_TIMEOUT_SECONDS, parse_activity_grace
from shelfmark.download.activity import STALL_TIMEOUT_SECONDS, activity_key, parse_activity_grace
from shelfmark.download.fs import run_blocking_io
from shelfmark.download.postprocess.pipeline import is_torrent_source, safe_cleanup_path
from shelfmark.download.postprocess.router import post_process_download
@@ -665,8 +665,14 @@ def retry_persisted_download(
*,
final_status: object,
priority: int = -10,
allow_request_linked: bool = False,
) -> tuple[bool, str | None]:
"""Retry a persisted download row after the in-memory task has been lost."""
"""Retry a persisted download row after the in-memory task has been lost.
A request-linked download that failed is normally retried from its request, since the
request decides what to fetch next. ``allow_request_linked`` queues it anyway, for work
that is already done and only needs importing (a finished folder an interrupted run left).
"""
task = _restore_task_from_retry_payload(payload)
if task is None:
return False, "Download cannot be retried"
@@ -684,15 +690,21 @@ def retry_persisted_download(
if normalized_status in {"active", "cancelled"} and not has_fresh_retry_context:
return False, "Download cannot be retried"
if task.request_id is not None and normalized_status == "error" and not has_staged_retry_source:
if (
task.request_id is not None
and normalized_status == "error"
and not has_staged_retry_source
and not allow_request_linked
):
return False, "Request-linked downloads must be retried from requests"
if (
task.request_id is None
(task.request_id is None or allow_request_linked)
and normalized_status == "error"
and not has_staged_retry_source
and not has_fresh_retry_context
):
# The flag lifts the "retry it from the request" rule, never "nothing to restart from".
return False, "Download cannot be retried"
task.priority = priority
@@ -740,6 +752,8 @@ def _task_to_dict(
"username": task.username,
"request_id": task.request_id,
"retry_available": can_retry_download_task(task, retry_status),
"reattached": bool(task.reattached),
"finished_at": task.finished_at,
}
@@ -977,9 +991,16 @@ def update_download_status(book_id: str, status: str, message: str | None = None
with _progress_lock:
status_event = (status_key, message)
if _last_status_event.get(book_id) == status_event:
previous_event = _last_status_event.get(book_id)
if previous_event == status_event:
return
_last_activity[book_id] = time.time()
# A change only in the swarm note beside the progress is shown but is not activity.
if (
previous_event is None
or previous_event[0] != status_key
or activity_key(previous_event[1]) != activity_key(message)
):
_last_activity[book_id] = time.time()
_last_status_event[book_id] = status_event
# Update status message first so terminal snapshots capture the final message
@@ -1344,6 +1365,16 @@ _coordinator_thread: threading.Thread | None = None
_coordinator_lock = Lock()
def coordinator_running() -> bool:
"""Whether the download coordinator thread is alive.
The coordinator is the only thing that picks queued downloads up. If it dies, every task
stays "Queued" while the rest of the app looks healthy, so /api/health reports this.
"""
with _coordinator_lock:
return _coordinator_thread is not None and _coordinator_thread.is_alive()
def start() -> None:
"""Start the download coordinator thread. Safe to call multiple times."""
global _coordinator_thread
+10 -21
View File
@@ -17,15 +17,11 @@ circular imports (`archive` is used by the pipeline).
from __future__ import annotations
import shelfmark.core.config as core_config
def _normalize_format_list(value: object, default: list[str]) -> list[str]:
if isinstance(value, str):
return [fmt.strip().lower() for fmt in value.split(",") if fmt.strip()]
if isinstance(value, (list, tuple, set)):
normalized = [str(fmt).strip().lower() for fmt in value if str(fmt).strip()]
return normalized or default
return default
from shelfmark.core.formats import (
get_supported_audiobook_formats,
get_supported_formats,
get_supported_formats_for,
)
def _config_text(value: object) -> str:
@@ -34,18 +30,11 @@ def _config_text(value: object) -> str:
return ""
def get_supported_formats() -> list[str]:
"""Get current supported formats from config singleton."""
default_formats = ["epub", "mobi", "azw3", "fb2", "djvu", "cbz", "cbr"]
formats = core_config.config.get("SUPPORTED_FORMATS", default_formats)
return _normalize_format_list(formats, default_formats)
def get_supported_audiobook_formats() -> list[str]:
"""Get current supported audiobook formats from config singleton."""
default_formats = ["m4b", "mp3"]
formats = core_config.config.get("SUPPORTED_AUDIOBOOK_FORMATS", default_formats)
return _normalize_format_list(formats, default_formats)
__all__ = [
"get_supported_audiobook_formats",
"get_supported_formats",
"get_supported_formats_for",
]
def get_file_organization(*, is_audiobook: bool) -> str:
+1 -11
View File
@@ -13,10 +13,7 @@ from shelfmark.download.archive import ArchiveExtractionError, extract_archive,
from shelfmark.download.fs import run_blocking_io
from shelfmark.download.permissions_debug import log_path_permission_context
from shelfmark.download.postprocess.policy import (
get_supported_audiobook_formats,
)
from shelfmark.download.postprocess.policy import (
get_supported_formats as get_book_formats,
get_supported_formats_for as get_supported_formats,
)
from shelfmark.download.staging import build_staging_dir
@@ -28,13 +25,6 @@ if TYPE_CHECKING:
logger = setup_logger("shelfmark.download.postprocess.pipeline")
def get_supported_formats(content_type: str | None = None) -> list[str]:
"""Return supported file extensions for the requested content type."""
if check_audiobook(content_type):
return get_supported_audiobook_formats()
return get_book_formats()
def _format_not_supported_error(rejected_files: list[Path], task: DownloadTask) -> str:
content_type = task.content_type
file_type_label = "audiobook" if check_audiobook(content_type) else "book"
+166
View File
@@ -0,0 +1,166 @@
"""Remove torrents this instance added that are going nowhere and that nothing is watching.
A torrent outlives the task that added it: a restart, a stall timeout that left it in the client
because it had some data. It then holds a download slot and a partial copy for ever. A torrent that
is still downloading is none of this sweep's business; one that is stalled or stuck fetching its
metadata, with no activity for hours, and that no running task owns, is dead.
Ownership never rests on a category: only a torrent whose hash is in the ledger of torrents this
instance added (``torrent_ledger``) can be removed, and only when the task that added it is not
running. A torrent from before the ledger, or one somebody else put in the same category, is never
touched.
"""
from __future__ import annotations
import math
import time
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any
from shelfmark.core.config import config
from shelfmark.core.logger import setup_logger
from shelfmark.download import torrent_ledger
if TYPE_CHECKING:
from collections.abc import Callable
logger = setup_logger(__name__)
STALE_HOURS_SETTING = "STALE_TORRENT_HOURS"
STALE_HOURS_DEFAULT = 6.0
STALE_HOURS_MIN = 3.0
STALE_HOURS_MAX = 72.0
# qBittorrent states of a torrent that is not moving: no peers gave it data, or it never got the
# metadata. A queued torrent is waiting for a slot, not dead, and is left alone.
_STUCK_STATES = frozenset({"stalledDL", "metaDL"})
# Task states that mean a task is still working with its torrent.
_RUNNING_BUCKETS = frozenset({"queued", "resolving", "locating", "downloading"})
@dataclass
class StaleReport:
removed: list[str] = field(default_factory=list)
def stale_hours() -> float:
"""The idle limit in hours: 0 turns the sweep off, anything else is held to 3 through 72."""
raw = config.get(STALE_HOURS_SETTING, STALE_HOURS_DEFAULT)
try:
hours = float(raw) if isinstance(raw, (int, float, str)) else STALE_HOURS_DEFAULT
except ValueError:
return STALE_HOURS_DEFAULT
if not math.isfinite(hours):
# nan would pass every comparison below and turn the idle test off.
return STALE_HOURS_DEFAULT
if hours <= 0:
return 0.0
return min(max(hours, STALE_HOURS_MIN), STALE_HOURS_MAX)
def _ledger_hash(torrent: Any, ledger: dict[str, str]) -> str | None:
"""The ledger key this torrent answers to, matched on every identity the client exposes."""
from shelfmark.download.clients.qbittorrent import _torrent_matches_download_id
for known in ledger:
if _torrent_matches_download_id(torrent, known):
return known
return None
def find_stale(
torrents: list[Any],
*,
ledger: dict[str, str],
running_task_ids: set[str],
idle_hours: float,
now: float | None = None,
) -> list[tuple[str, Any]]:
"""``(ledger hash, torrent)`` for each torrent that is ours, stuck, idle and owned by nobody."""
if idle_hours <= 0:
return []
moment = time.time() if now is None else now
stale: list[tuple[str, Any]] = []
for torrent in torrents:
if float(getattr(torrent, "progress", 0.0) or 0.0) >= 1.0:
continue
if getattr(torrent, "state", "") not in _STUCK_STATES:
continue
last = float(getattr(torrent, "last_activity", 0) or 0)
if last <= 0:
last = float(getattr(torrent, "added_on", 0) or 0)
if last <= 0 or moment - last < idle_hours * 3600:
continue
known = _ledger_hash(torrent, ledger)
if known is None or ledger[known] in running_task_ids:
continue
stale.append((known, torrent))
return stale
def run(
client: Any,
*,
running_task_ids: Callable[[], set[str]],
now: float | None = None,
) -> StaleReport:
"""Remove the stale torrents (and their partial data) that this instance added."""
report = StaleReport()
idle = stale_hours()
if idle <= 0:
return report
ledger = torrent_ledger.entries()
if not ledger:
return report
torrents = client.list_torrents()
if torrents is None:
return report # qBittorrent did not answer: nothing is judged, nothing forgotten
# A torrent qBittorrent no longer lists is forgotten, even when it lists none at all, so
# the same torrent added later by someone else is never taken for one Shelfmark added.
listed = {_ledger_hash(t, ledger) for t in torrents}
torrent_ledger.forget(*(known for known in ledger if known not in listed))
for known, torrent in find_stale(
torrents,
ledger=ledger,
running_task_ids=running_task_ids(),
idle_hours=idle,
now=now,
):
if torrent_ledger.entries().get(known) != ledger[known]:
# A task claimed the torrent after the ledger was read (a retry, say): it is live.
continue
if client.remove(torrent.hash, delete_files=True):
torrent_ledger.forget(known)
report.removed.append(str(getattr(torrent, "name", torrent.hash)))
logger.info(
"Removed a dead torrent nothing is watching: %s (%.0f%%, %s)",
getattr(torrent, "name", torrent.hash),
float(torrent.progress) * 100,
torrent.state,
)
return report
def run_for_shelfmark(*, now: float | None = None) -> StaleReport:
"""``run`` against the configured qBittorrent."""
from shelfmark.download import orchestrator as backend
from shelfmark.download.clients import get_client
report = StaleReport()
if stale_hours() <= 0:
return report
client = get_client("torrent")
if client is None or not hasattr(client, "list_torrents"):
return report
def running_task_ids() -> set[str]:
"""Ids of the tasks the queue is running now, whose torrents the sweep leaves alone."""
ids: set[str] = set()
for bucket, tasks in backend.queue_status().items():
if bucket in _RUNNING_BUCKETS:
ids.update(str(task_id) for task_id in tasks)
return ids
return run(client, running_task_ids=running_task_ids, now=now)
+95
View File
@@ -0,0 +1,95 @@
"""The info hashes of the torrents this instance added, and which task added them.
The stale-torrent sweep may only remove a torrent Shelfmark itself put in the client: a category
says nothing about who else shares it. Every torrent an add returns a hash for is recorded here,
with the task that added it, and the sweep touches nothing that is not in the ledger. A torrent
added before the ledger existed is never in it, so it is never swept.
"""
from __future__ import annotations
import sqlite3
import time
from contextlib import closing
from typing import TYPE_CHECKING
from shelfmark.config.env import CONFIG_DIR
from shelfmark.core.logger import setup_logger
if TYPE_CHECKING:
from pathlib import Path
logger = setup_logger(__name__)
_SCHEMA = (
"CREATE TABLE IF NOT EXISTS torrent_ledger ("
"hash TEXT PRIMARY KEY, task_id TEXT NOT NULL, added_at REAL NOT NULL)"
)
def _path() -> Path:
return CONFIG_DIR / "torrent_ledger.db"
def _connect() -> sqlite3.Connection:
conn = sqlite3.connect(_path())
conn.execute(_SCHEMA)
return conn
def record(torrent_hash: str, task_id: str) -> None:
"""Note that ``task_id`` added the torrent. A ledger failure never fails the download."""
if not torrent_hash:
return
try:
with closing(_connect()) as conn, conn:
conn.execute(
"INSERT INTO torrent_ledger (hash, task_id, added_at) VALUES (?, ?, ?) "
"ON CONFLICT(hash) DO UPDATE SET task_id = excluded.task_id",
(torrent_hash.lower(), task_id, time.time()),
)
except (sqlite3.Error, OSError) as exc:
logger.warning("Could not record torrent %s in the ledger: %s", torrent_hash, exc)
def claim(torrent_hash: str, task_id: str) -> None:
"""A task joined a torrent that is already in the ledger: it owns it now.
A torrent the ledger does not hold stays out of it, so joining something Shelfmark did
not add never makes it sweepable.
"""
if not torrent_hash:
return
try:
with closing(_connect()) as conn, conn:
conn.execute(
"UPDATE torrent_ledger SET task_id = ? WHERE hash = ?",
(task_id, torrent_hash.lower()),
)
except (sqlite3.Error, OSError) as exc:
logger.warning("Could not update torrent %s in the ledger: %s", torrent_hash, exc)
def entries() -> dict[str, str]:
"""Every recorded hash with the id of the task that last owned it."""
try:
with closing(_connect()) as conn:
return {
str(row[0]): str(row[1])
for row in conn.execute("SELECT hash, task_id FROM torrent_ledger")
}
except (sqlite3.Error, OSError) as exc:
logger.warning("Could not read the torrent ledger: %s", exc)
return {}
def forget(*torrent_hashes: str) -> None:
"""Drop rows, once their torrent has been removed or is gone from the client."""
hashes = [(h.lower(),) for h in torrent_hashes if h]
if not hashes:
return
try:
with closing(_connect()) as conn, conn:
conn.executemany("DELETE FROM torrent_ledger WHERE hash = ?", hashes)
except (sqlite3.Error, OSError) as exc:
logger.warning("Could not update the torrent ledger: %s", exc)
Loaded 100 of 303 files, more files were not shown because too many files have changed in this diff. Show more