Compare commits

...
44 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
135 changed files with 14735 additions and 3818 deletions

No files matched your search

@@ -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 }}
+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@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
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@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
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@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3
with:
version: "0.11.3"
python-version: "3.14"
+3 -3
View File
@@ -25,15 +25,15 @@ jobs:
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Initialize CodeQL
uses: github/codeql-action/init@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # 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@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v3
uses: github/codeql-action/autobuild@1fc58d04a4d4374f921bdac573c3811c240f9c26 # v3
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # 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@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
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@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3
with:
python-version: "3.14"
enable-cache: true
@@ -1,37 +0,0 @@
name: Jules PR Review (forks)
# Fork PRs get no secrets under pull_request, so they are reviewed here under
# pull_request_target, which runs this file from main with the repo's secrets.
# That is safe only while nothing here checks out or runs the PR's code: the
# action reads the diff through the API. Upstream jules-pr-reviewer refuses
# this event, hence the calibrain fork.
# The jules-forks environment has no required reviewers on purpose, so fork
# pushes are reviewed straight away; adding reviewers there (Settings →
# Environments) would make each run wait for a maintainer's approval.
on:
pull_request_target:
types: [opened, synchronize, reopened, ready_for_review]
permissions: {}
concurrency:
# Not jules-review-: the same-repo workflow's skipped runs would cancel this one
group: jules-review-fork-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
review:
# Drafts are skipped here rather than by the action, so they never start a job
if: github.event.pull_request.head.repo.full_name != github.repository && !github.event.pull_request.draft
runs-on: ubuntu-latest
environment:
name: jules-forks
deployment: false
permissions:
pull-requests: write
contents: read
checks: write
steps:
- uses: calibrain/jules-pr-reviewer@v0.0.3
with:
jules_api_key: ${{ secrets.JULES_API_KEY }}
github_token: ${{ secrets.GITHUB_TOKEN }}
skip_forks: false
-27
View File
@@ -1,27 +0,0 @@
name: Jules PR Review
# Reviews PRs whose branch lives in this repo. Fork PRs get no secrets under
# pull_request and Dependabot runs get only Dependabot secrets, so
# JULES_API_KEY would be empty for both; jules-pr-reviewer-forks.yml reviews
# fork PRs instead.
on:
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
permissions: {}
concurrency:
group: jules-review-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
review:
if: github.event.pull_request.head.repo.full_name == github.repository && github.actor != 'dependabot[bot]'
runs-on: ubuntu-latest
permissions:
pull-requests: write
contents: read
checks: write
steps:
- uses: calibrain/jules-pr-reviewer@v0.0.3
with:
jules_api_key: ${{ secrets.JULES_API_KEY }}
github_token: ${{ secrets.GITHUB_TOKEN }}
+4 -16
View File
@@ -28,7 +28,7 @@ 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.13.6@sha256:af22a52f5eafef3d78e551599d405c02df61919d18028f0cea8eda93bc9986d0 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.9-slim@sha256:35caba83af2fe6da9a5d922f9b0156f21d1246a48278c58a08bd253303ba42e8 AS base
@@ -148,28 +148,16 @@ ENTRYPOINT ["/usr/bin/dumb-init", "--"]
FROM base AS shelfmark
# --- Chromium (PINNED to 156.0.8078.73) ---
ARG CHROMIUM_VERSION=156.0.8078.73-1~deb13u1
ARG CHROMIUM_SNAPSHOT=20261029T020000Z
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
+1
View File
@@ -4,4 +4,5 @@ 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
+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).
+46 -4
View File
@@ -1127,7 +1127,7 @@ Custom label for the OIDC sign-in button on the login page.
| `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 synced requests. | boolean | `false` |
| `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` |
@@ -1204,7 +1204,7 @@ Whether the sync interval is measured in minutes or hours.
**Enable automatic downloads**
Auto-approve and auto-download matching releases for synced requests.
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`
@@ -1904,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>
@@ -1975,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)**
@@ -2005,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**
@@ -2311,7 +2342,7 @@ How long to keep cached search results before they expire.
| `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_ |
| `TRANSMISSION_CATEGORY` | Label to assign to book downloads in Transmission | string | `books` |
@@ -2343,6 +2374,7 @@ How long to keep cached search results before they expire.
| `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` |
@@ -2516,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_
@@ -2804,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**
+5 -3
View File
@@ -21,6 +21,8 @@ A book is skipped when:
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*;
@@ -41,7 +43,7 @@ A request whose book turns out to be in your library already is closed, not left
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, 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 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.
@@ -74,7 +76,7 @@ The check **fails closed** here: if an enabled library cannot be read and has no
| `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 every pending Hardcover request, synced or made by a user with Request Book. The download belongs to the user who asked, and admins see every user's requests |
| `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 |
@@ -108,7 +110,7 @@ Only one run happens at a time. If a scheduled cycle is in progress, **Sync now*
**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. Lowering **Minimum seeders** helps on quiet trackers; loosening the title or author match is deliberately not an option.
**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.
+2
View File
@@ -12,6 +12,8 @@ Shelfmark writes its log to the container output, so `docker logs shelfmark` sho
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.
+16 -1
View File
@@ -69,6 +69,10 @@ Admins can override the default ebook/audiobook modes and request rules for indi
---
## 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.
@@ -94,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
@@ -115,4 +120,14 @@ Admins can override the default ebook/audiobook modes and request rules for indi
4. For fulfilled requests, delivery state is tracked through the download pipeline
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
+5 -5
View File
@@ -21,22 +21,22 @@ dependencies = [
"rarfile",
"qbittorrent-api>=2026.10.1",
"transmission-rpc",
"authlib>=1.9.1,<1.10",
"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.6.8",
"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.2.20261023",
"publicsuffixlist>=1.1.2.20261105",
]
[project.optional-dependencies]
browser = [
"pyvirtualdisplay",
"pyautogui",
"seleniumbase==4.56.4",
"seleniumbase==4.57.1",
"python-xlib",
]
@@ -47,7 +47,7 @@ dev = [
"pytest",
"pytest-cov",
"pytest-xdist>=3.8.0",
"ruff==0.17.3",
"ruff==0.17.4",
"vulture>=2.14",
]
+15 -1
View File
@@ -153,7 +153,7 @@ Some of the additional options available in Settings:
- **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
- **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
@@ -324,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.
+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"
+7 -2
View File
@@ -141,7 +141,8 @@ def hardcover_sync_settings() -> list[SettingsField]:
title="Automatic Downloads",
description=(
"When enabled, pending Hardcover requests (synced from a shelf, or made by a "
"user with Request Book) are auto-approved and downloaded from the "
"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 "
@@ -151,7 +152,11 @@ def hardcover_sync_settings() -> list[SettingsField]:
CheckboxField(
key="AUTO_DOWNLOAD_ENABLED",
label="Enable automatic downloads",
description="Auto-approve and auto-download matching releases for Hardcover requests.",
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(
+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.
+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,
}
)
+133 -15
View File
@@ -31,9 +31,19 @@ from shelfmark.core.config import config as app_config
from shelfmark.core.logger import setup_logger
from shelfmark.core.models import QueueStatus
from shelfmark.core.release_search import search_source_releases
from shelfmark.core.request_helpers import coerce_int
from shelfmark.core.request_policy import normalize_content_type
from shelfmark.core.request_helpers import (
coerce_bool,
coerce_int,
load_users_request_policy_settings,
)
from shelfmark.core.request_policy import (
PolicyMode,
merge_request_policy_settings,
normalize_content_type,
resolve_policy_mode,
)
from shelfmark.core.request_validation import RequestStatus
from shelfmark.core.status_messages import client_problem
from shelfmark.core.text_match import (
DEFAULT_TITLE_MATCH_THRESHOLD,
PACKAGING_MARKERS,
@@ -56,7 +66,7 @@ from shelfmark.download.postprocess.policy import (
)
if TYPE_CHECKING:
from collections.abc import Callable
from collections.abc import Callable, Mapping
from shelfmark.core.user_db import UserDB
from shelfmark.metadata_providers import BookMetadata
@@ -1021,15 +1031,6 @@ def _latest_history(db_path: str, request_ids: list[int]) -> dict[int, tuple[str
return latest
# What a download records when its client could not be reached. The release is not to blame.
_CLIENT_UNREACHABLE = (
"failed to connect",
"cannot connect",
"connection refused",
"connection aborted",
)
def _failure_kind(latest: tuple[str, str | None] | None) -> str | None:
"""Why a request whose delivery never completed never arrived, from its latest history row.
@@ -1045,8 +1046,9 @@ def _failure_kind(latest: tuple[str, str | None] | None) -> str | None:
return _INTERRUPTED
if final_status == "error":
lowered = (message or "").strip().lower()
if lowered == _INTERRUPTED_MESSAGE or any(m in lowered for m in _CLIENT_UNREACHABLE):
# Nothing was wrong with the release: the process stopped, or its client was down.
if lowered == _INTERRUPTED_MESSAGE or client_problem(lowered):
# Nothing was wrong with the release: the process stopped, or its client was down
# or refused the login.
return _INTERRUPTED
return _FAILED
if final_status == "cancelled" and (message or "").strip().lower().startswith(
@@ -1169,6 +1171,7 @@ def reopen_stale_failures(
failure_cooldown: timedelta | None = None,
only_kinds: frozenset[str] | None = None,
reopened_ids: list[int] | None = None,
owner_policy: OwnerPolicy | None = None,
) -> int:
"""Return synced requests whose download never arrived to pending once the cooldown passes.
@@ -1188,6 +1191,9 @@ def reopen_stale_failures(
skipped next time, and is given up on after ``MAX_FAILURE_RETRIES``. Other cancelled requests
and rejected ones are decisions and are never touched. Returns how many were reopened; ``cooldown_days`` of 0 or less turns retries off,
and without readable history nothing is retried, since guessing would be worse.
A request auto-download could not fulfil on its owner's behalf (see ``OwnerPolicy``) is
not reopened either: nothing would pick it up again without an admin.
"""
if cooldown_days <= 0 and failure_cooldown is None:
return 0
@@ -1222,11 +1228,14 @@ def reopen_stale_failures(
return 0
stale.sort(key=lambda item: item[0])
policy = owner_policy or OwnerPolicy(user_db)
reopened = 0
for _changed, row, state in stale:
if reopened >= max_reopen:
break
request_id = int(row["id"])
if not policy.may_fulfil(row):
continue
kind = _failure_kind(history.get(request_id))
if kind is None or (state == _CANCELLED and kind != _STALLED):
continue
@@ -1315,6 +1324,95 @@ def _configured_source_priority(content_type: str) -> list[str]:
return [src["name"] for src in available if src.get("enabled")]
class OwnerPolicy:
"""What a request's owner could download without asking, read once per pass.
Auto-download fulfils a request as an admin, so it may only do what the owner could have
done alone: the same check the download route makes before it queues a release. An admin,
an install without login, or a user for whom requests are turned off downloads directly
from any source. Anyone else may only use the sources whose resolved policy mode is
``download``. A request with no such source is left exactly as it is, pending for an
admin, like any other request awaiting review.
"""
def __init__(self, user_db: UserDB, *, auth_mode: str | None = None) -> None:
"""Read nothing yet.
The policy settings and each owner are loaded on first use and kept for the pass.
"""
from shelfmark.core.auth_modes import AUTH_MODE_NONE, load_active_auth_mode
self._user_db = user_db
self._bypass = (auth_mode or load_active_auth_mode()) == AUTH_MODE_NONE
self._global: Mapping[str, Any] | None = None
self._owners: dict[int, tuple[bool, dict[str, Any]] | None] = {}
def _owner(self, user_id: int) -> tuple[bool, dict[str, Any]] | None:
"""(owner may skip the policy, their settings), or None when they cannot be read."""
if user_id not in self._owners:
try:
user = self._user_db.get_user(user_id=user_id)
settings = self._user_db.get_user_settings(user_id) if user else {}
except sqlite3.Error as exc:
logger.warning("auto-download: could not read user %s: %s", user_id, exc)
user, settings = None, {}
if user is None:
self._owners[user_id] = None
else:
if self._global is None:
self._global = load_users_request_policy_settings()
effective = merge_request_policy_settings(self._global, settings)
exempt = user.get("role") == "admin" or not coerce_bool(
effective.get("REQUESTS_ENABLED"), default=False
)
self._owners[user_id] = (exempt, settings)
return self._owners[user_id]
def download_sources(
self, row: dict[str, Any], content_type: str, sources: list[str]
) -> list[str]:
"""The sources in ``sources`` the owner of ``row`` may download from directly."""
if self._bypass:
return list(sources)
user_id = coerce_int(row.get("user_id"), 0)
owner = self._owner(user_id) if user_id > 0 else None
if owner is None:
return []
exempt, settings = owner
if exempt:
return list(sources)
return [
source
for source in sources
if resolve_policy_mode(
source=source,
content_type=content_type,
global_settings=self._global,
user_settings=settings,
)
== PolicyMode.DOWNLOAD
]
def may_fulfil(self, row: dict[str, Any]) -> bool:
"""True when auto-download could fulfil ``row`` from at least one configured source."""
if self._bypass:
return True
user_id = coerce_int(row.get("user_id"), 0)
owner = self._owner(user_id) if user_id > 0 else None
if owner is None:
return False
if owner[0]:
return True
book_data = row.get("book_data")
content_type = normalize_content_type(
row.get("content_type")
or (book_data.get("content_type") if isinstance(book_data, dict) else None)
)
return bool(
self.download_sources(row, content_type, _configured_source_priority(content_type))
)
def _acted_on_since_listed(user_db: UserDB, request_id: int) -> bool:
"""True only when the request is read back and is positively no longer waiting.
@@ -1636,6 +1734,7 @@ def retry_failed_downloads(
if minutes <= 0 or not bool(app_config.get("AUTO_DOWNLOAD_ENABLED", False)):
return None
reopened_ids: list[int] = []
owner_policy = OwnerPolicy(user_db)
reopen_stale_failures(
user_db,
cooldown_days=0,
@@ -1644,6 +1743,7 @@ def retry_failed_downloads(
failure_cooldown=timedelta(minutes=minutes),
only_kinds=frozenset({_FAILED, _STALLED}),
reopened_ids=reopened_ids,
owner_policy=owner_policy,
)
# A failed download the status poll already reopened is pending with no release; those
# are searched here as well, so they are not left for the next full pass.
@@ -1692,6 +1792,10 @@ def auto_download_pending(
priority list, format guard and library check all follow the request. Returns a summary
count dict. No-ops (returns zeros) unless AUTO_DOWNLOAD_ENABLED. ``admin_user_id``
defaults to the lowest-id admin, who fulfils the requests.
A request is only searched from the sources its owner could download from directly
(``OwnerPolicy``); one with none is left untouched for an admin to review and counted
under ``awaiting_approval``.
"""
zeros = {"queued": 0, "no_match": 0, "in_library": 0, "skipped": 0, "error": 0}
if not bool(app_config.get("AUTO_DOWNLOAD_ENABLED", False)):
@@ -1705,6 +1809,7 @@ def auto_download_pending(
logger.warning("auto-download: no admin user exists to fulfil requests")
return zeros
owner_policy = OwnerPolicy(user_db)
retry_days = coerce_int(
app_config.get(RETRY_DAYS_SETTING, DEFAULT_RETRY_DAYS), DEFAULT_RETRY_DAYS
)
@@ -1712,7 +1817,11 @@ def auto_download_pending(
0
if only_request_ids is not None
else reopen_stale_failures(
user_db, cooldown_days=retry_days, db_path=db_path, provider_filter=provider_filter
user_db,
cooldown_days=retry_days,
db_path=db_path,
provider_filter=provider_filter,
owner_policy=owner_policy,
)
)
if reopened:
@@ -1764,6 +1873,15 @@ def auto_download_pending(
if not sources:
summary["skipped"] += 1
continue
# Only what the owner could download without asking; the rest waits for an admin.
sources = owner_policy.download_sources(row, content_type, sources)
if not sources:
logger.debug(
"auto-download: request %s needs an admin's approval under its owner's policy",
row.get("id"),
)
summary["awaiting_approval"] = summary.get("awaiting_approval", 0) + 1
continue
if not _search_is_due(book_data, datetime.now(UTC), backoff_days):
logger.debug(
@@ -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)
+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.
+9 -7
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)
@@ -178,15 +179,15 @@ class BookQueue:
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:
@@ -339,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
+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
+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)
+30 -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
@@ -271,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)
@@ -541,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)
@@ -571,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")
@@ -700,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),
)
+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"})
+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()
+107 -2
View File
@@ -121,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
);
"""
@@ -132,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")
@@ -919,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()
@@ -951,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
@@ -958,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 = ?",
@@ -974,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,
@@ -1011,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,),
+4 -2
View File
@@ -662,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 {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
@@ -15,6 +15,7 @@ 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 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,
@@ -1149,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
@@ -1194,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:
@@ -1237,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
+4 -1
View File
@@ -740,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
@@ -749,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)
+52
View File
@@ -34,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:
@@ -45,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
@@ -103,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,
@@ -302,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})"
@@ -321,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}"
@@ -350,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)
@@ -608,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
+4 -2
View File
@@ -517,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
+21 -1
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"},
),
@@ -1120,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",
+4 -1
View File
@@ -528,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(
@@ -538,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:
+3 -1
View File
@@ -382,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(
+17 -3
View File
@@ -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,
}
+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)
+127 -3140
View File
File diff suppressed because it is too large. Load diff
@@ -840,7 +840,12 @@ def _fetch_download_count_inline(book_id: str) -> int | None:
return cached
try:
url = f"{network.get_aa_base_url()}/dyn/md5/inline_info/{book_id}"
resp = _inline_info_session.get(url, timeout=5, headers={"Accept": "application/json"})
resp = _inline_info_session.get(
url,
timeout=5,
headers={"Accept": "application/json"},
proxies=network.get_proxies(url),
)
if resp.status_code == 200:
data = resp.json()
count = data.get("downloads_total")
+34
View File
@@ -399,6 +399,10 @@ class SlskdClient:
data = self._json(response)
return _flatten_transfers(data)
def list_all_downloads(self) -> list[dict[str, Any]]:
"""Return every download transfer slskd tracks, for every user, flattened."""
return _flatten_transfers(self._json(self._request("GET", "transfers/downloads")))
def get_transfer(self, username: str, transfer_id: str) -> dict[str, Any] | None:
"""Return one transfer record, or None when slskd no longer tracks it."""
try:
@@ -427,6 +431,36 @@ class SlskdClient:
raise
return True
# ── private messages ──────────────────────────────────────────────────────
def list_conversations(self) -> list[dict[str, Any]]:
"""Return slskd's private-message conversations (no message bodies)."""
data = self._json(self._request("GET", "conversations"))
return [c for c in data if isinstance(c, dict)] if isinstance(data, list) else []
def get_conversation(self, username: str) -> dict[str, Any] | None:
"""Return one conversation with its messages, or None when slskd has none."""
try:
response = self._request(
"GET",
f"conversations/{quote(username, safe='')}",
params={"includeMessages": "true"},
)
except requests.exceptions.HTTPError as e:
if e.response is not None and e.response.status_code == _HTTP_NOT_FOUND:
return None
raise
data = self._json(response)
return data if isinstance(data, dict) else None
def send_private_message(self, username: str, message: str) -> None:
"""Send a private message; slskd takes the body as a bare JSON string."""
self._request("POST", f"conversations/{quote(username, safe='')}", json=message)
def acknowledge_conversation(self, username: str) -> None:
"""Mark every message in a conversation as read."""
self._request("PUT", f"conversations/{quote(username, safe='')}")
def _flatten_transfers(data: Any) -> list[dict[str, Any]]:
"""Flatten slskd's ``{username, directories: [{directory, files: [...]}]}`` shape."""
@@ -0,0 +1,219 @@
"""Answer anti-leech challenges that Soulseek uploaders send as private messages.
Some uploaders run bots that block your downloads until you type a token in a
private message ("please type 'download' in this chat to be added to my
whitelist"). The downloads then sit queued until someone answers.
The challenge patterns and the one-reply-per-cooldown idea come from SoulSync's
``core/chat_autoprove.py`` (https://github.com/Nezreka/SoulSync, commit 445473b, MIT license,
by Nezreka), reworked here for slskd's client and Shelfmark's download handler.
Deliberately conservative:
- private messages only; only inbound, unacknowledged messages can trigger a reply
- the token comes from the message itself and must match ``[A-Za-z0-9_-]{1,24}``
- only the known phrasings below match; anything else is ignored
- one reply per user per cooldown window (bots repeat their challenge)
- the cooldown is recorded only after the reply was actually sent
- every reply is logged at INFO so the operator can see it
"""
from __future__ import annotations
import re
import threading
from typing import TYPE_CHECKING, Any
import requests
from shelfmark.core.logger import setup_logger
from shelfmark.release_sources.slskd.api import SlskdError
if TYPE_CHECKING:
from collections.abc import Callable
from shelfmark.release_sources.slskd.api import SlskdClient
logger = setup_logger(__name__)
SCAN_INTERVAL_SECONDS = 60.0
# A scan runs inside a download's poll loop, so it stops reading conversations after this long
# and leaves the rest for the next pass.
SCAN_BUDGET_SECONDS = 10.0
COOLDOWN_SECONDS = 12 * 3600.0
_ERRORS = (SlskdError, requests.exceptions.RequestException)
_WORD = r"[A-Za-z0-9_-]{1,24}"
_PATTERNS = (
# please type "human" in this chat
re.compile(rf"""type\s+["']?({_WORD})["']?\s+in\s+(?:this\s+)?chat""", re.IGNORECASE),
# reply with "human" to be whitelisted
re.compile(rf"""reply\s+(?:with\s+)?["']?({_WORD})["']?\s+to\s+(?:be|get)""", re.IGNORECASE),
# say "human" in chat / say human to be added (not "I'd say thanks to everyone")
re.compile(
rf"""say\s+["']?({_WORD})["']?\s+(?:in\s+(?:this\s+|the\s+)?chat|to\s+(?:be|get))\b""",
re.IGNORECASE,
),
)
def extract_token(text: object) -> str | None:
"""Return the token a challenge message demands, or None when it is not one."""
body = str(text or "")
for pattern in _PATTERNS:
match = pattern.search(body)
if match:
return match.group(1)
return None
def _is_unread_inbound(message: Any) -> bool:
"""Whether a chat message came from the peer and has not been acknowledged yet."""
if not isinstance(message, dict):
return False
if str(message.get("direction") or "").lower() != "in":
return False
return not message.get("acknowledged", message.get("isAcknowledged", False))
def _has_unread(conversation: dict[str, Any]) -> bool:
"""Whether slskd marks a conversation as having unread messages, by flag or by count."""
count = conversation.get("unAcknowledgedMessageCount")
return bool(conversation.get("hasUnAcknowledgedMessages")) or (
isinstance(count, int) and count > 0
)
def _resume_order(
conversations: list[dict[str, Any]], progress: dict[str, str] | None
) -> list[dict[str, Any]]:
"""The conversations, starting after the one the last pass read last and wrapping round."""
last = (progress or {}).get("last")
names = [str(c.get("username") or "") for c in conversations]
if not last or last not in names:
return list(conversations)
start = names.index(last) + 1
return list(conversations[start:]) + list(conversations[:start])
def scan_and_respond(
client: SlskdClient,
state: dict[str, float],
*,
now: float,
cooldown: float = COOLDOWN_SECONDS,
clock: Callable[[], float] | None = None,
budget: float | None = None,
progress: dict[str, str] | None = None,
) -> list[tuple[str, str]]:
"""Run one pass and return the ``(username, token)`` replies sent.
``state`` maps a username to the time of the last reply and is owned by the
caller. A failure on one conversation does not stop the others, but slskd timing
out or refusing the connection ends the pass, and with ``clock`` and ``budget`` no
further conversation is read once ``budget`` seconds have passed since ``now``.
``progress`` (owned by the caller) remembers the last conversation read, and the next pass
starts after it and wraps round, so unread conversations that are not challenges cannot
use up every pass's budget ahead of one that is.
"""
replies: list[tuple[str, str]] = []
for conversation in _resume_order(client.list_conversations(), progress):
username = str(conversation.get("username") or "")
if not username or not _has_unread(conversation):
continue
last = state.get(username)
if last is not None and now - last < cooldown:
continue
if clock is not None and budget is not None and clock() - now >= budget:
logger.debug("slskd chat challenge: scan budget spent, the rest waits a pass")
break
if progress is not None:
progress["last"] = username
try:
detail = client.get_conversation(username) or {}
token = None
for message in detail.get("messages") or []:
if _is_unread_inbound(message):
token = extract_token(message.get("message"))
if token:
break
if not token:
continue
client.send_private_message(username, token)
except (requests.exceptions.Timeout, requests.exceptions.ConnectionError) as e:
logger.warning("slskd chat challenge: slskd did not answer, scan stopped: %s", e)
break
except _ERRORS as e:
logger.warning("slskd chat challenge: reply to %s failed: %s", username, e)
continue
state[username] = now
logger.info("slskd chat challenge: answered %r to %s", token, username)
replies.append((username, token))
try:
client.acknowledge_conversation(username)
except _ERRORS as e:
logger.debug("slskd chat challenge: acknowledge for %s failed: %s", username, e)
return replies
class ChatAutoProver:
"""Runs ``scan_and_respond`` at most once per interval, from whoever calls it.
The slskd handler already loops while a download is queued, so it calls
``maybe_scan`` each iteration; no thread or scheduler of its own. Only one scan runs
at a time: a caller that arrives while another thread is scanning returns at once.
"""
def __init__(
self,
*,
interval: float = SCAN_INTERVAL_SECONDS,
cooldown: float = COOLDOWN_SECONDS,
) -> None:
self._interval = interval
self._cooldown = cooldown
self._lock = threading.Lock()
self._last_scan: float | None = None
self._scanning = False
self._state: dict[str, float] = {}
self._progress: dict[str, str] = {}
def maybe_scan(
self,
get_client: Callable[[], SlskdClient | None],
clock: Callable[[], float],
) -> list[tuple[str, str]]:
"""Scan when the interval has passed; never raises, quiet when slskd is down."""
now = clock()
with self._lock:
if self._scanning:
return []
if self._last_scan is not None and now - self._last_scan < self._interval:
return []
self._last_scan = now
self._scanning = True
try:
client = get_client()
if client is None:
return []
return scan_and_respond(
client,
self._state,
now=now,
cooldown=self._cooldown,
clock=clock,
budget=SCAN_BUDGET_SECONDS,
progress=self._progress,
)
except _ERRORS as e:
logger.debug("slskd chat challenge: scan skipped: %s", e)
return []
except Exception: # a side feature must never stop the download polling it
logger.exception("slskd chat challenge: unexpected scan error")
return []
finally:
with self._lock:
self._scanning = False
@@ -47,6 +47,7 @@ from shelfmark.release_sources.slskd.api import (
transfer_is_complete,
transfer_succeeded,
)
from shelfmark.release_sources.slskd.chat_autoprove import ChatAutoProver
from shelfmark.release_sources.slskd.source import (
SOURCE_NAME,
build_client_from_config,
@@ -63,6 +64,9 @@ if TYPE_CHECKING:
logger = setup_logger(__name__)
POLL_INTERVAL = 2.0
# Shared by every download so concurrent polls still scan private messages once a minute.
_CHAT_AUTOPROVER = ChatAutoProver()
COMPLETED_PATH_RETRY_INTERVAL = _DEFAULT_COMPLETED_PATH_RETRY_INTERVAL
COMPLETED_PATH_MAX_ATTEMPTS = _DEFAULT_COMPLETED_PATH_MAX_ATTEMPTS
# Polls with the transfer missing from slskd before giving up (~30s at POLL_INTERVAL).
@@ -332,6 +336,19 @@ def _safe_name(value: str) -> str:
return re.sub(r"[^A-Za-z0-9._-]+", "_", value or "").strip("._") or "download"
def folder_maps_back(task_id: str) -> bool:
"""Whether a task's own folder name leads back to this task id and no other.
The folder is ``_safe_name(task_id)``, and the leftovers sweep reads the id back by turning
the ``slskd_`` prefix into ``slskd:``. Search ids (``slskd:`` and hex) always map back. An id
sent in a queued payload may not: ``slskd:ab+cd`` would land in ``slskd_ab_cd``, which the
sweep would read as ``slskd:ab_cd``, find nothing recorded, and remove.
"""
if not task_id.startswith("slskd:"):
return False
return _safe_name(task_id) == "slskd_" + task_id[len("slskd:") :]
def _clean_prefix(raw: object) -> str:
"""The folder, relative to slskd's downloads folder, that holds Shelfmark's downloads."""
text = str(raw or "").strip().replace("\\", "/").strip("/")
@@ -391,6 +408,23 @@ def _discard_folder(folder: Path) -> None:
logger.debug("Could not remove slskd folder %s: %s", folder, e)
# Public names for the leftovers sweep, which reads the same settings and folder rules.
def isolation_enabled() -> bool:
return _bool_setting("SLSKD_ISOLATE_DOWNLOADS", True)
def destination_prefix() -> str:
return _clean_prefix(config.get("SLSKD_DESTINATION_PREFIX", DEFAULT_DESTINATION_PREFIX))
def bool_setting(key: str, default: bool) -> bool:
return _bool_setting(key, default)
def folder_complete(folder: Path, expected_sizes: list[int]) -> bool:
return _folder_complete(folder, expected_sizes)
def _completed_remote_files(events: list[dict[str, Any]]) -> set[str]:
"""Remote filenames slskd's stored events say finished downloading."""
done: set[str] = set()
@@ -469,6 +503,11 @@ class SlskdHandler(DownloadHandler):
def _get_client(self) -> SlskdClient | None:
return build_client_from_config()
def _answer_chat_challenges(self, client: SlskdClient) -> None:
"""Reply to anti-leech bots that block downloads until they get a chat message."""
if _bool_setting("SLSKD_CHAT_AUTOPROVE", False):
_CHAT_AUTOPROVER.maybe_scan(lambda: client, time.monotonic)
# ── download ──────────────────────────────────────────────────────────────
def download(
@@ -501,6 +540,11 @@ class SlskdHandler(DownloadHandler):
return None
if self._isolate_enabled():
if not folder_maps_back(task.task_id):
# Its folder would read back as another id, and the sweep could remove it.
status_callback("error", "This release's id cannot name its own download folder")
logger.warning("slskd task id %r does not map back from its folder", task.task_id)
return None
outcome = self._download_isolated(
task, spec, client, cancel_flag, progress_callback, status_callback
)
@@ -580,6 +624,10 @@ class SlskdHandler(DownloadHandler):
progress_callback: Callable[[float], None],
status_callback: Callable[[str, str | None], None],
) -> _TransferSnapshot | None:
"""Follow a transfer until it finishes, fails, is cancelled or times out in the queue.
Answers chat challenges on the way when that is switched on.
"""
poll_interval = self._poll_interval()
queue_timeout = self._queue_timeout_seconds()
started = self._clock()
@@ -589,6 +637,7 @@ class SlskdHandler(DownloadHandler):
try:
while not cancel_flag.is_set():
self._answer_chat_challenges(client)
try:
transfers = client.list_downloads(spec.username)
except _SLSKD_ERRORS as e:
@@ -929,6 +978,10 @@ class SlskdHandler(DownloadHandler):
progress_callback: Callable[[float], None],
status_callback: Callable[[str, str | None], None],
) -> str | None:
"""Follow an isolated download's files until they are all in its folder.
Returns None on success, else the reason it failed.
"""
poll_interval = self._poll_interval()
queue_timeout = self._queue_timeout_seconds()
started = self._clock()
@@ -940,6 +993,7 @@ class SlskdHandler(DownloadHandler):
try:
while not cancel_flag.is_set():
self._answer_chat_challenges(client)
now = self._clock()
records: dict[str, dict[str, Any] | None] = {}
try:
@@ -0,0 +1,578 @@
"""Folders slskd left under Shelfmark's downloads folder: finish the whole ones, drop the dead ones.
Each download gets a folder named after its task id. A restart can land between "the last file
arrived" and "the files were copied to the library", leaving a finished folder nothing will ever
import, and a failed or abandoned download leaves a partial one. Both sit in slskd's folder
(and are shared with other Soulseek users) until someone looks.
A whole folder whose download never completed is queued again through the ordinary retry; the
handler sees the files are already there and goes straight to the import. It is queued once:
if that import fails too, the folder stays, since the files may not be had again, until a
person retries the download or its request is done with (delivered another way, or dropped).
A partial folder, or the folder of a cancelled download, is removed once its download is old
enough that nothing is going to resume it. Running downloads keep their folders, and nothing
is removed while clean-up is off.
"""
from __future__ import annotations
import json
import shutil
import time
from dataclasses import dataclass, field
from datetime import UTC, datetime, timedelta
from typing import TYPE_CHECKING, Any
from shelfmark.core.config import config
from shelfmark.core.logger import setup_logger
from shelfmark.core.request_helpers import coerce_int
from shelfmark.core.startup_reconcile import INTERRUPTED_MESSAGE
from shelfmark.download.fs import run_blocking_io
if TYPE_CHECKING:
from pathlib import Path
from shelfmark.core.download_history_service import DownloadHistoryService
from shelfmark.core.user_db import UserDB
logger = setup_logger(__name__)
FINISH_SETTING = "SLSKD_FINISH_LEFTOVERS"
CLEAN_DAYS_SETTING = "SLSKD_CLEAN_LEFTOVERS_DAYS"
# Folders of a request that already has its book, or that was given up on, go after this long.
DELIVERED_GRACE = timedelta(days=1)
# At most this many downloads are queued per run by each loop (finished folders, transfers picked
# up again), counting only the ones that were queued, so a backlog does not flood the queue.
MAX_ADOPTIONS_PER_RUN = 3
# Left in a folder once it has been tried for import, queued or not. Hidden, so it never counts as
# a file of the download. A folder is not tried again every run: it waits for a person to retry
# the download, or for its request to be done with.
ADOPTED_MARKER = ".shelfmark-adopted"
_TASK_PREFIX = "slskd_"
@dataclass
class LeftoverReport:
adopted: list[str] = field(default_factory=list)
removed: list[str] = field(default_factory=list)
kept: list[str] = field(default_factory=list)
reattached: list[str] = field(default_factory=list) # task ids picked up again
cancelled: list[str] = field(default_factory=list) # "<peer>: <file>" transfers dropped
def _task_id_for_folder(name: str) -> str | None:
"""The task id a folder is named after ('slskd_ab12' is 'slskd:ab12'), or None.
One-to-one because the handler only gives a task its own folder when the name maps back
(``handler.folder_maps_back``); any other folder here was not made for a task.
"""
if not name.startswith(_TASK_PREFIX) or len(name) == len(_TASK_PREFIX):
return None
return "slskd:" + name[len(_TASK_PREFIX) :]
def _parse_stamp(value: object) -> datetime | None:
"""Parse a stored ISO timestamp, reading one without a zone as UTC.
None when it is missing or unreadable.
"""
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)
def _expected_files(row: dict[str, Any]) -> list[dict[str, Any]]:
"""The files a Soulseek download asked for, from its retry payload.
Empty when the payload is missing or malformed.
"""
payload = row.get("retry_payload")
if isinstance(payload, str):
try:
payload = json.loads(payload)
except ValueError:
return []
context = payload.get("retry_source_context") if isinstance(payload, dict) else None
files = context.get("files") if isinstance(context, dict) else None
return [f for f in files if isinstance(f, dict)] if isinstance(files, list) else []
def _folder_age(folder: Path, row: dict[str, Any] | None, now: datetime) -> timedelta:
"""How old a download folder is.
Counted from the download's end, else its start, else the folder's own mtime.
"""
stamp = _parse_stamp((row or {}).get("terminal_at")) or _parse_stamp(
(row or {}).get("queued_at")
)
if stamp is None:
try:
stamp = datetime.fromtimestamp(run_blocking_io(folder.stat).st_mtime, tz=UTC)
except OSError:
return timedelta(0)
return now - stamp
def _request_state(user_db: UserDB, row: dict[str, Any]) -> tuple[bool, bool]:
"""(needs this download, already has its book or was given up on) for the row's request."""
request_id = row.get("request_id")
if not request_id:
return True, False
request = user_db.get_request(int(request_id))
if not request:
return False, True
status = request.get("status")
delivered = request.get("delivery_state") == "complete"
still_waiting = status == "fulfilled" and not delivered
return still_waiting, delivered or status in {"rejected", "cancelled"}
def plan(
folders: list[Path],
*,
history_service: DownloadHistoryService,
user_db: UserDB,
folder_complete: Any,
keep_completed: bool,
clean_after: timedelta | None,
now: datetime | None = None,
live_request_ids: set[int] | None = None,
) -> tuple[list[tuple[Path, dict[str, Any]]], list[Path], list[Path]]:
"""Sort folders into (to finish, to remove, to keep). Reads only, changes nothing.
What goes where, with ``clean_after`` the clean-up age (None while clean-up is off):
- not named after a task, or a running download: kept.
- imported: removed a day on, unless ``keep_completed``. Clean-up has no say here.
- every file asked for is there and its request is still open (or it was a direct
download): queued for import once, when the request waits for it; kept otherwise.
- the request has its book from elsewhere, or was dropped: removed a day on.
- cancelled, partial, or nothing records it: removed at ``clean_after``.
``live_request_ids`` are the requests a running task is downloading for now; a folder of an
older attempt for one of them is kept, not imported next to the new attempt.
"""
now = now or datetime.now(UTC)
to_finish: list[tuple[Path, dict[str, Any]]] = []
to_remove: list[Path] = []
to_keep: list[Path] = []
for folder in folders:
task_id = _task_id_for_folder(folder.name)
row = history_service.get_by_task_id(task_id) if task_id else None
if task_id is None or (row is not None and row.get("task_id") != task_id):
to_keep.append(folder)
continue
age = _folder_age(folder, row, now)
status = str((row or {}).get("final_status") or "")
if row is None:
# Nothing records this download. Old enough, and it is dead weight.
(to_remove if clean_after and age >= clean_after else to_keep).append(folder)
continue
if status == "active":
to_keep.append(folder) # a download is running or about to be reconciled
continue
if status == "complete":
# Imported. The folder is only still here if cleanup failed, or it is kept shared.
removable = not keep_completed and age >= DELIVERED_GRACE
(to_remove if removable else to_keep).append(folder)
continue
if status == "cancelled":
# Someone cancelled it: never imported automatically. The folder goes with clean-up.
(to_remove if clean_after is not None and age >= clean_after else to_keep).append(
folder
)
continue
needs_it, done_with = _request_state(user_db, row)
request_id = coerce_int(row.get("request_id"), 0)
if needs_it and request_id and request_id in (live_request_ids or set()):
to_keep.append(folder) # a newer attempt for this request is running
continue
sizes = [int(f.get("size") or 0) for f in _expected_files(row)]
whole = folder_complete(folder, sizes)
if whole and not done_with:
# Every file asked for is here, and someone may still want it: a download a person
# started, or a request that is still open. Never removed, whatever its age: the
# files may not be had again, and a later attempt at the same release (the task id
# is the release) lands in this folder and imports it without downloading. Queued
# for import once; after that it waits for a person to retry the download.
if needs_it and not run_blocking_io((folder / ADOPTED_MARKER).exists):
to_finish.append((folder, row))
else:
to_keep.append(folder)
elif done_with and clean_after is not None and age >= DELIVERED_GRACE:
# The book is in the library, or the request was dropped: no need to wait the full
# clean-up period, but only while clean-up is on (0 keeps every folder).
to_remove.append(folder)
elif not whole and clean_after is not None and age >= clean_after:
to_remove.append(folder) # partial and old: nothing will resume it
else:
to_keep.append(folder)
return to_finish, to_remove, to_keep
_LIVE_TASK_STATES = frozenset({"queued", "resolving", "locating", "downloading"})
def _is_unfinished(transfer: dict[str, Any]) -> bool:
"""slskd transfer states are 'Queued, Remotely', 'InProgress', 'Completed, Errored'..."""
return not str(transfer.get("state") or "").lower().startswith("completed")
def _rows_by_file(rows: list[dict[str, Any]]) -> dict[tuple[str, str], dict[str, Any]]:
"""The newest Soulseek history row that asked for each (peer, remote filename)."""
index: dict[tuple[str, str], dict[str, Any]] = {}
for row in rows: # newest first
if row.get("source") != "slskd":
continue
payload = row.get("retry_payload")
if isinstance(payload, str):
try:
payload = json.loads(payload)
except ValueError:
continue
context = payload.get("retry_source_context") if isinstance(payload, dict) else None
if not isinstance(context, dict):
continue
peer = str(context.get("username") or "")
for entry in context.get("files") or []:
name = str(entry.get("filename") or "") if isinstance(entry, dict) else ""
if peer and name:
index.setdefault((peer, name), row)
return index
def _interrupted(row: dict[str, Any]) -> bool:
"""Whether a download stopped without anyone giving up on it.
Either its row is still active with no task behind it, or startup marked it as cut short
by a restart (an error with the "Interrupted" message). Any other ending was a real one.
"""
if row.get("final_status") == "active":
return True
return row.get("final_status") == "error" and row.get("status_message") == INTERRUPTED_MESSAGE
def _queued_after_it_ended(transfer: dict[str, Any], row: dict[str, Any]) -> bool:
"""Whether slskd queued this transfer after the Shelfmark download it matches had ended.
slskd will not queue a second transfer of a file from a peer while one is unfinished, and
stamps each with ``requestedAt`` when it is queued. So a transfer asked for after the
download ended is not one Shelfmark queued: another app or a person queued that file
again. A running download has not ended, and a transfer with no readable stamp cannot be
told apart, so both answer False.
"""
if row.get("final_status") == "active":
return False
ended = _parse_stamp(row.get("terminal_at"))
requested = _parse_stamp(transfer.get("requestedAt"))
return ended is not None and requested is not None and requested > ended
def plan_transfers(
transfers: list[dict[str, Any]],
*,
rows: list[dict[str, Any]],
live_task_ids: set[str],
live_request_ids: set[int],
user_db: UserDB,
) -> tuple[list[tuple[dict[str, Any], list[dict[str, Any]]]], list[dict[str, Any]]]:
"""Split the unfinished transfers nothing is watching into (reattach, cancel).
``reattach`` holds one (history row, its transfers) pair per download to pick up again, so
a download that cannot be picked up can have its transfers dropped instead.
Reads only. Only transfers a Shelfmark download asked for are considered, and only those
slskd queued before that download ended; anything else on the slskd instance belongs to
someone else and is left alone. A transfer is watched when a
running task owns it. slskd keeps working on a
transfer after the task that queued it is gone (a restart, a timeout), so these go on
queueing at peers and writing into a folder nobody imports. One whose request still waits
for that download is picked up again; the rest are dropped. A request that is pending is
deciding what to fetch next, and may well choose this very release, so its transfers wait.
"""
index = _rows_by_file(rows)
reattach: dict[str, tuple[dict[str, Any], list[dict[str, Any]]]] = {}
cancel: list[dict[str, Any]] = []
def pick_up(row: dict[str, Any], transfer: dict[str, Any]) -> None:
"""Note the transfer under its download, which is picked up once however many it has."""
reattach.setdefault(str(row["task_id"]), (row, []))[1].append(transfer)
for transfer in transfers:
if not _is_unfinished(transfer):
continue
row = index.get((str(transfer.get("username") or ""), str(transfer.get("filename") or "")))
if row is not None and row.get("task_id") in live_task_ids:
continue # a running task owns it
if row is None:
# Not one Shelfmark asked for: slskd may be shared with other apps or used by hand.
continue
if _queued_after_it_ended(transfer, row):
continue # the same file queued again, by another app or by hand
if row.get("final_status") == "complete":
# That download finished; an unfinished transfer of the same file is someone else's.
continue
if row.get("final_status") == "cancelled":
cancel.append(transfer) # someone cancelled this download; slskd should stop too
continue
request_id = row.get("request_id")
if not request_id:
if _interrupted(row):
pick_up(row, transfer) # interrupted, not abandoned
else:
cancel.append(transfer)
continue
request = user_db.get_request(int(request_id))
if not request:
cancel.append(transfer)
elif request.get("status") == "pending":
continue
elif int(request_id) in live_request_ids:
cancel.append(transfer) # a newer attempt is running; this is an old one
elif request.get("status") == "fulfilled" and request.get("delivery_state") != "complete":
pick_up(row, transfer)
else:
cancel.append(transfer)
return list(reattach.values()), cancel
def _subfolders(base: Path) -> list[Path]:
"""The folders directly inside base, sorted; run off the event loop by the caller."""
return sorted(p for p in base.iterdir() if p.is_dir())
def _is_inside(folder: Path, root: Path) -> bool:
"""Whether folder is strictly inside root.
Both are resolved first, so a link cannot lead the sweep elsewhere.
"""
try:
run_blocking_io(folder.resolve).relative_to(run_blocking_io(root.resolve))
except OSError, ValueError:
return False
return run_blocking_io(folder.resolve) != run_blocking_io(root.resolve)
def run(
*,
history_service: DownloadHistoryService,
user_db: UserDB,
requeue: Any,
live_tasks: Any = None,
now: datetime | None = None,
) -> LeftoverReport:
"""Finish and clean up the folders under Shelfmark's slskd downloads folder.
Does nothing while the Soulseek source is switched off, or without a client, isolated
downloads or a readable downloads folder.
"""
from shelfmark.release_sources.slskd import handler as slskd_handler
report = LeftoverReport()
if not config.get("SLSKD_ENABLED", False):
# Soulseek is switched off: an import queued now would be refused and still mark the
# folder as tried, so it would not be imported when Soulseek is switched back on.
return report
client = slskd_handler.build_client_from_config()
if client is None or not slskd_handler.isolation_enabled():
return report
root, error = slskd_handler.resolve_local_download_root(client)
if root is None:
logger.debug("slskd leftovers: %s", error)
return report
prefix = slskd_handler.destination_prefix()
base = root.joinpath(*prefix.split("/"))
try:
folders = run_blocking_io(_subfolders, base)
except OSError:
return report
finish = slskd_handler.bool_setting(FINISH_SETTING, True)
clean_days = _clean_days()
clean_after = timedelta(days=clean_days) if clean_days > 0 else None
keep_completed = slskd_handler.bool_setting("SLSKD_KEEP_COMPLETED", True)
live_now = live_tasks() if live_tasks is not None else {}
to_finish, to_remove, to_keep = plan(
folders,
history_service=history_service,
user_db=user_db,
folder_complete=slskd_handler.folder_complete,
keep_completed=keep_completed,
clean_after=clean_after,
now=now,
live_request_ids={coerce_int(r, 0) for r in live_now.values() if r},
)
report.kept = [f.name for f in to_keep]
if finish:
adopted_now = 0
for folder, row in to_finish:
if adopted_now >= MAX_ADOPTIONS_PER_RUN:
report.kept.append(folder.name) # the next run takes it
continue
ok, message = requeue(row.get("retry_payload"), final_status=row.get("final_status"))
if ok:
adopted_now += 1
report.adopted.append(folder.name)
logger.info("slskd leftovers: importing the finished folder %s", folder.name)
else:
logger.warning("slskd leftovers: could not import %s: %s", folder.name, message)
report.kept.append(folder.name)
# Tried once either way: a folder that cannot be queued is not retried every run.
try:
run_blocking_io((folder / ADOPTED_MARKER).touch)
except OSError as exc:
logger.warning("slskd leftovers: could not mark %s: %s", folder.name, exc)
else:
report.kept.extend(f.name for f, _ in to_finish)
if finish and live_tasks is not None:
_settle_transfers(
client,
history_service=history_service,
user_db=user_db,
requeue=requeue,
live_tasks=live_tasks,
report=report,
)
# A folder whose download this run picked up again, or that a task is using now, is in use.
busy = set(report.reattached)
if live_tasks is not None:
busy |= set(live_tasks())
for folder in to_remove:
if not _is_inside(folder, base):
continue
if _task_id_for_folder(folder.name) in busy:
report.kept.append(folder.name)
continue
try:
run_blocking_io(shutil.rmtree, folder)
except OSError as exc:
logger.warning("slskd leftovers: could not remove %s: %s", folder, exc)
report.kept.append(folder.name)
continue
report.removed.append(folder.name)
logger.info("slskd leftovers: removed %s", folder.name)
return report
def _settle_transfers(
client: Any,
*,
history_service: DownloadHistoryService,
user_db: UserDB,
requeue: Any,
live_tasks: Any,
report: LeftoverReport,
) -> None:
"""Pick up or drop the unfinished slskd transfers no running task owns.
As with folders, only downloads that were queued count towards the per-run cap; the rest
wait for the next run, their transfers untouched. One that cannot be queued has its
transfers dropped, since nothing is ever going to watch them.
"""
try:
transfers = client.list_all_downloads()
except Exception as exc: # noqa: BLE001 - slskd being down is not this sweep's problem
logger.debug("slskd leftovers: could not list transfers: %s", exc)
return
live = live_tasks() # {task id: request id or None} of the tasks running now
reattach, cancel = plan_transfers(
transfers,
rows=history_service.list_recent(user_id=None, limit=1000),
live_task_ids=set(live),
live_request_ids={int(r) for r in live.values() if r},
user_db=user_db,
)
picked_up = 0
for row, owned in reattach:
if picked_up >= MAX_ADOPTIONS_PER_RUN:
break # the next run takes the rest
ok, message = requeue(row.get("retry_payload"), final_status=row.get("final_status"))
if ok:
picked_up += 1
report.reattached.append(str(row["task_id"]))
logger.info("slskd leftovers: picked %s up again", row["task_id"])
else:
logger.warning(
"slskd leftovers: could not pick up %s, dropping its transfers: %s",
row["task_id"],
message,
)
cancel.extend(owned)
_drop_transfers(client, cancel, report)
def _cancel_once(client: Any, transfer: dict[str, Any]) -> bool:
"""Ask slskd to cancel one transfer and remove its record. False when slskd refused."""
try:
client.cancel_transfer(str(transfer.get("username")), str(transfer.get("id")), remove=True)
except Exception as exc: # noqa: BLE001
logger.warning("slskd leftovers: could not cancel %s: %s", _label(transfer), exc)
return False
return True
def _drop_transfers(client: Any, transfers: list[dict[str, Any]], report: LeftoverReport) -> None:
"""Cancel the transfers and remove their records, noting each in the report when both land.
A live transfer needs a second pass once the cancel lands, so every transfer is cancelled
first, then one wait covers them all before the second pass. One slskd refuses is left for
the next run.
"""
pending = [t for t in transfers if _cancel_once(client, t)]
if pending:
time.sleep(2.0)
for transfer in pending:
if _cancel_once(client, transfer):
report.cancelled.append(_label(transfer))
logger.info("slskd leftovers: dropped an unwatched transfer, %s", _label(transfer))
def _label(transfer: dict[str, Any]) -> str:
"""'<peer>: <file>', as the report and the log name a transfer."""
return f"{transfer.get('username')}: {transfer.get('filename')}"
def _clean_days() -> int:
return max(coerce_int(config.get(CLEAN_DAYS_SETTING, 0), 0), 0)
def run_with_backend(
*, history_service: DownloadHistoryService, user_db: UserDB, now: datetime | None = None
) -> LeftoverReport:
"""``run`` wired to the live download queue: requeue through the retry, read what is running."""
from shelfmark.download import orchestrator as backend
def requeue(payload: object, final_status: object) -> tuple[bool, str | None]:
return backend.retry_persisted_download(
payload, final_status=final_status, allow_request_linked=True
)
def live_tasks() -> dict[str, Any]:
"""Task id to request id for every task the queue is still working on."""
return {
str(task_id): payload.get("request_id")
for bucket, tasks in backend.queue_status().items()
if bucket in _LIVE_TASK_STATES
for task_id, payload in tasks.items()
if isinstance(payload, dict)
}
return run(
history_service=history_service,
user_db=user_db,
requeue=requeue,
live_tasks=live_tasks,
now=now,
)
@@ -148,6 +148,44 @@ def slskd_config_settings() -> list[SettingsField]:
),
show_when={"field": "SLSKD_ISOLATE_DOWNLOADS", "value": True},
),
CheckboxField(
key="SLSKD_FINISH_LEFTOVERS",
label="Import finished folders an interruption left behind",
default=True,
description=(
"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."
),
show_when={"field": "SLSKD_ISOLATE_DOWNLOADS", "value": True},
),
NumberField(
key="SLSKD_CLEAN_LEFTOVERS_DAYS",
label="Remove leftover download folders after (days)",
default=0,
min_value=0,
max_value=365,
description=(
"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."
),
show_when={"field": "SLSKD_ISOLATE_DOWNLOADS", "value": True},
),
NumberField(
key="SLSKD_SEARCH_TIMEOUT",
label="Search Wait (seconds)",
@@ -181,6 +219,20 @@ def slskd_config_settings() -> list[SettingsField]:
max_value=1440,
show_when={"field": "SLSKD_ENABLED", "value": True},
),
CheckboxField(
key="SLSKD_CHAT_AUTOPROVE",
label="Answer anti-leech chat challenges",
default=False,
description=(
"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."
),
show_when={"field": "SLSKD_ENABLED", "value": True},
),
CheckboxField(
key="SLSKD_REMOVE_COMPLETED",
label="Remove completed transfers from slskd",
+32 -17
View File
@@ -109,6 +109,8 @@ import {
import {
applyDirectPolicyModeToButtonState,
applyUniversalPolicyModeToButtonState,
canRequestBookFromReleaseModal,
shouldShowRequestsTab,
} from './utils/requestPolicyUi';
import { getSearchByPreference, setSearchByPreference } from './utils/searchByPreference';
import { buildUrlSearchHash } from './utils/urlSearchHash';
@@ -264,6 +266,7 @@ const AdminSettingsWarmupMount = () => {
return null;
};
/** The application shell: search, results, the release modal, activity and settings. */
function App() {
const location = useLocation();
const { toasts, showToast, removeToast } = useToast();
@@ -322,6 +325,7 @@ function App() {
getSourceMode,
requestsEnabled: requestsPolicyEnabled,
allowNotes: allowRequestNotes,
allowBookRequestWithDownload,
refresh: refreshRequestPolicy,
} = useRequestPolicy({
enabled: isAuthenticated,
@@ -455,20 +459,23 @@ function App() {
};
}, [activityStatus, currentStatus, dismissedDownloadTaskIds]);
const showRequestsTab = useMemo(() => {
if (requestRoleIsAdmin) {
return true;
}
if (!isAuthenticated || !requestsPolicyEnabled) {
return false;
}
if (!requestPolicy) {
return false;
}
return !(
requestPolicy.defaults.ebook === 'download' && requestPolicy.defaults.audiobook === 'download'
);
}, [requestRoleIsAdmin, isAuthenticated, requestsPolicyEnabled, requestPolicy]);
const showRequestsTab = useMemo(
() =>
shouldShowRequestsTab({
isAdmin: requestRoleIsAdmin,
isAuthenticated,
requestsEnabled: requestsPolicyEnabled,
defaults: requestPolicy?.defaults ?? null,
allowBookRequestWithDownload,
}),
[
requestRoleIsAdmin,
isAuthenticated,
requestsPolicyEnabled,
requestPolicy,
allowBookRequestWithDownload,
],
);
// Search state and handlers
const {
@@ -2459,7 +2466,7 @@ function App() {
<Header
calibreWebUrl={config?.calibre_web_url || ''}
audiobookLibraryUrl={config?.audiobook_library_url || ''}
debug={config?.debug || false}
debug={(config?.debug || false) && authIsAdmin}
logoUrl={logoUrl}
showSearch={!isInitialState}
searchInput={activeQueryValue}
@@ -2671,7 +2678,15 @@ function App() {
onDownload={isBrowseFulfilMode ? handleBrowseFulfilDownload : handleReleaseDownload}
onRequestRelease={isBrowseFulfilMode ? undefined : handleReleaseRequest}
onRequestBook={
isBrowseFulfilMode || !requestRoleIsAdmin ? undefined : handleReleaseBookRequest
canRequestBookFromReleaseModal({
isBrowseFulfilMode,
isAdmin: requestRoleIsAdmin,
requestsEnabled: requestsPolicyEnabled,
allowBookRequestWithDownload,
bookRequestMode: getSourceMode('*', activeReleaseContentType),
})
? handleReleaseBookRequest
: undefined
}
getPolicyModeForSource={
isBrowseFulfilMode ? () => 'download' : (source, ct) => getSourceMode(source, ct)
@@ -2776,7 +2791,7 @@ function App() {
pendingRequestCount={pendingRequestCount}
showRequestsTab={showRequestsTab}
isRequestsLoading={isActivitySnapshotLoading}
onRequestCancel={showRequestsTab ? handleRequestCancel : undefined}
onRequestCancel={handleRequestCancel}
onRequestApprove={requestRoleIsAdmin ? handleRequestApprove : undefined}
onRequestReject={requestRoleIsAdmin ? handleRequestReject : undefined}
onRequestReopen={requestRoleIsAdmin ? handleRequestReopen : undefined}
+25 -7
View File
@@ -26,6 +26,7 @@ import type {
import { isMetadataBook } from '../types';
import { bookSupportsTargets } from '../utils/bookTargetLoader';
import { getColorStyleFromHint } from '../utils/colorMaps';
import { failedDownloadButtonState } from '../utils/failedDownloadButton';
import { groupFilesByFormat, type FormatGroup } from '../utils/formatReview';
import {
LANGUAGE_OPTION_DEFAULT,
@@ -914,6 +915,18 @@ const ReleaseModalSession = ({
const tabRefs = useRef<Record<string, HTMLButtonElement | null>>({});
const tabIndicatorStyle = useTabIndicator(tabRefs, activeTab, allTabs);
const tabStripRef = useRef<HTMLDivElement>(null);
// The strip's scrollbar is hidden, so bring the selected tab fully into view. This sets
// the strip's own scrollLeft: scrollIntoView would also scroll the modal vertically.
const selectTab = (name: string, tab: HTMLButtonElement) => {
setActiveTab(name);
const strip = tabStripRef.current;
strip?.scrollTo({
left: tab.offsetLeft - (strip.clientWidth - tab.offsetWidth) / 2,
behavior: 'smooth',
});
};
// Get unique formats from current releases for filter dropdown
// Only show formats that are in the supported list
@@ -1183,9 +1196,7 @@ const ReleaseModalSession = ({
if (mode === 'blocked' || mode === 'request_book') {
return { text: 'Unavailable', state: 'blocked' };
}
return currentStatus.error[releaseId].retry_available === true
? { text: 'Retry', state: 'download' }
: { text: 'Failed', state: 'error' };
return failedDownloadButtonState(currentStatus.error[releaseId]);
}
// Check completed
if (currentStatus.complete && currentStatus.complete[releaseId]) {
@@ -1814,9 +1825,16 @@ const ReleaseModalSession = ({
</div>
)}
{!sourcesLoading && allTabs.length > 0 && (
<div className="flex items-center px-5">
{/* Tabs - scrollable on narrow screens */}
<div className="scrollbar-hide min-w-0 flex-1 overflow-x-auto">
<div
className={`flex items-center px-5 ${allTabs.length > 1 ? 'flex-wrap sm:flex-nowrap' : ''}`}
>
{/* Tabs - scrollable on narrow screens. With more than one source they get
their own row on phones, because the controls would otherwise leave
them about 110px. */}
<div
ref={tabStripRef}
className={`scrollbar-hide min-w-0 flex-1 overflow-x-auto ${allTabs.length > 1 ? 'basis-full sm:basis-0' : ''}`}
>
<div className="relative flex gap-1">
{/* Sliding indicator */}
<div
@@ -1833,7 +1851,7 @@ const ReleaseModalSession = ({
ref={(el) => {
tabRefs.current[tab.name] = el;
}}
onClick={() => setActiveTab(tab.name)}
onClick={(event) => selectTab(tab.name, event.currentTarget)}
className={`border-b-2 border-transparent px-4 py-2.5 text-sm font-medium whitespace-nowrap transition-colors ${
activeTab === tab.name
? 'text-emerald-600 dark:text-emerald-400'
@@ -32,6 +32,7 @@ interface ActivityCardProps {
onRequestReject?: (requestId: number, adminNote?: string) => Promise<void> | void;
onRequestRejectConfirm?: (requestId: number, adminNote?: string) => Promise<void> | void;
onRequestReopen?: (requestId: number) => Promise<void> | void;
onRequestSetHidden?: (requestId: number, hidden: boolean) => Promise<void> | void;
onRequestDismiss?: (requestId: number) => void;
showRequestDetailsToggle?: boolean;
isRequestDetailsOpen?: boolean;
@@ -69,6 +70,19 @@ const IconButton = ({
</button>
);
// Fork: one colour per format, picked from hues the status badges do not use.
const FORMAT_PILL_CLASSES: Record<'audiobook' | 'ebook', string> = {
audiobook: 'bg-violet-100 text-violet-800 dark:bg-violet-900/50 dark:text-violet-200',
ebook: 'bg-orange-100 text-orange-800 dark:bg-orange-900/50 dark:text-orange-200',
};
// Fork: a download that picked up work already under way (a torrent the client had, files
// already on disk). Sky blue, a hue neither the status badges nor the format pills use.
const REATTACHED_PILL_CLASSES = 'bg-sky-100 text-sky-800 dark:bg-sky-900/50 dark:text-sky-200';
type ActionIconName = 'cross' | 'check' | 'stop' | 'retry' | 'eye' | 'eye-off';
/** A React key for an action button: its kind plus the download or request it acts on. */
const actionKey = (action: ActivityCardAction): string => {
switch (action.kind) {
case 'download-remove':
@@ -80,6 +94,8 @@ const actionKey = (action: ActivityCardAction): string => {
return `${action.kind}-${action.requestId}-${action.record.id}`;
case 'request-reject':
case 'request-reopen':
case 'request-hide':
case 'request-unhide':
case 'request-cancel':
case 'request-dismiss':
return `${action.kind}-${action.requestId}`;
@@ -90,7 +106,7 @@ const actionKey = (action: ActivityCardAction): string => {
const actionUiConfig = (
action: ActivityCardAction,
): { title: string; className: string; icon: 'cross' | 'check' | 'stop' | 'retry' } => {
): { title: string; className: string; icon: ActionIconName } => {
switch (action.kind) {
case 'download-remove':
return {
@@ -135,6 +151,18 @@ const actionUiConfig = (
className: 'text-sky-600 dark:text-sky-400 hover:bg-sky-100 dark:hover:bg-sky-900/30',
icon: 'retry',
};
case 'request-hide':
return {
title: 'Hide from Rejected',
className: 'text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-700',
icon: 'eye-off',
};
case 'request-unhide':
return {
title: 'Show in Rejected again',
className: 'text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-700',
icon: 'eye',
};
case 'request-cancel':
return {
title: 'Cancel request',
@@ -156,7 +184,28 @@ const actionUiConfig = (
}
};
const ActionIcon = ({ icon }: { icon: 'cross' | 'check' | 'stop' | 'retry' }) => {
/** The small inline SVG for an action button. */
const ActionIcon = ({ icon }: { icon: ActionIconName }) => {
if (icon === 'eye' || icon === 'eye-off') {
return (
<svg
className="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M2.25 12s3.75-6.75 9.75-6.75S21.75 12 21.75 12 18 18.75 12 18.75 2.25 12 2.25 12Z"
/>
<circle cx="12" cy="12" r="2.75" />
{icon === 'eye-off' && <path strokeLinecap="round" d="M4 4l16 16" />}
</svg>
);
}
if (icon === 'stop') {
return (
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
@@ -494,6 +543,7 @@ export const ActivityCard = ({
onRequestReject,
onRequestRejectConfirm,
onRequestReopen,
onRequestSetHidden,
onRequestDismiss,
showRequestDetailsToggle = false,
isRequestDetailsOpen = false,
@@ -505,6 +555,7 @@ export const ActivityCard = ({
}: ActivityCardProps) => {
const model = useMemo(() => buildActivityCardModel(item, isAdmin), [item, isAdmin]);
const noteLine = model.noteLine;
const formatPill = item.formatPill;
const badgeRefs = useRef<Record<string, HTMLSpanElement | null>>({});
const titleLineRef = useRef<HTMLParagraphElement | null>(null);
const [badgeOverflow, setBadgeOverflow] = useState<Record<string, boolean>>({});
@@ -581,6 +632,7 @@ export const ActivityCard = ({
const reviewApproveHandler = onRequestReviewApprove || onRequestApprove;
const isDetailsExpanded = isRequestDetailsOpen || isRequestRejectOpen;
/** Call the handler the parent passed for this action; approve may open the details instead. */
const runAction = (action: ActivityCardAction) => {
switch (action.kind) {
case 'download-remove':
@@ -612,6 +664,12 @@ export const ActivityCard = ({
case 'request-reopen':
void onRequestReopen?.(action.requestId);
break;
case 'request-hide':
void onRequestSetHidden?.(action.requestId, true);
break;
case 'request-unhide':
void onRequestSetHidden?.(action.requestId, false);
break;
case 'request-cancel':
onRequestCancel?.(action.requestId);
break;
@@ -623,6 +681,7 @@ export const ActivityCard = ({
}
};
/** Whether the parent passed a handler for this action, so the button is worth showing. */
const hasActionHandler = (action: ActivityCardAction): boolean => {
switch (action.kind) {
case 'download-remove':
@@ -638,6 +697,9 @@ export const ActivityCard = ({
return Boolean(onRequestReject);
case 'request-reopen':
return Boolean(onRequestReopen);
case 'request-hide':
case 'request-unhide':
return Boolean(onRequestSetHidden);
case 'request-cancel':
return Boolean(onRequestCancel);
case 'request-dismiss':
@@ -801,9 +863,42 @@ export const ActivityCard = ({
</div>
</div>
<p className="mt-0.5 truncate text-[11px] leading-tight opacity-60" title={item.metaLine}>
{item.metaLine}
</p>
{formatPill ? (
<div className="mt-0.5 flex min-w-0 items-center gap-1.5 text-[11px] leading-tight">
<span
className={`shrink-0 rounded px-1.5 py-px font-medium ${FORMAT_PILL_CLASSES[formatPill.kind]}`}
>
{formatPill.label}
</span>
{item.reattached && (
<span
className={`shrink-0 rounded px-1.5 py-px font-medium ${REATTACHED_PILL_CLASSES}`}
title="Picked up where it left off instead of starting again"
>
Reattached
</span>
)}
{item.metaLine && (
<span className="truncate opacity-60" title={item.metaLine}>
{item.metaLine}
</span>
)}
</div>
) : (
<div className="mt-0.5 flex min-w-0 items-center gap-1.5 text-[11px] leading-tight">
{item.reattached && (
<span
className={`shrink-0 rounded px-1.5 py-px font-medium ${REATTACHED_PILL_CLASSES}`}
title="Picked up where it left off instead of starting again"
>
Reattached
</span>
)}
<p className="truncate opacity-60" title={item.metaLine}>
{item.metaLine}
</p>
</div>
)}
{noteLine && (
<p className="mt-0.5 truncate text-[11px] italic opacity-60" title={noteLine}>
@@ -3,9 +3,18 @@ import { useCallback, useMemo, useRef, useState, type WheelEvent } from 'react';
import { useTabIndicator } from '../../hooks/ui/useTabIndicator';
import { useEscapeKey } from '../../hooks/useEscapeKey';
import { useMediaQuery } from '../../hooks/useMediaQuery';
import { useRejectedRequests } from '../../hooks/useRejectedRequests';
import type { RequestRecord, StatusData } from '../../types';
import { Dropdown } from '../Dropdown';
import { ActivityCard } from './ActivityCard';
import {
getActivityCategory,
getCategoryHint,
getCategoryLabel,
getVisibleCategoryOrder,
type ActivityCategoryKey,
type ActivityTabKey,
} from './activityCategories';
import type { DownloadStatusKey } from './activityMappers';
import { downloadToActivityItem, linkedDownloadIdForRequest } from './activityMappers';
import type { ActivityItem } from './activityTypes';
@@ -65,89 +74,8 @@ const DOWNLOAD_STATUS_KEYS: DownloadStatusKey[] = [
'cancelled',
];
type ActivityCategoryKey = 'needs_review' | 'in_progress' | 'complete' | 'failed';
type ActivityTabKey = 'all' | 'downloads' | 'requests' | 'history';
const ALL_USERS_FILTER = '__all_users__';
const getCategoryLabel = (key: ActivityCategoryKey, isAdmin: boolean): string => {
if (key === 'needs_review') {
return isAdmin ? 'Needs Review' : 'Waiting';
}
if (key === 'in_progress') {
return 'In Progress';
}
if (key === 'complete') {
return 'Complete';
}
return 'Failed';
};
const getVisibleCategoryOrder = (tab: ActivityTabKey): ActivityCategoryKey[] => {
if (tab === 'downloads') {
return ['in_progress', 'complete', 'failed'];
}
if (tab === 'requests') {
return ['needs_review', 'in_progress', 'complete', 'failed'];
}
if (tab === 'history') {
return [];
}
return ['needs_review', 'in_progress', 'complete', 'failed'];
};
const getActivityCategory = (item: ActivityItem): ActivityCategoryKey => {
if (item.kind === 'download') {
if (
item.visualStatus === 'queued' ||
item.visualStatus === 'resolving' ||
item.visualStatus === 'locating' ||
item.visualStatus === 'downloading'
) {
return 'in_progress';
}
if (item.visualStatus === 'complete') {
return 'complete';
}
return 'failed';
}
const requestStatus = item.requestRecord?.status;
if (requestStatus === 'pending' || item.visualStatus === 'pending') {
return 'needs_review';
}
if (requestStatus === 'rejected' || requestStatus === 'cancelled') {
return 'failed';
}
const deliveryState = item.requestRecord?.delivery_state;
if (requestStatus === 'fulfilled' || item.visualStatus === 'fulfilled') {
if (
deliveryState === 'queued' ||
deliveryState === 'resolving' ||
deliveryState === 'locating' ||
deliveryState === 'downloading'
) {
return 'in_progress';
}
if (deliveryState === 'error' || deliveryState === 'cancelled') {
return 'failed';
}
// Legacy fulfilled requests often have unknown/none delivery state because the
// pre-refactor queue state was ephemeral. Treat as completed approval, not in-progress.
return 'complete';
}
if (deliveryState === 'complete') {
return 'complete';
}
if (deliveryState === 'error' || deliveryState === 'cancelled') {
return 'failed';
}
return 'in_progress';
};
const mergeRequestWithDownload = (
requestItem: ActivityItem,
downloadItem: ActivityItem,
@@ -249,26 +177,46 @@ export const ActivitySidebar = ({
const [collapsedGroups, setCollapsedGroups] = useState<Record<string, boolean>>({});
const scrollViewportRef = useRef<HTMLDivElement | null>(null);
const dismissedKeySet = useMemo(() => new Set(dismissedItemKeys), [dismissedItemKeys]);
const { rejectedItems, rejectedLoading, loadRejected, setRejectedHidden } = useRejectedRequests();
const [showHiddenRejected, setShowHiddenRejected] = useState(false);
const hiddenRejectedCount = rejectedItems.filter((item) => item.hiddenInRejected).length;
const handleTabChange = useCallback(
(nextTab: ActivityTabKey) => {
if (nextTab === 'downloads') {
setRejectingRequest(null);
setReviewingRequestId(null);
}
if (nextTab === 'rejected') {
void loadRejected();
}
setActiveTab(nextTab);
onActiveTabChange?.(nextTab);
},
[onActiveTabChange],
[loadRejected, onActiveTabChange],
);
const isPinnedOpen = isOpen && isDesktop && isPinned;
const effectiveActiveTab = !showRequestsTab && activeTab === 'requests' ? 'all' : activeTab;
let effectiveActiveTab = activeTab;
if (!showRequestsTab && activeTab === 'requests') {
effectiveActiveTab = 'all';
} else if (!isAdmin && activeTab === 'rejected') {
effectiveActiveTab = 'all';
}
if (effectiveActiveTab !== activeTab) {
setActiveTab(effectiveActiveTab);
}
useEscapeKey(isOpen && !isPinnedOpen, onClose);
/** Reopen a rejected request, then reload the list whether or not the call succeeded. */
const handleRejectedReopen = useCallback(
(requestId: number) =>
Promise.resolve(onRequestReopen?.(requestId)).finally(() => {
void loadRejected();
}),
[loadRejected, onRequestReopen],
);
const downloadItems = useMemo(() => {
const items: ActivityItem[] = [];
@@ -379,17 +327,6 @@ export const ActivitySidebar = ({
};
}, [downloadItems, visibleRequestItems]);
const hasTerminalDownloadItems = useMemo(
() =>
mergedDownloadItems.some(
(item) =>
item.visualStatus === 'complete' ||
item.visualStatus === 'error' ||
item.visualStatus === 'cancelled',
),
[mergedDownloadItems],
);
const allItems = useMemo(() => {
const combined = dedupeById([...mergedDownloadItems, ...mergedRequestItems]);
return combined.toSorted((a, b) => b.timestamp - a.timestamp);
@@ -412,6 +349,10 @@ export const ActivitySidebar = ({
});
} else if (effectiveActiveTab === 'history') {
baseVisibleItems = historyItems;
} else if (effectiveActiveTab === 'rejected') {
baseVisibleItems = showHiddenRejected
? rejectedItems
: rejectedItems.filter((item) => !item.hiddenInRejected);
}
const isHistoryInitialLoad = effectiveActiveTab === 'history' && !historyLoaded;
let emptyStateMessage = 'No activity';
@@ -422,6 +363,14 @@ export const ActivitySidebar = ({
historyLoading || isHistoryInitialLoad ? 'Loading history...' : 'No history';
} else if (effectiveActiveTab === 'downloads') {
emptyStateMessage = 'No downloads';
} else if (effectiveActiveTab === 'rejected') {
if (rejectedLoading) {
emptyStateMessage = 'Loading rejected requests...';
} else if (hiddenRejectedCount > 0 && !showHiddenRejected) {
emptyStateMessage = `No rejected requests shown (${hiddenRejectedCount} hidden)`;
} else {
emptyStateMessage = 'No rejected requests';
}
}
const availableUsers = useMemo(() => {
@@ -497,7 +446,7 @@ export const ActivitySidebar = ({
);
const groupedVisibleItems = useMemo(() => {
if (effectiveActiveTab === 'history') {
if (effectiveActiveTab === 'history' || effectiveActiveTab === 'rejected') {
return [];
}
@@ -519,6 +468,7 @@ export const ActivitySidebar = ({
.map((key) => ({
key,
label: getCategoryLabel(key, isAdmin),
hint: getCategoryHint(key, isAdmin),
items: (grouped.get(key) ?? []).toSorted((left, right) => right.timestamp - left.timestamp),
}))
.filter((group) => group.items.length > 0);
@@ -551,7 +501,9 @@ export const ActivitySidebar = ({
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<h2 className="text-lg font-semibold">
{effectiveActiveTab === 'history' ? 'History' : 'Activity'}
{effectiveActiveTab === 'history' && 'History'}
{effectiveActiveTab === 'rejected' && 'Rejected'}
{effectiveActiveTab !== 'history' && effectiveActiveTab !== 'rejected' && 'Activity'}
</h2>
<button
type="button"
@@ -667,6 +619,36 @@ export const ActivitySidebar = ({
)}
</Dropdown>
)}
{isAdmin && (
<button
type="button"
onClick={() =>
handleTabChange(effectiveActiveTab === 'rejected' ? 'all' : 'rejected')
}
className={`hover-action relative inline-flex h-9 w-9 items-center justify-center rounded-full transition-colors ${
effectiveActiveTab === 'rejected' ? 'text-sky-600 dark:text-sky-400' : ''
}`}
title={
effectiveActiveTab === 'rejected' ? 'Back to activity' : 'Open rejected requests'
}
aria-label={
effectiveActiveTab === 'rejected' ? 'Back to activity' : 'Open rejected requests'
}
aria-pressed={effectiveActiveTab === 'rejected'}
>
<svg
className="h-5 w-5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.75"
aria-hidden="true"
>
<circle cx="12" cy="12" r="8.25" />
<path strokeLinecap="round" strokeLinejoin="round" d="M6.2 6.2l11.6 11.6" />
</svg>
</button>
)}
<button
type="button"
onClick={() => handleTabChange(effectiveActiveTab === 'history' ? 'all' : 'history')}
@@ -714,7 +696,20 @@ export const ActivitySidebar = ({
</div>
</div>
{effectiveActiveTab !== 'history' && (
{effectiveActiveTab === 'rejected' && (
<div className="mt-2 flex items-center justify-end text-sm">
<label className="inline-flex cursor-pointer items-center gap-2 opacity-80">
<input
type="checkbox"
checked={showHiddenRejected}
onChange={(event) => setShowHiddenRejected(event.target.checked)}
/>
Show hidden ({hiddenRejectedCount})
</label>
</div>
)}
{effectiveActiveTab !== 'history' && effectiveActiveTab !== 'rejected' && (
<div className="-mx-4 mt-2 border-b border-(--border-muted) px-4">
<div className="relative flex gap-1">
{/* Sliding indicator */}
@@ -797,6 +792,22 @@ export const ActivitySidebar = ({
return <p className="mt-8 text-center text-sm opacity-70">{emptyStateMessage}</p>;
}
if (effectiveActiveTab === 'rejected') {
return (
<div className="divide-y divide-[color-mix(in_srgb,var(--border-muted)_60%,transparent)]">
{visibleItems.map((item) => (
<ActivityCard
key={item.id}
item={item}
isAdmin={isAdmin}
onRequestReopen={onRequestReopen ? handleRejectedReopen : undefined}
onRequestSetHidden={setRejectedHidden}
/>
))}
</div>
);
}
if (effectiveActiveTab === 'history') {
return (
<div className="divide-y divide-[color-mix(in_srgb,var(--border-muted)_60%,transparent)]">
@@ -855,6 +866,9 @@ export const ActivitySidebar = ({
</span>
</button>
)}
{group.hint && !collapsedGroups[group.key] && (
<p className="px-0 pb-1.5 text-[11px] leading-snug opacity-60">{group.hint}</p>
)}
{!collapsedGroups[group.key] && (
<div className="divide-y divide-[color-mix(in_srgb,var(--border-muted)_60%,transparent)]">
{group.items.map((item) => {
@@ -958,8 +972,9 @@ export const ActivitySidebar = ({
})()}
</div>
{(effectiveActiveTab === 'downloads' || effectiveActiveTab === 'all') &&
hasTerminalDownloadItems &&
{(effectiveActiveTab === 'downloads' ||
effectiveActiveTab === 'all' ||
effectiveActiveTab === 'requests') &&
clearCompletedTargets.length > 0 && (
<div
className="flex items-center justify-center border-t p-3"
@@ -0,0 +1,35 @@
// Fork: scripts and the automatic passes act through the admin account and mark what they did with
// an "[auto] " admin note. The cards say "automatically" for those, so a person is not shown as
// the one who declined or approved something they never saw.
const AUTO_PREFIX = /^\s*\[auto\]\s*/i;
/** Whether an admin note starts with the "[auto] " marker that scripts and passes write. */
export const isAutomaticNote = (note: string | null | undefined): boolean =>
typeof note === 'string' && AUTO_PREFIX.test(note);
/** The note without its "[auto] " marker. */
export const stripAutomaticMarker = (note: string): string => note.replace(AUTO_PREFIX, '').trim();
interface RetryFields {
status?: string;
admin_note?: string | null;
last_failure_reason?: string | null;
}
/**
* A pending request the automation is retrying by itself (reopened after a failed or interrupted
* download), as opposed to one it searched and found nothing for. Nothing for a person to do.
*/
export const isRetryingAutomatically = (record: RetryFields | null | undefined): boolean => {
if (!record || record.status !== 'pending') {
return false;
}
const note = (record.admin_note ?? '').trim().toLowerCase();
const reason = (record.last_failure_reason ?? '').trim().toLowerCase();
return (
note.startsWith('[auto] retry') ||
reason.startsWith('retrying') ||
reason.startsWith('interrupted by a restart')
);
};
@@ -1,4 +1,9 @@
import type { RequestRecord } from '../../types';
import {
isAutomaticNote,
isRetryingAutomatically,
stripAutomaticMarker,
} from './activityAutomation';
import { isActiveDownloadStatus } from './activityStyles.js';
import type { ActivityItem, ActivityVisualStatus } from './activityTypes';
@@ -14,7 +19,13 @@ export type ActivityCardAction =
record: RequestRecord;
}
| {
kind: 'request-reject' | 'request-reopen' | 'request-cancel' | 'request-dismiss';
kind:
| 'request-reject'
| 'request-reopen'
| 'request-hide'
| 'request-unhide'
| 'request-cancel'
| 'request-dismiss';
requestId: number;
};
@@ -51,14 +62,15 @@ const toRequestVisualStatus = (status: RequestRecord['status']): ActivityVisualS
return 'cancelled';
};
const getPendingRequestText = (item: ActivityItem, isAdmin: boolean): string => {
if (!isAdmin) {
return 'Awaiting review';
}
const username = item.username?.trim() || item.requestRecord?.username?.trim();
return username ? `Needs review · ${username}` : 'Needs review';
/** The badge text for a pending request. The requester is named on the meta line, not here. */
const getPendingRequestText = (_item: ActivityItem, isAdmin: boolean): string => {
return isAdmin ? 'No match yet' : 'Awaiting review';
};
/**
* The status badge for a request card. A failure reason on a pending request shows as an error,
* an automatic retry says so, and a linked download in flight shows as approved.
*/
const getRequestBadge = (item: ActivityItem, isAdmin: boolean): ActivityCardBadge => {
const requestVisualStatus = item.requestRecord
? toRequestVisualStatus(item.requestRecord.status)
@@ -79,6 +91,10 @@ const getRequestBadge = (item: ActivityItem, isAdmin: boolean): ActivityCardBadg
let text = item.statusLabel;
if (hasInFlightLinkedDownload) {
text = 'Approved';
} else if (isRetryingAutomatically(item.requestRecord)) {
// Fork: the automation reopened it after a failed download; the next pass searches again.
text = 'Retrying automatically';
visualStatus = 'resolving';
} else if (hasFailureReason && failureReason) {
text = failureReason;
} else if (requestVisualStatus === 'pending') {
@@ -116,6 +132,7 @@ const getDownloadBadge = (item: ActivityItem): ActivityCardBadge => {
};
};
/** The badges on a card: a download linked to a request shows both, once it is complete only its own. */
const buildBadges = (item: ActivityItem, isAdmin: boolean): ActivityCardBadge[] => {
if (item.kind === 'download' && item.visualStatus === 'complete') {
return [getDownloadBadge(item)];
@@ -132,8 +149,57 @@ const buildBadges = (item: ActivityItem, isAdmin: boolean): ActivityCardBadge[]
return [getDownloadBadge(item)];
};
const buildRequestNoteLine = (item: ActivityItem): string | undefined => {
/** A review stamp as "Mar 1", or undefined when it is missing or unreadable. */
const formatReviewDate = (value: string | null | undefined): string | undefined => {
if (!value) {
return undefined;
}
const parsed = Date.parse(value);
if (!Number.isFinite(parsed)) {
return undefined;
}
return new Intl.DateTimeFormat(undefined, { month: 'short', day: 'numeric' }).format(parsed);
};
/** An admin note for display: an automatic one loses its "[auto] " marker. */
const formatAdminNote = (note: string): string =>
isAutomaticNote(note) ? stripAutomaticMarker(note) : note;
/**
* Who declined a rejected request and when, with the note, for admins. The reviewer is only sent
* to admin views; a decline the automation made says so instead of naming the account.
*/
const buildDeclinedLine = (item: ActivityItem): string | undefined => {
const record = item.requestRecord;
if (!record || record.status !== 'rejected') {
return undefined;
}
// Fork: a decline the automation made says so, instead of naming the account it acted through.
const automatic = isAutomaticNote(item.adminNote);
let who = 'Declined';
if (automatic) {
who = 'Declined automatically';
} else if (record.reviewer_username) {
who = `Declined by ${record.reviewer_username}`;
}
const parts = [who];
const date = formatReviewDate(record.reviewed_at);
if (date) {
parts.push(date);
}
const line = parts.join(' · ');
return item.adminNote ? `${line}: "${formatAdminNote(item.adminNote)}"` : line;
};
/**
* The quoted note under a request card: the declined line for admins, the user's note while it is
* pending, or the admin note once it is decided. Undefined when there is nothing to say.
*/
const buildRequestNoteLine = (item: ActivityItem, isAdmin: boolean): string | undefined => {
const requestStatus = item.requestRecord?.status;
if (isAdmin && requestStatus === 'rejected') {
return buildDeclinedLine(item);
}
if (item.requestNote && (requestStatus === 'pending' || item.visualStatus === 'pending')) {
return `"${item.requestNote}"`;
}
@@ -144,11 +210,14 @@ const buildRequestNoteLine = (item: ActivityItem): string | undefined => {
item.visualStatus === 'rejected' ||
item.visualStatus === 'fulfilled')
) {
return `"${item.adminNote}"`;
return isAutomaticNote(item.adminNote)
? `Automatic: ${formatAdminNote(item.adminNote)}`
: `"${item.adminNote}"`;
}
return undefined;
};
/** The action buttons a card offers, by what the item is, its status and who is looking. */
const buildActions = (item: ActivityItem, isAdmin: boolean): ActivityCardAction[] => {
if (item.kind === 'download' && item.downloadBookId) {
const canRetry = item.downloadRetryAvailable === true;
@@ -215,10 +284,14 @@ const buildActions = (item: ActivityItem, isAdmin: boolean): ActivityCardAction[
}
if (item.visualStatus === 'rejected' && isAdmin) {
return [
{ kind: 'request-reopen', requestId: item.requestId },
{ kind: 'request-dismiss', requestId: item.requestId },
];
const actions: ActivityCardAction[] = [{ kind: 'request-reopen', requestId: item.requestId }];
if (item.hiddenInRejected === true) {
actions.push({ kind: 'request-unhide', requestId: item.requestId });
} else if (item.hiddenInRejected === false) {
actions.push({ kind: 'request-hide', requestId: item.requestId });
}
actions.push({ kind: 'request-dismiss', requestId: item.requestId });
return actions;
}
if (
@@ -236,7 +309,7 @@ const buildActions = (item: ActivityItem, isAdmin: boolean): ActivityCardAction[
export const buildActivityCardModel = (item: ActivityItem, isAdmin: boolean): ActivityCardModel => {
return {
badges: buildBadges(item, isAdmin),
noteLine: buildRequestNoteLine(item),
noteLine: buildRequestNoteLine(item, isAdmin),
actions: buildActions(item, isAdmin),
};
};
@@ -0,0 +1,107 @@
import { isRetryingAutomatically } from './activityAutomation';
import type { ActivityItem } from './activityTypes';
export type ActivityCategoryKey =
| 'needs_review'
| 'auto_retry'
| 'in_progress'
| 'complete'
| 'failed';
export type ActivityTabKey = 'all' | 'downloads' | 'requests' | 'history' | 'rejected';
/** The section header for a category; the no-match one reads differently for admins. */
export const getCategoryLabel = (key: ActivityCategoryKey, isAdmin: boolean): string => {
if (key === 'needs_review') {
// Fork: nothing here is waiting for a person; the automatic search found no match it trusts.
return isAdmin ? 'No Match Yet' : 'Waiting';
}
if (key === 'auto_retry') {
return 'Retrying Automatically';
}
if (key === 'in_progress') {
return 'In Progress';
}
if (key === 'complete') {
return 'Complete';
}
return 'Failed';
};
/** One line under a section header that says what, if anything, is expected of the reader. */
export const getCategoryHint = (key: ActivityCategoryKey, isAdmin: boolean): string | undefined => {
if (!isAdmin) {
return undefined;
}
if (key === 'needs_review') {
return 'Searched automatically on every sync. Choose a release yourself, decline, or leave it.';
}
if (key === 'auto_retry') {
return 'A download failed and another copy is being tried. Nothing to do.';
}
return undefined;
};
/** The categories a tab shows, in order. History and Rejected are flat lists. */
export const getVisibleCategoryOrder = (tab: ActivityTabKey): ActivityCategoryKey[] => {
if (tab === 'downloads') {
return ['in_progress', 'complete', 'failed'];
}
if (tab === 'history' || tab === 'rejected') {
return [];
}
return ['needs_review', 'auto_retry', 'in_progress', 'complete', 'failed'];
};
/** Which section an item belongs under, by whether it is a download or a request and its state. */
export const getActivityCategory = (item: ActivityItem): ActivityCategoryKey => {
if (item.kind === 'download') {
if (
item.visualStatus === 'queued' ||
item.visualStatus === 'resolving' ||
item.visualStatus === 'locating' ||
item.visualStatus === 'downloading'
) {
return 'in_progress';
}
if (item.visualStatus === 'complete') {
return 'complete';
}
return 'failed';
}
const requestStatus = item.requestRecord?.status;
if (requestStatus === 'pending' || item.visualStatus === 'pending') {
return isRetryingAutomatically(item.requestRecord) ? 'auto_retry' : 'needs_review';
}
if (requestStatus === 'rejected' || requestStatus === 'cancelled') {
return 'failed';
}
const deliveryState = item.requestRecord?.delivery_state;
if (requestStatus === 'fulfilled' || item.visualStatus === 'fulfilled') {
if (
deliveryState === 'queued' ||
deliveryState === 'resolving' ||
deliveryState === 'locating' ||
deliveryState === 'downloading'
) {
return 'in_progress';
}
if (deliveryState === 'error' || deliveryState === 'cancelled') {
return 'failed';
}
// Legacy fulfilled requests often have unknown/none delivery state because the
// pre-refactor queue state was ephemeral. Treat as completed approval, not in-progress.
return 'complete';
}
if (deliveryState === 'complete') {
return 'complete';
}
if (deliveryState === 'error' || deliveryState === 'cancelled') {
return 'failed';
}
return 'in_progress';
};
@@ -1,7 +1,8 @@
import type { Book, RequestRecord, StatusData } from '../../types';
import { getDownloadsCount } from '../../types';
import { STATUS_LABELS, isActiveDownloadStatus } from './activityStyles.js';
import type { ActivityItem, ActivityVisualStatus } from './activityTypes';
import { buildTimingText, parseBackendTimestamp } from './activityTiming';
import type { ActivityItem, FormatPill, ActivityVisualStatus } from './activityTypes';
export type DownloadStatusKey = Extract<
keyof StatusData,
@@ -82,6 +83,42 @@ const getDownloadProgress = (
return undefined;
};
// Fork: audio file formats, to colour a pill when the content type is not given.
const AUDIO_FILE_FORMATS = new Set([
'mp3',
'm4b',
'm4a',
'aac',
'flac',
'ogg',
'opus',
'wav',
'aax',
]);
const formatKind = (
contentType: string | undefined,
fileFormat: string | undefined,
): FormatPill['kind'] | undefined => {
const type = contentType?.toLowerCase();
if (type?.includes('audio')) return 'audiobook';
if (type) return 'ebook';
const format = fileFormat?.toLowerCase();
if (!format) return undefined;
return AUDIO_FILE_FORMATS.has(format) ? 'audiobook' : 'ebook';
};
const buildFormatPill = (
contentType: string | undefined,
fileFormat: string | undefined,
): FormatPill | undefined => {
const kind = formatKind(contentType, fileFormat);
if (!kind) return undefined;
const label = fileFormat?.toUpperCase() || (kind === 'audiobook' ? 'Audiobook' : 'Ebook');
return { label, kind };
};
/** A download from the status payload as a card item, with its format pill and timing text. */
export const downloadToActivityItem = (book: Book, statusKey: DownloadStatusKey): ActivityItem => {
const visualStatus = statusKeyToVisualStatus(statusKey);
const requestId =
@@ -91,12 +128,26 @@ export const downloadToActivityItem = (book: Book, statusKey: DownloadStatusKey)
const downloadsCount = getDownloadsCount(book);
const downloadsText =
downloadsCount != null ? `${downloadsCount.toLocaleString()} downloads` : undefined;
const formatPill = buildFormatPill(
toOptionalText(book.content_type),
toOptionalText(book.format),
);
// Fork: a finished download says how long it took and how long ago it ended.
const timingText =
statusKey === 'complete' || statusKey === 'error' || statusKey === 'cancelled'
? buildTimingText({
startedMs: toEpochMillis(book.added_time),
finishedMs: toEpochMillis(book.finished_at),
showDuration: statusKey === 'complete',
})
: undefined;
const metaLine = joinMetaParts([
toOptionalText(book.format)?.toUpperCase(),
formatPill ? undefined : toOptionalText(book.format)?.toUpperCase(),
toOptionalText(book.size),
toOptionalText(book.source_display_name) || toSourceLabel(book.source),
downloadsText,
toOptionalText(book.username),
timingText,
]);
const progress = getDownloadProgress(visualStatus, book.progress);
const statusDetail = toOptionalText(book.status_message);
@@ -110,6 +161,8 @@ export const downloadToActivityItem = (book: Book, statusKey: DownloadStatusKey)
author: toText(book.author, 'Unknown author'),
preview: toOptionalText(book.preview),
metaLine,
formatPill,
reattached: book.reattached === true ? true : undefined,
statusLabel: STATUS_LABELS[visualStatus],
statusDetail,
progress,
@@ -138,24 +191,31 @@ const requestStatusToVisualStatus = (status: RequestRecord['status']): ActivityV
const buildRequestMetaLine = (
record: RequestRecord,
bookData: Record<string, unknown>,
_bookData: Record<string, unknown>,
releaseData: Record<string, unknown>,
viewerRole: 'user' | 'admin',
): string => {
// Fork: a request that has been delivered says how long ago. The migration backfills this
// stamp from SQLite's zone-free UTC created_at, so it is parsed as UTC, not local time.
const deliveredMs = parseBackendTimestamp(record.delivery_updated_at);
const delivered =
record.status === 'fulfilled' && record.delivery_state === 'complete' && deliveredMs > 0
? buildTimingText({ finishedMs: deliveredMs, showDuration: false, agoPrefix: 'delivered' })
: undefined;
if (record.request_level === 'book') {
const contentType = toOptionalText(record.content_type || bookData.content_type)?.toLowerCase();
const requestTypeLabel = contentType === 'audiobook' ? 'Audiobook request' : 'Book request';
// Fork: the card shows the format as a coloured pill, so the line only names the requester.
const username = viewerRole === 'admin' ? toOptionalText(record.username) : undefined;
return joinMetaParts([requestTypeLabel, username]);
return joinMetaParts([username, delivered]);
}
const format = toOptionalText(releaseData.format)?.toUpperCase();
// Fork: the release format is shown as the card's pill.
const size = toOptionalText(releaseData.size);
const source = toSourceLabel(releaseData.source || record.source_hint);
const username = viewerRole === 'admin' ? toOptionalText(record.username) : undefined;
const line = joinMetaParts([format, size, source, username]);
return line || joinMetaParts(['Release request', username]);
const line = joinMetaParts([size, source, username, delivered]);
return line || joinMetaParts(['Release request', username, delivered]);
};
export const requestToActivityItem = (
@@ -178,6 +238,10 @@ export const requestToActivityItem = (
author: toText(bookData.author ?? releaseData.author, 'Unknown author'),
preview: toOptionalText(bookData.preview) || toOptionalText(releaseData.preview),
metaLine: buildRequestMetaLine(record, bookData, releaseData, viewerRole),
formatPill: buildFormatPill(
toOptionalText(record.content_type || bookData.content_type),
record.request_level === 'release' ? toOptionalText(releaseData.format) : undefined,
),
statusLabel: STATUS_LABELS[visualStatus],
adminNote: toOptionalText(record.admin_note),
timestamp,
@@ -0,0 +1,99 @@
// Fork: how long a finished download took and how long ago it ended, as short text for the end of
// a card's meta line ("took 4m · 2h ago"). Text only, so the cards stay as quiet as they are.
const SECOND = 1000;
const MINUTE = 60 * SECOND;
const HOUR = 60 * MINUTE;
const DAY = 24 * HOUR;
// "YYYY-MM-DD HH:MM:SS" or "YYYY-MM-DDTHH:MM:SS" with no zone: SQLite's CURRENT_TIMESTAMP shape.
const ZONE_FREE_TIMESTAMP = /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?$/;
/**
* Epoch ms for a backend timestamp, or 0 when it is missing or unreadable. A value with no
* zone is UTC (SQLite's CURRENT_TIMESTAMP writes those); an explicit zone or offset is kept.
*/
export const parseBackendTimestamp = (value: string | null | undefined): number => {
if (!value) {
return 0;
}
const text = value.trim();
const parsed = Date.parse(ZONE_FREE_TIMESTAMP.test(text) ? `${text.replace(' ', 'T')}Z` : text);
return Number.isFinite(parsed) ? parsed : 0;
};
/** "45s", "4m", "1h 20m", "3d": the two most useful units, never more. */
export const formatDuration = (ms: number): string => {
if (!Number.isFinite(ms) || ms < 0) {
return '';
}
// Round first, then pick the unit, so 59.6s is "1m" and not "60s".
const seconds = Math.max(Math.round(ms / SECOND), 1);
if (seconds < 60) {
return `${seconds}s`;
}
const totalMinutes = Math.round(ms / MINUTE);
if (totalMinutes < 60) {
return `${totalMinutes}m`;
}
if (totalMinutes < 24 * 60) {
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`;
}
return `${Math.max(Math.floor(ms / DAY), 1)}d`;
};
/** "just now", "12m ago", "3h ago", "2d ago". */
export const formatAgo = (ms: number): string => {
if (!Number.isFinite(ms) || ms < 0) {
return '';
}
if (ms < MINUTE) {
return 'just now';
}
if (ms < HOUR) {
return `${Math.floor(ms / MINUTE)}m ago`;
}
if (ms < DAY) {
return `${Math.floor(ms / HOUR)}h ago`;
}
return `${Math.floor(ms / DAY)}d ago`;
};
interface TimingInput {
/** When it started, epoch ms. 0 or missing when unknown. */
startedMs?: number;
/** When it ended, epoch ms. 0 or missing when unknown. */
finishedMs?: number;
/** Say how long it took (a completed download); an error or cancel only says how long ago. */
showDuration: boolean;
/** Lead the ago text, e.g. "delivered" for a request. */
agoPrefix?: string;
now?: number;
}
/** The text for the end of the meta line, or undefined when nothing is known. */
export const buildTimingText = ({
startedMs = 0,
finishedMs = 0,
showDuration,
agoPrefix,
now = Date.now(),
}: TimingInput): string | undefined => {
if (finishedMs <= 0) {
return undefined;
}
const parts: string[] = [];
if (showDuration && startedMs > 0 && finishedMs >= startedMs) {
const took = formatDuration(finishedMs - startedMs);
if (took) {
parts.push(`took ${took}`);
}
}
const ago = formatAgo(now - finishedMs);
if (ago) {
parts.push(agoPrefix ? `${agoPrefix} ${ago}` : ago);
}
return parts.length > 0 ? parts.join(' · ') : undefined;
};
@@ -14,6 +14,11 @@ export type ActivityVisualStatus =
| 'fulfilled'
| 'rejected';
export interface FormatPill {
label: string;
kind: 'audiobook' | 'ebook';
}
export interface ActivityItem {
id: string;
kind: ActivityKind;
@@ -44,4 +49,10 @@ export interface ActivityItem {
requestLevel?: 'book' | 'release';
requestNote?: string;
requestRecord?: RequestRecord;
// Fork: format shown as a pill coloured by kind, e.g. EPUB in the ebook colour.
formatPill?: FormatPill;
// Fork: the download picked up work that was already under way (shown as a pill).
reattached?: boolean;
// Fork Rejected view only: whether the admin hid this rejected request there.
hiddenInRejected?: boolean;
}
@@ -1,11 +1,19 @@
import type { ActivityDismissTarget } from './ActivitySidebar';
import type { ActivityItem } from './activityTypes';
// A fulfilled request whose download is still under way is not finished.
const ACTIVE_DELIVERY_STATES = new Set(['queued', 'resolving', 'locating', 'downloading']);
/**
* Items the "Clear Completed" button dismisses: every finished download, plus the request
* linked to it once that request is itself finished. The server only dismisses finished
* requests and rejects the whole batch if one is still pending, which a restart can cause
* by reopening the request of a download that already ended.
*
* Also every finished request card that has no download of its own: an approved request
* nothing is still downloading for (auto-download closes a request whose book is already
* in the library without downloading anything), or a cancelled one. Rejected requests
* stay; they have their own view.
*/
export const getClearCompletedTargets = (
visibleItems: ActivityItem[],
@@ -21,6 +29,20 @@ export const getClearCompletedTargets = (
const seen = new Set<string>();
visibleItems.forEach((item) => {
if (item.kind === 'request' && typeof item.requestId === 'number') {
const record = item.requestRecord;
const finished =
record?.status === 'cancelled' ||
(record?.status === 'fulfilled' &&
!ACTIVE_DELIVERY_STATES.has(record.delivery_state ?? ''));
const requestKey = `request:${item.requestId}`;
if (finished && !pendingRequestIds.has(item.requestId) && !seen.has(requestKey)) {
seen.add(requestKey);
targets.push({ itemType: 'request', itemKey: requestKey });
}
return;
}
const isTerminalDownload =
item.kind === 'download' &&
(item.visualStatus === 'complete' ||
@@ -31,6 +31,7 @@ const REQUEST_POLICY_OVERRIDE_KEYS: Array<keyof PerUserSettings> = [
'REQUEST_POLICY_RULES',
'MAX_PENDING_REQUESTS_PER_USER',
'REQUESTS_ALLOW_NOTES',
'REQUESTS_ALLOW_BOOK_REQUEST_WITH_DOWNLOAD',
];
const requestPolicyHeading: HeadingFieldConfig = {
@@ -22,6 +22,7 @@ export interface PerUserSettings {
REQUEST_POLICY_RULES?: Array<Record<string, unknown>>;
MAX_PENDING_REQUESTS_PER_USER?: number;
REQUESTS_ALLOW_NOTES?: boolean;
REQUESTS_ALLOW_BOOK_REQUEST_WITH_DOWNLOAD?: boolean;
}
export interface CreateUserFormState {
+11 -29
View File
@@ -3,6 +3,7 @@ import { useLayoutEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { useMountEffect } from '../../hooks/useMountEffect';
import { getTooltipViewportNudge } from '../../utils/tooltipViewportNudge';
interface TooltipProps {
content: ReactNode;
@@ -112,44 +113,25 @@ export function Tooltip({
return;
}
const tooltipRect = tooltipRef.current.getBoundingClientRect();
const viewportPadding = 6;
const nudge = getTooltipViewportNudge(
tooltipRef.current.getBoundingClientRect(),
window.innerWidth,
window.innerHeight,
);
let deltaX = 0;
let deltaY = 0;
if (tooltipRect.width + viewportPadding * 2 > window.innerWidth) {
deltaX = viewportPadding - tooltipRect.left;
} else {
if (tooltipRect.left < viewportPadding) {
deltaX = viewportPadding - tooltipRect.left;
} else if (tooltipRect.right > window.innerWidth - viewportPadding) {
deltaX = window.innerWidth - viewportPadding - tooltipRect.right;
}
}
if (tooltipRect.height + viewportPadding * 2 > window.innerHeight) {
deltaY = viewportPadding - tooltipRect.top;
} else {
if (tooltipRect.top < viewportPadding) {
deltaY = viewportPadding - tooltipRect.top;
} else if (tooltipRect.bottom > window.innerHeight - viewportPadding) {
deltaY = window.innerHeight - viewportPadding - tooltipRect.bottom;
}
}
if (deltaX !== 0 || deltaY !== 0) {
if (nudge.x !== 0 || nudge.y !== 0) {
// Genuine measure-and-adjust: the tooltip must be laid out before we know
// whether it overflows the viewport. The loop converges in one pass because
// the corrected position yields deltaX/deltaY of 0 on the next run.
// the corrected position measures as no nudge on the next run, which holds
// only because the nudge ignores sub-pixel overflow.
// oxlint-disable-next-line react/set-state-in-effect
setCoords((current) => {
if (!current) {
return current;
}
return {
top: current.top + deltaY,
left: current.left + deltaX,
top: current.top + nudge.y,
left: current.left + nudge.x,
};
});
}
+2 -2
View File
@@ -280,7 +280,7 @@ interface UseActivityResult {
prefetchActivityHistory: () => void;
refreshActivitySnapshot: () => Promise<void>;
refreshHistoryIfLoaded: () => void;
handleActivityTabChange: (tab: 'all' | 'downloads' | 'requests' | 'history') => void;
handleActivityTabChange: (tab: 'all' | 'downloads' | 'requests' | 'history' | 'rejected') => void;
resetActivity: () => void;
handleActivityHistoryLoadMore: () => void;
handleRequestDismiss: (requestId: number) => void;
@@ -366,7 +366,7 @@ export const useActivity = ({
}, [isAuthenticated, resetActivityHistory]);
const handleActivityTabChange = useCallback(
(tab: 'all' | 'downloads' | 'requests' | 'history') => {
(tab: 'all' | 'downloads' | 'requests' | 'history' | 'rejected') => {
if (tab !== 'history' || activityHistoryLoaded || activityHistoryLoading) {
return;
}
+14 -10
View File
@@ -1,6 +1,7 @@
import { useState, useCallback } from 'react';
import type { StatusData, ButtonStateInfo } from '../types';
import { addFailedRelease, failedDownloadButtonState } from '../utils/failedDownloadButton';
interface UseDownloadTrackingReturn {
bookToReleaseMap: Record<string, string[]>;
@@ -12,6 +13,12 @@ interface UseDownloadTrackingReturn {
getUniversalButtonState: (bookId: string) => ButtonStateInfo;
}
/**
* Download button state for books and releases, from the queue status and this session's picks.
*
* In universal mode a book's button follows the releases downloaded for it, so the hook keeps
* which releases belong to which book and which books finished during this session.
*/
export function useDownloadTracking(currentStatus: StatusData): UseDownloadTrackingReturn {
// Track mapping of metadata book IDs to release source IDs for universal mode
const [bookToReleaseMap, setBookToReleaseMap] = useState<Record<string, string[]>>({});
@@ -38,9 +45,7 @@ export function useDownloadTracking(currentStatus: StatusData): UseDownloadTrack
const getButtonState = useCallback(
(bookId: string): ButtonStateInfo => {
if (currentStatus.error && currentStatus.error[bookId]) {
return currentStatus.error[bookId].retry_available === true
? { text: 'Retry', state: 'download' }
: { text: 'Failed', state: 'error' };
return failedDownloadButtonState(currentStatus.error[bookId]);
}
if (currentStatus.complete && currentStatus.complete[bookId]) {
return { text: 'Downloaded', state: 'complete' };
@@ -67,7 +72,11 @@ export function useDownloadTracking(currentStatus: StatusData): UseDownloadTrack
[currentStatus],
);
// Get button state for a metadata book in universal mode
/**
* The button for a metadata book in universal mode, from every release tracked for it. An
* active download wins, then a finished one, then a failure that can be retried, then any
* other failure.
*/
const getUniversalButtonState = useCallback(
(bookId: string): ButtonStateInfo => {
const releaseIds = bookToReleaseMap[bookId] || [];
@@ -111,12 +120,7 @@ export function useDownloadTracking(currentStatus: StatusData): UseDownloadTrack
bestState = { text: 'Downloaded', state: 'complete' };
foundActiveState = true;
} else if (currentStatus.error && currentStatus.error[releaseId]) {
if (bestState.state === 'download') {
bestState =
currentStatus.error[releaseId].retry_available === true
? { text: 'Retry', state: 'download' }
: { text: 'Failed', state: 'error' };
}
bestState = addFailedRelease(bestState, currentStatus.error[releaseId]);
}
}
}
@@ -0,0 +1,78 @@
import { useCallback, useRef, useState } from 'react';
import { requestToActivityItem } from '../components/activity/activityMappers';
import type { ActivityItem } from '../components/activity/activityTypes';
import {
listHiddenRejectedRequestIds,
listRejectedAdminRequests,
setRejectedRequestHidden,
} from '../services/api';
interface UseRejectedRequestsResult {
rejectedItems: ActivityItem[];
rejectedLoading: boolean;
loadRejected: () => Promise<void>;
setRejectedHidden: (requestId: number, hidden: boolean) => Promise<void>;
}
/**
* Rejected requests for the admin Rejected view. Unlike the activity snapshot this includes
* requests that were cleared from the list, which is where most of them are. Each item says
* whether the admin hid it from this view (its own state, not the activity list's clear).
* Loaded from the events that change it, like History.
*/
export const useRejectedRequests = (): UseRejectedRequestsResult => {
const [rejectedItems, setRejectedItems] = useState<ActivityItem[]>([]);
const [rejectedLoading, setRejectedLoading] = useState(false);
const latestLoad = useRef(0);
/** Fetch the rejected requests and hidden ids; a stale load is dropped and an error empties the list. */
const loadRejected = useCallback(async () => {
latestLoad.current += 1;
const loadId = latestLoad.current;
setRejectedLoading(true);
try {
const [records, hiddenIds] = await Promise.all([
listRejectedAdminRequests(),
listHiddenRejectedRequestIds(),
]);
if (loadId !== latestLoad.current) {
return;
}
const hidden = new Set(hiddenIds);
const items = records.map((record) => {
const item = requestToActivityItem(record, 'admin');
item.hiddenInRejected = hidden.has(record.id);
return item;
});
items.sort((a, b) => b.timestamp - a.timestamp);
setRejectedItems(items);
} catch (error) {
console.error('Loading rejected requests failed:', error);
if (loadId === latestLoad.current) {
setRejectedItems([]);
}
} finally {
if (loadId === latestLoad.current) {
setRejectedLoading(false);
}
}
}, []);
/** Hide or show one request in the view; the item is left as it was when the call fails. */
const setRejectedHidden = useCallback(async (requestId: number, hidden: boolean) => {
try {
await setRejectedRequestHidden(requestId, hidden);
} catch (error) {
console.error('Changing hidden state failed:', error);
return;
}
setRejectedItems((current) =>
current.map((item) =>
item.requestId === requestId ? Object.assign({}, item, { hiddenInRejected: hidden }) : item,
),
);
}, []);
return { rejectedItems, rejectedLoading, loadRejected, setRejectedHidden };
};
@@ -22,11 +22,15 @@ interface UseRequestPolicyReturn {
isAdmin: boolean;
requestsEnabled: boolean;
allowNotes: boolean;
allowBookRequestWithDownload: boolean;
getDefaultMode: (contentType: string) => RequestPolicyMode;
getSourceMode: (source: string, contentType: string) => RequestPolicyMode;
refresh: (options?: { force?: boolean }) => Promise<RequestPolicyResponse | null>;
}
/**
* Fetch and cache the user's request policy, and resolve the mode for a source and content type.
*/
export function useRequestPolicy({
enabled,
isAdmin,
@@ -102,6 +106,9 @@ export function useRequestPolicy({
isAdmin: enabled && policy ? policy.is_admin : isAdmin,
requestsEnabled: enabled ? (policy?.requests_enabled ?? false) : false,
allowNotes: enabled ? (policy?.allow_notes ?? true) : true,
allowBookRequestWithDownload: enabled
? (policy?.allow_book_request_with_download ?? false)
: false,
getDefaultMode,
getSourceMode,
refresh,
+19
View File
@@ -709,6 +709,25 @@ export const rejectAdminRequest = async (
});
};
export const listRejectedAdminRequests = async (): Promise<RequestRecord[]> => {
return fetchJSON<RequestRecord[]>(`${API.adminRequests}?status=rejected`);
};
/** Ids of the rejected requests hidden from the admin Rejected view; empty when the field is missing. */
export const listHiddenRejectedRequestIds = async (): Promise<number[]> => {
const response = await fetchJSON<{ request_ids: number[] }>(
`${API.adminRequests}/rejected-hidden`,
);
return Array.isArray(response.request_ids) ? response.request_ids : [];
};
export const setRejectedRequestHidden = async (id: number, hidden: boolean): Promise<void> => {
await fetchJSON(`${API.adminRequests}/${encodeURIComponent(String(id))}/rejected-hidden`, {
method: 'POST',
body: JSON.stringify({ hidden }),
});
};
export const reopenAdminRequest = async (
id: number,
body: RejectAdminRequestBody = {},
@@ -16,6 +16,18 @@ const makeItem = (overrides: Partial<ActivityItem> = {}): ActivityItem => ({
...overrides,
});
const rejectedActionKinds = (hiddenInRejected?: boolean) =>
buildActivityCardModel(
makeItem({
kind: 'request',
visualStatus: 'rejected',
requestId: 47,
downloadBookId: undefined,
hiddenInRejected,
}),
true,
).actions.map((action) => action.kind);
describe('activityCardModel', () => {
it('shows ownership in badge text for admin pending requests', () => {
const model = buildActivityCardModel(
@@ -30,7 +42,7 @@ describe('activityCardModel', () => {
);
expect(model.badges.length).toBe(1);
expect(model.badges[0]?.text).toBe('Needs review · testuser');
expect(model.badges[0]?.text).toBe('No match yet');
});
it('keeps pending label for requester-side pending requests', () => {
@@ -273,4 +285,152 @@ describe('activityCardModel', () => {
expect(model.actions.map((action) => action.kind)).toEqual(['request-dismiss']);
});
it('tells an admin who declined a request and when', () => {
const record = {
id: 45,
user_id: 7,
status: 'rejected' as const,
source_hint: null,
content_type: 'ebook' as const,
request_level: 'book' as const,
policy_mode: 'request_book' as const,
book_data: { title: 'My Dog Thinks It Is a Cat' },
release_data: null,
note: null,
admin_note: 'already in the library',
reviewed_by: 1,
reviewed_at: '2026-09-22T05:31:12Z',
created_at: '2026-09-20T03:37:29Z',
updated_at: '2026-09-22T05:31:12Z',
username: 'reader',
reviewer_username: 'admin@example.com',
};
const item = makeItem({
kind: 'request',
visualStatus: 'rejected',
requestId: 45,
downloadBookId: undefined,
adminNote: record.admin_note,
requestRecord: record,
});
const adminLine = buildActivityCardModel(item, true).noteLine;
expect(adminLine).toMatch(/^Declined by admin@example\.com · .+: "already in the library"$/);
const requesterLine = buildActivityCardModel(item, false).noteLine;
expect(requesterLine).toBe('"already in the library"');
});
it("says a decline the automation made was automatic, not a person's", () => {
const record = {
id: 46,
user_id: 7,
status: 'rejected' as const,
source_hint: null,
content_type: 'audiobook' as const,
request_level: 'book' as const,
policy_mode: 'request_book' as const,
book_data: { title: 'Sauron Defeated' },
release_data: null,
note: null,
admin_note: '[auto] No audiobook found on Audible (US/UK) or our sources, 2026-10-11.',
reviewed_by: 1,
reviewed_at: '2026-10-11T22:40:00Z',
created_at: '2026-10-01T03:37:29Z',
updated_at: '2026-10-11T22:40:00Z',
username: 'reader',
reviewer_username: 'rob@martin-up.com',
};
const item = makeItem({
kind: 'request',
visualStatus: 'rejected',
requestId: 46,
downloadBookId: undefined,
adminNote: record.admin_note,
requestRecord: record,
});
const adminLine = buildActivityCardModel(item, true).noteLine;
expect(adminLine).toMatch(/^Declined automatically · .+: "No audiobook found on Audible/);
expect(adminLine).not.toContain('rob@martin-up.com');
expect(adminLine).not.toContain('[auto]');
expect(buildActivityCardModel(item, false).noteLine).toBe(
'Automatic: No audiobook found on Audible (US/UK) or our sources, 2026-10-11.',
);
});
it('says an approval the automation made was automatic', () => {
const item = makeItem({
kind: 'request',
visualStatus: 'fulfilled',
requestId: 47,
downloadBookId: undefined,
adminNote: '[auto] Already in the library.',
requestRecord: {
id: 47,
user_id: 7,
status: 'fulfilled' as const,
source_hint: null,
content_type: 'ebook' as const,
request_level: 'book' as const,
policy_mode: 'request_book' as const,
book_data: { title: 'X' },
release_data: null,
note: null,
admin_note: '[auto] Already in the library.',
reviewed_by: 1,
reviewed_at: '2026-10-11T22:40:00Z',
created_at: '2026-10-01T03:37:29Z',
updated_at: '2026-10-11T22:40:00Z',
username: 'reader',
},
});
expect(buildActivityCardModel(item, true).noteLine).toBe('Automatic: Already in the library.');
});
it('says a pending request the automation reopened is retrying automatically', () => {
const record = {
id: 48,
user_id: 7,
status: 'pending' as const,
source_hint: null,
content_type: 'audiobook' as const,
request_level: 'book' as const,
policy_mode: 'request_book' as const,
book_data: { title: 'The Big Bad Wolf' },
release_data: null,
note: null,
admin_note: '[auto] retry batch 5 of 7 after the cancelled-download reopen',
reviewed_by: null,
reviewed_at: null,
created_at: '2026-10-01T03:37:29Z',
updated_at: '2026-10-16T22:40:00Z',
username: 'reader',
last_failure_reason: 'Peer transfer failed',
};
const model = buildActivityCardModel(
makeItem({ kind: 'request', visualStatus: 'pending', requestId: 48, requestRecord: record }),
true,
);
expect(model.badges[0]?.text).toBe('Retrying automatically');
expect(model.badges[0]?.visualStatus).not.toBe('error');
});
it('offers hide or unhide on rejected cards only in the Rejected view', () => {
expect(rejectedActionKinds()).toEqual(['request-reopen', 'request-dismiss']);
expect(rejectedActionKinds(false)).toEqual([
'request-reopen',
'request-hide',
'request-dismiss',
]);
expect(rejectedActionKinds(true)).toEqual([
'request-reopen',
'request-unhide',
'request-dismiss',
]);
});
});
@@ -0,0 +1,122 @@
import { describe, expect, it } from 'vitest';
import {
isAutomaticNote,
isRetryingAutomatically,
stripAutomaticMarker,
} from '../components/activity/activityAutomation';
import {
getActivityCategory,
getCategoryHint,
getCategoryLabel,
getVisibleCategoryOrder,
} from '../components/activity/activityCategories';
import type { ActivityItem } from '../components/activity/activityTypes';
import type { RequestRecord } from '../types';
const pendingItem = (extra: Partial<RequestRecord>): ActivityItem => ({
id: 'request-1',
kind: 'request',
visualStatus: 'pending',
title: 'T',
author: 'A',
metaLine: '',
statusLabel: 'Pending',
timestamp: 0,
requestRecord: {
id: 1,
user_id: 7,
status: 'pending',
source_hint: null,
content_type: 'audiobook',
request_level: 'book',
policy_mode: 'request_book',
book_data: { title: 'T' },
release_data: null,
note: null,
admin_note: null,
reviewed_by: null,
reviewed_at: null,
created_at: '2026-10-01T00:00:00Z',
updated_at: '2026-10-01T00:00:00Z',
username: 'reader',
...extra,
},
});
describe('automatic notes', () => {
it('recognises and strips the marker', () => {
expect(isAutomaticNote('[auto] Already in the library.')).toBe(true);
expect(isAutomaticNote(' [AUTO] x')).toBe(true);
expect(isAutomaticNote('a person wrote this')).toBe(false);
expect(isAutomaticNote(null)).toBe(false);
expect(stripAutomaticMarker('[auto] Already in the library.')).toBe('Already in the library.');
});
});
describe('isRetryingAutomatically', () => {
it('is true for a pending request the automation reopened', () => {
expect(isRetryingAutomatically({ status: 'pending', admin_note: '[auto] retry batch 5' })).toBe(
true,
);
expect(
isRetryingAutomatically({
status: 'pending',
last_failure_reason: 'Retrying after a 20-minute cooldown (failed)',
}),
).toBe(true);
expect(
isRetryingAutomatically({
status: 'pending',
last_failure_reason: 'Interrupted by a restart',
}),
).toBe(true);
});
it('is false when nothing was reopened, or the request is not pending', () => {
expect(isRetryingAutomatically({ status: 'pending' })).toBe(false);
expect(
isRetryingAutomatically({
status: 'pending',
last_failure_reason: 'All download sources failed',
}),
).toBe(false);
expect(isRetryingAutomatically({ status: 'rejected', admin_note: '[auto] retry' })).toBe(false);
expect(isRetryingAutomatically(null)).toBe(false);
});
});
describe('request categories', () => {
it('puts a reopened request under Retrying Automatically', () => {
expect(getActivityCategory(pendingItem({ admin_note: '[auto] retry batch 5' }))).toBe(
'auto_retry',
);
});
it('keeps a request nothing matched under No Match Yet', () => {
expect(getActivityCategory(pendingItem({}))).toBe('needs_review');
expect(
getActivityCategory(pendingItem({ last_failure_reason: 'All download sources failed' })),
).toBe('needs_review');
});
it('labels the sections for what they mean', () => {
expect(getCategoryLabel('needs_review', true)).toBe('No Match Yet');
expect(getCategoryLabel('needs_review', false)).toBe('Waiting');
expect(getCategoryLabel('auto_retry', true)).toBe('Retrying Automatically');
});
it('says what is expected of an admin, and nothing to anyone else', () => {
expect(getCategoryHint('needs_review', true)).toContain('Searched automatically');
expect(getCategoryHint('auto_retry', true)).toContain('Nothing to do');
expect(getCategoryHint('needs_review', false)).toBeUndefined();
expect(getCategoryHint('complete', true)).toBeUndefined();
});
it('shows the retrying section on the requests and all tabs but not under downloads', () => {
expect(getVisibleCategoryOrder('requests')).toContain('auto_retry');
expect(getVisibleCategoryOrder('all')).toContain('auto_retry');
expect(getVisibleCategoryOrder('downloads')).not.toContain('auto_retry');
expect(getVisibleCategoryOrder('history')).toEqual([]);
});
});
+129 -6
View File
@@ -85,7 +85,8 @@ describe('activityMappers.downloadToActivityItem', () => {
expect(item.kind).toBe('download');
expect(item.visualStatus).toBe('queued');
expect(item.statusLabel).toBe('Queued');
expect(item.metaLine).toBe('EPUB · 3 MB · Direct Download · alice');
expect(item.metaLine).toBe('3 MB · Direct Download · alice');
expect(item.formatPill).toEqual({ label: 'EPUB', kind: 'ebook' });
expect(item.progress).toBe(5);
expect(item.progressAnimated).toBe(true);
expect(item.timestamp).toBe(123);
@@ -115,7 +116,8 @@ describe('activityMappers.downloadToActivityItem', () => {
it('omits empty meta parts cleanly', () => {
const item = downloadToActivityItem(makeBook({ format: 'epub', size: undefined }), 'error');
expect(item.metaLine).toBe('EPUB');
expect(item.metaLine).toBe('');
expect(item.formatPill).toEqual({ label: 'EPUB', kind: 'ebook' });
});
});
@@ -139,7 +141,8 @@ describe('activityMappers.requestToActivityItem', () => {
expect(item.kind).toBe('request');
expect(item.visualStatus).toBe('pending');
expect(item.metaLine).toBe('EPUB · 2 MB · Prowlarr · alice');
expect(item.metaLine).toBe('2 MB · Prowlarr · alice');
expect(item.formatPill).toEqual({ label: 'EPUB', kind: 'ebook' });
expect(item.requestId).toBe(42);
expect(item.requestLevel).toBe('release');
expect(item.requestNote).toBe('please add this');
@@ -157,7 +160,7 @@ describe('activityMappers.requestToActivityItem', () => {
'user',
);
expect(item.metaLine).toBe('Book request');
expect(item.metaLine).toBe('');
});
it('maps audiobook book-level request with audiobook label', () => {
@@ -171,7 +174,7 @@ describe('activityMappers.requestToActivityItem', () => {
'admin',
);
expect(item.metaLine).toBe('Audiobook request · alice');
expect(item.metaLine).toBe('alice');
});
it('maps rejected requests with admin note', () => {
@@ -189,7 +192,7 @@ describe('activityMappers.requestToActivityItem', () => {
it('does not append username to meta line for user viewer role', () => {
const item = requestToActivityItem(makeRequest(), 'user');
expect(item.metaLine).toBe('EPUB · 2 MB · Prowlarr');
expect(item.metaLine).toBe('2 MB · Prowlarr');
});
});
@@ -221,3 +224,123 @@ describe('activityMappers.linkedDownloadIdForRequest', () => {
expect(linkedDownloadIdForRequest(item, new Map([[42, 'dl_abc']]))).toBeNull();
});
});
describe('activityMappers format pill', () => {
it('colours an audio file as an audiobook even without a content type', () => {
const item = downloadToActivityItem(
{ id: 'a1', title: 'Young Zaphod', author: 'Douglas Adams', format: 'm4b' },
'complete',
);
expect(item.formatPill).toEqual({ label: 'M4B', kind: 'audiobook' });
});
it('uses the content type over the file format', () => {
const item = downloadToActivityItem(
{
id: 'a2',
title: 'Young Zaphod',
author: 'Douglas Adams',
format: 'mp3',
content_type: 'audiobook',
},
'complete',
);
expect(item.formatPill).toEqual({ label: 'MP3', kind: 'audiobook' });
});
it('labels a book-level request by its type', () => {
const item = requestToActivityItem(
makeRequest({ request_level: 'book', content_type: 'audiobook', release_data: null }),
'admin',
);
expect(item.formatPill).toEqual({ label: 'Audiobook', kind: 'audiobook' });
});
});
describe('activityMappers reattached pill', () => {
it('carries the flag for a download that picked up work already under way', () => {
const item = downloadToActivityItem(
{ id: 'r1', title: 'Cross Down', author: 'James Patterson', reattached: true },
'downloading',
);
expect(item.reattached).toBe(true);
});
it('leaves it off for an ordinary download', () => {
const item = downloadToActivityItem(
{ id: 'r2', title: 'Cross Down', author: 'James Patterson' },
'downloading',
);
expect(item.reattached).toBeUndefined();
});
});
describe('activityMappers timing', () => {
const nowSeconds = Math.floor(Date.now() / 1000);
it('adds how long a completed download took and how long ago to the meta line', () => {
const item = downloadToActivityItem(
{
id: 't1',
title: 'Cross Down',
author: 'James Patterson',
format: 'm4b',
added_time: nowSeconds - 2 * 3600 - 240,
finished_at: nowSeconds - 2 * 3600,
},
'complete',
);
expect(item.metaLine).toContain('took 4m');
expect(item.metaLine).toContain('2h ago');
});
it('only says how long ago for a failed download', () => {
const item = downloadToActivityItem(
{
id: 't2',
title: 'X',
author: 'Y',
added_time: nowSeconds - 600,
finished_at: nowSeconds - 300,
},
'error',
);
expect(item.metaLine).toContain('5m ago');
expect(item.metaLine).not.toContain('took');
});
it('adds nothing for a download that is still running', () => {
const item = downloadToActivityItem(
{ id: 't3', title: 'X', author: 'Y', added_time: nowSeconds - 600, finished_at: null },
'downloading',
);
expect(item.metaLine).not.toContain('ago');
});
it('says a request was delivered how long ago', () => {
const item = requestToActivityItem(
makeRequest({
status: 'fulfilled',
delivery_state: 'complete',
delivery_updated_at: new Date(Date.now() - 3 * 3600 * 1000).toISOString(),
}),
'admin',
);
expect(item.metaLine).toContain('delivered 3h ago');
});
it('reads a migrated zone-free delivery stamp as UTC', () => {
const delivered = new Date(Date.now() - 3 * 3600 * 1000);
const stamp = delivered.toISOString().slice(0, 19).replace('T', ' ');
const item = requestToActivityItem(
makeRequest({ status: 'fulfilled', delivery_state: 'complete', delivery_updated_at: stamp }),
'admin',
);
expect(item.metaLine).toContain('delivered 3h ago');
});
it('says nothing about delivery for a request that is still waiting', () => {
const item = requestToActivityItem(makeRequest({ status: 'pending' }), 'admin');
expect(item.metaLine).not.toContain('delivered');
});
});
@@ -0,0 +1,118 @@
import { describe, expect, it } from 'vitest';
import {
buildTimingText,
formatAgo,
formatDuration,
parseBackendTimestamp,
} from '../components/activity/activityTiming';
const SECOND = 1000;
const MINUTE = 60 * SECOND;
const HOUR = 60 * MINUTE;
const DAY = 24 * HOUR;
describe('formatDuration', () => {
it('uses the two most useful units', () => {
expect(formatDuration(500)).toBe('1s');
expect(formatDuration(45 * SECOND)).toBe('45s');
expect(formatDuration(4 * MINUTE)).toBe('4m');
expect(formatDuration(HOUR + 20 * MINUTE)).toBe('1h 20m');
expect(formatDuration(3 * HOUR)).toBe('3h');
expect(formatDuration(3 * DAY + 5 * HOUR)).toBe('3d');
});
it('rounds before picking the unit, so it never says 60 of anything', () => {
expect(formatDuration(59.6 * SECOND)).toBe('1m');
expect(formatDuration(59.6 * MINUTE)).toBe('1h');
expect(formatDuration(HOUR + 59 * MINUTE + 45 * SECOND)).toBe('2h');
expect(formatDuration(23 * HOUR + 59 * MINUTE + 45 * SECOND)).toBe('1d');
});
it('says nothing for a value that makes no sense', () => {
expect(formatDuration(-1)).toBe('');
expect(formatDuration(Number.NaN)).toBe('');
});
});
describe('formatAgo', () => {
it('reads as a relative time', () => {
expect(formatAgo(10 * SECOND)).toBe('just now');
expect(formatAgo(12 * MINUTE)).toBe('12m ago');
expect(formatAgo(3 * HOUR + 40 * MINUTE)).toBe('3h ago');
expect(formatAgo(2 * DAY)).toBe('2d ago');
});
it('says nothing for a time in the future', () => {
expect(formatAgo(-5 * MINUTE)).toBe('');
});
});
describe('buildTimingText', () => {
const now = 1_000_000_000_000;
it('says how long a completed download took and how long ago it ended', () => {
const text = buildTimingText({
startedMs: now - 2 * HOUR - 4 * MINUTE,
finishedMs: now - 2 * HOUR,
showDuration: true,
now,
});
expect(text).toBe('took 4m · 2h ago');
});
it('only says how long ago when the duration is not wanted', () => {
expect(
buildTimingText({
startedMs: now - 3 * HOUR,
finishedMs: now - HOUR,
showDuration: false,
now,
}),
).toBe('1h ago');
});
it('leads the ago text with a prefix when one is given', () => {
expect(
buildTimingText({
finishedMs: now - 3 * HOUR,
showDuration: false,
agoPrefix: 'delivered',
now,
}),
).toBe('delivered 3h ago');
});
it('skips the duration when the start is unknown or after the end', () => {
expect(buildTimingText({ finishedMs: now - MINUTE * 5, showDuration: true, now })).toBe(
'5m ago',
);
expect(
buildTimingText({ startedMs: now, finishedMs: now - MINUTE * 5, showDuration: true, now }),
).toBe('5m ago');
});
it('says nothing without a finish time', () => {
expect(buildTimingText({ startedMs: now - HOUR, showDuration: true, now })).toBeUndefined();
expect(buildTimingText({ finishedMs: 0, showDuration: true, now })).toBeUndefined();
});
});
describe('parseBackendTimestamp', () => {
it('reads a zone-free SQLite stamp as UTC', () => {
expect(parseBackendTimestamp('2026-03-01 12:00:00')).toBe(Date.UTC(2026, 2, 1, 12));
expect(parseBackendTimestamp('2026-03-01T12:00:00')).toBe(Date.UTC(2026, 2, 1, 12));
});
it('keeps an explicit zone or offset', () => {
expect(parseBackendTimestamp('2026-03-01T12:00:00Z')).toBe(Date.UTC(2026, 2, 1, 12));
expect(parseBackendTimestamp('2026-03-01T12:00:00+02:00')).toBe(Date.UTC(2026, 2, 1, 10));
});
it('is 0 for nothing or nonsense', () => {
expect(parseBackendTimestamp(undefined)).toBe(0);
expect(parseBackendTimestamp(null)).toBe(0);
expect(parseBackendTimestamp('')).toBe(0);
expect(parseBackendTimestamp('yesterday')).toBe(0);
});
});
@@ -98,4 +98,32 @@ describe('getClearCompletedTargets', () => {
expect(targets).toEqual([]);
});
it('clears a finished request that never had a download', () => {
const closed = makeRequestItem(30, 'fulfilled');
closed.requestRecord = Object.assign({}, closed.requestRecord, {
delivery_state: 'complete' as const,
admin_note: '[auto] Already in the library.',
});
const cancelled = makeRequestItem(31, 'cancelled');
const targets = getClearCompletedTargets([closed, cancelled], [closed, cancelled]);
expect(targets).toEqual([
{ itemType: 'request', itemKey: 'request:30' },
{ itemType: 'request', itemKey: 'request:31' },
]);
});
it('leaves pending, rejected and still-downloading requests alone', () => {
const pending = makeRequestItem(40, 'pending');
const rejected = makeRequestItem(41, 'rejected');
const downloading = makeRequestItem(42, 'fulfilled');
downloading.requestRecord = Object.assign({}, downloading.requestRecord, {
delivery_state: 'downloading' as const,
});
const items = [pending, rejected, downloading];
expect(getClearCompletedTargets(items, items)).toEqual([]);
});
});
@@ -0,0 +1,58 @@
import { describe, it, expect } from 'vitest';
import type { Book } from '../types';
import { addFailedRelease, failedDownloadButtonState } from '../utils/failedDownloadButton';
const failed = (fields: Partial<Book>): Book => ({
id: 'rel-1',
title: 'Dungeon Crawler Carl',
author: 'Matt Dinniman',
...fields,
});
describe('failedDownloadButtonState', () => {
it('offers a failed request download again so the request can be retried with it', () => {
expect(failedDownloadButtonState(failed({ request_id: 7, retry_available: false }))).toEqual({
text: 'Retry',
state: 'download',
});
});
it('offers a retry when the queue can retry the download', () => {
expect(failedDownloadButtonState(failed({ retry_available: true }))).toEqual({
text: 'Retry',
state: 'download',
});
});
it('marks a direct download with nothing to retry from as failed', () => {
expect(failedDownloadButtonState(failed({ retry_available: false }))).toEqual({
text: 'Failed',
state: 'error',
});
});
});
describe('addFailedRelease', () => {
const get = { text: 'Get', state: 'download' } as const;
const retryable = failed({ request_id: 7, retry_available: false });
const final = failed({ id: 'rel-2', retry_available: false });
it('keeps Retry when a release that cannot be retried is found after it', () => {
expect(addFailedRelease(addFailedRelease(get, retryable), final)).toEqual({
text: 'Retry',
state: 'download',
});
});
it('turns Failed into Retry when a retryable release is found after it', () => {
expect(addFailedRelease(addFailedRelease(get, final), retryable)).toEqual({
text: 'Retry',
state: 'download',
});
});
it('marks the book failed when no failed release can be retried', () => {
expect(addFailedRelease(get, final)).toEqual({ text: 'Failed', state: 'error' });
});
});
@@ -4,8 +4,103 @@ import type { ButtonStateInfo } from '../types/index';
import {
applyDirectPolicyModeToButtonState,
applyUniversalPolicyModeToButtonState,
canRequestBookFromReleaseModal,
shouldShowRequestsTab,
} from '../utils/requestPolicyUi';
describe('canRequestBookFromReleaseModal', () => {
const base = {
isBrowseFulfilMode: false,
isAdmin: false,
requestsEnabled: true,
allowBookRequestWithDownload: false,
bookRequestMode: 'download' as const,
};
it('is always on for an admin', () => {
expect(canRequestBookFromReleaseModal({ ...base, isAdmin: true })).toBe(true);
expect(canRequestBookFromReleaseModal({ ...base, isAdmin: true, requestsEnabled: false })).toBe(
true,
);
});
it('is off for a user unless the setting allows it', () => {
expect(canRequestBookFromReleaseModal(base)).toBe(false);
expect(canRequestBookFromReleaseModal({ ...base, allowBookRequestWithDownload: true })).toBe(
true,
);
});
it('is off for a user whose policy for this content type is not Download', () => {
const allowed = { ...base, allowBookRequestWithDownload: true };
expect(canRequestBookFromReleaseModal({ ...allowed, bookRequestMode: 'request_release' })).toBe(
false,
);
expect(canRequestBookFromReleaseModal({ ...allowed, bookRequestMode: 'request_book' })).toBe(
false,
);
expect(canRequestBookFromReleaseModal({ ...allowed, bookRequestMode: 'blocked' })).toBe(false);
});
it('does not depend on the policy mode for an admin', () => {
expect(
canRequestBookFromReleaseModal({ ...base, isAdmin: true, bookRequestMode: 'blocked' }),
).toBe(true);
});
it('needs requests to be on for a user', () => {
expect(
canRequestBookFromReleaseModal({
...base,
requestsEnabled: false,
allowBookRequestWithDownload: true,
}),
).toBe(false);
});
it('is off while fulfilling a request, even for an admin', () => {
expect(
canRequestBookFromReleaseModal({ ...base, isAdmin: true, isBrowseFulfilMode: true }),
).toBe(false);
});
});
describe('shouldShowRequestsTab', () => {
const allDownload = { ebook: 'download' as const, audiobook: 'download' as const };
const base = {
isAdmin: false,
isAuthenticated: true,
requestsEnabled: true,
defaults: allDownload,
allowBookRequestWithDownload: false,
};
it('is hidden for a user who can only download', () => {
expect(shouldShowRequestsTab(base)).toBe(false);
});
it('shows for a user who may request a book alongside downloads', () => {
expect(shouldShowRequestsTab({ ...base, allowBookRequestWithDownload: true })).toBe(true);
});
it('shows when either default asks for requests', () => {
expect(
shouldShowRequestsTab({
...base,
defaults: { ebook: 'download', audiobook: 'request_book' },
}),
).toBe(true);
});
it('is hidden with requests off or no policy, and always shown to an admin', () => {
const allowed = { ...base, allowBookRequestWithDownload: true };
expect(shouldShowRequestsTab({ ...allowed, requestsEnabled: false })).toBe(false);
expect(shouldShowRequestsTab({ ...allowed, defaults: null })).toBe(false);
expect(shouldShowRequestsTab({ ...allowed, isAuthenticated: false })).toBe(false);
expect(shouldShowRequestsTab({ ...base, isAdmin: true, requestsEnabled: false })).toBe(true);
});
});
describe('requestPolicyUi', () => {
const baseDownload: ButtonStateInfo = { text: 'Download', state: 'download' };
@@ -0,0 +1,52 @@
import { describe, it, expect } from 'vitest';
import { getTooltipViewportNudge } from '../utils/tooltipViewportNudge';
const box = (left: number, top: number, width: number, height: number) => ({
left,
top,
width,
height,
right: left + width,
bottom: top + height,
});
describe('getTooltipViewportNudge', () => {
it('leaves a tooltip that fits where it is', () => {
expect(getTooltipViewportNudge(box(100, 100, 80, 30), 1600, 900)).toEqual({ x: 0, y: 0 });
});
// The two measurements Firefox took of the "Stop download" tooltip in #1646,
// before and after the nudge, in a 1600x900 window.
it('moves a tooltip that runs past the right edge back inside', () => {
const rect = box(1536.2166748046875, 185, 67.566680908203125, 35.5);
const nudge = getTooltipViewportNudge(rect, 1600, 900);
expect(nudge.x).toBeCloseTo(1594 - rect.right, 10);
expect(nudge.y).toBe(0);
});
it('leaves a tooltip that measures a fraction of a pixel past the edge', () => {
const rect = box(1526.433349609375, 185, 67.56666564941406, 35.5);
expect(rect.right).toBeGreaterThan(1594);
expect(getTooltipViewportNudge(rect, 1600, 900)).toEqual({ x: 0, y: 0 });
});
it('still moves a tooltip that is half a pixel over', () => {
expect(getTooltipViewportNudge(box(1514, 100, 80.5, 30), 1600, 900)).toEqual({
x: -0.5,
y: 0,
});
});
it('moves a tooltip past the left or top edge back inside', () => {
expect(getTooltipViewportNudge(box(-20, -10, 80, 30), 1600, 900)).toEqual({ x: 26, y: 16 });
});
it('moves a tooltip past the bottom edge up', () => {
expect(getTooltipViewportNudge(box(100, 880, 80, 30), 1600, 900)).toEqual({ x: 0, y: -16 });
});
it('pins a tooltip wider than the viewport to the left edge', () => {
expect(getTooltipViewportNudge(box(40, 100, 400, 30), 390, 900)).toEqual({ x: -34, y: 0 });
});
});
+4
View File
@@ -57,6 +57,8 @@ export interface Book {
titles_by_language?: Record<string, string>;
username?: string;
retry_available?: boolean;
finished_at?: number | null; // When a finished download ended, epoch seconds
reattached?: boolean; // Picked up work already under way, e.g. a torrent the client had
downloads?: number;
extra?: Record<string, unknown>;
}
@@ -217,6 +219,7 @@ export interface RequestPolicyResponse {
requests_enabled: boolean;
is_admin: boolean;
allow_notes: boolean;
allow_book_request_with_download?: boolean;
defaults: RequestPolicyDefaults;
rules: Array<Record<string, unknown>>;
source_modes: RequestPolicySourceMode[];
@@ -264,6 +267,7 @@ export interface RequestRecord {
created_at: string;
updated_at: string;
username?: string;
reviewer_username?: string;
}
export interface QueuedDownloadResult {
@@ -0,0 +1,32 @@
import type { Book, ButtonStateInfo } from '../types';
const RETRY: ButtonStateInfo = { text: 'Retry', state: 'download' };
const FAILED: ButtonStateInfo = { text: 'Failed', state: 'error' };
/**
* The button for a release whose last download failed.
*
* When the queue cannot resume a failed download of a request, the request is reopened so a
* release can be picked again, and the one that failed may be fine (its client was down or
* refused the login). So it is offered again, like a download the queue can retry. Any other
* failure with nothing to retry from stays marked as failed.
*/
export function failedDownloadButtonState(entry: Book): ButtonStateInfo {
return entry.retry_available === true || (entry.request_id ?? 0) > 0
? { ...RETRY }
: { ...FAILED };
}
/**
* The button for a book with several tracked releases once another of them is found failed.
*
* A failure that can be retried wins over one that can't, in whichever order they are found, so
* the book keeps Retry while any of its failed releases can be tried again.
*/
export function addFailedRelease(best: ButtonStateInfo, entry: Book): ButtonStateInfo {
if (best.state === RETRY.state && best.text === RETRY.text) {
return best;
}
const failed = failedDownloadButtonState(entry);
return failed.text === RETRY.text || best.state === 'download' ? failed : best;
}
+49
View File
@@ -1,5 +1,54 @@
import type { ButtonStateInfo, RequestPolicyMode } from '../types';
/**
* Whether the release modal offers a book-level request ("Add to requests"). Admins always get
* it. Anyone else gets it only when requests are on, the setting lets users who can download
* also ask for a book without choosing a release, and their policy for this content type is
* Download. The server turns a book-level request into a pending one only in Download mode, so
* the action would be refused (Blocked) or mean something else (Request Release) otherwise.
*/
export const canRequestBookFromReleaseModal = ({
isBrowseFulfilMode,
isAdmin,
requestsEnabled,
allowBookRequestWithDownload,
bookRequestMode,
}: {
isBrowseFulfilMode: boolean;
isAdmin: boolean;
requestsEnabled: boolean;
allowBookRequestWithDownload: boolean;
bookRequestMode: RequestPolicyMode;
}): boolean => {
if (isBrowseFulfilMode) return false;
if (isAdmin) return true;
return requestsEnabled && allowBookRequestWithDownload && bookRequestMode === 'download';
};
/**
* Whether the activity sidebar shows a Requests tab. Admins always see it. A user sees it when
* requests are on and either default asks for requests, or when they may request a book
* alongside downloads, since those requests then need a place to be followed and cancelled.
*/
export const shouldShowRequestsTab = ({
isAdmin,
isAuthenticated,
requestsEnabled,
defaults,
allowBookRequestWithDownload,
}: {
isAdmin: boolean;
isAuthenticated: boolean;
requestsEnabled: boolean;
defaults: { ebook: RequestPolicyMode; audiobook: RequestPolicyMode } | null;
allowBookRequestWithDownload: boolean;
}): boolean => {
if (isAdmin) return true;
if (!isAuthenticated || !requestsEnabled || !defaults) return false;
if (allowBookRequestWithDownload) return true;
return !(defaults.ebook === 'download' && defaults.audiobook === 'download');
};
export const applyDirectPolicyModeToButtonState = (
baseState: ButtonStateInfo,
mode: RequestPolicyMode,
@@ -0,0 +1,38 @@
type MeasuredBox = Pick<DOMRect, 'left' | 'right' | 'top' | 'bottom' | 'width' | 'height'>;
interface ViewportNudge {
x: number;
y: number;
}
const VIEWPORT_PADDING = 6;
// Firefox measures a transformed box in single precision, so a tooltip already moved flush
// to the edge can read 0.00001px past it. Moving it by that much changes nothing, the next
// measurement shows the same overflow, and the tooltip's layout effect loops until React
// gives up with error #185 and the page goes blank (#1646).
const SUBPIXEL_TOLERANCE = 0.5;
const nudgeAxis = (start: number, end: number, size: number, viewport: number): number => {
let delta = 0;
if (size + VIEWPORT_PADDING * 2 > viewport || start < VIEWPORT_PADDING) {
delta = VIEWPORT_PADDING - start;
} else if (end > viewport - VIEWPORT_PADDING) {
delta = viewport - VIEWPORT_PADDING - end;
}
return Math.abs(delta) < SUBPIXEL_TOLERANCE ? 0 : delta;
};
/**
* How far to move a tooltip so it stays inside the viewport, keeping a small gap
* from each edge. A tooltip bigger than the viewport is pinned to the top/left edge.
* Overflow under half a pixel counts as none.
*/
export const getTooltipViewportNudge = (
rect: MeasuredBox,
viewportWidth: number,
viewportHeight: number,
): ViewportNudge => ({
x: nudgeAxis(rect.left, rect.right, rect.width, viewportWidth),
y: nudgeAxis(rect.top, rect.bottom, rect.height, viewportHeight),
});
@@ -24,6 +24,12 @@ def _clean_display():
ib.DISPLAY.update(before)
@pytest.fixture(autouse=True)
def _ffmpeg_on_path(monkeypatch):
# The recorder checks for the binary first; these tests stub the process itself.
monkeypatch.setattr(ib.shutil, "which", lambda _name: "/usr/bin/ffmpeg")
class _Proc:
def __init__(self, returncode):
self.returncode = returncode
@@ -133,3 +139,23 @@ def test_summary_survives_a_missing_log():
ib.DISPLAY["ffmpeg_error_log"] = None
assert "No FFmpeg error log" in ib._ffmpeg_error_summary()
def test_debug_mode_without_ffmpeg_skips_the_recording_and_says_so(monkeypatch, tmp_path):
"""Debug mode can be on with no ffmpeg in the image; that must not fail the bypass."""
monkeypatch.setattr(ib, "RECORDING_DIR", tmp_path / "recording")
monkeypatch.setattr(ib.shutil, "which", lambda _name: None)
def popen_must_not_run(*_args, **_kwargs):
raise AssertionError("ffmpeg should not be started when it is not installed")
monkeypatch.setattr(ib.subprocess, "Popen", popen_must_not_run)
warnings: list[str] = []
monkeypatch.setattr(ib.logger, "warning", lambda msg, *_args: warnings.append(msg))
ib._start_ffmpeg_recording(display=":99")
assert ib.DISPLAY["ffmpeg"] is None
assert any("ffmpeg is not installed" in message for message in warnings)
assert not (tmp_path / "recording").exists()
@@ -0,0 +1,91 @@
"""The Debug mode ffmpeg install: what it runs, what it logs, and what it can't block."""
from __future__ import annotations
import os
import shutil
import subprocess
from pathlib import Path
ENTRYPOINT_PATH = Path(__file__).resolve().parents[2] / "entrypoint.sh"
BASH_PATH = shutil.which("bash") or "/bin/bash"
def _stub(bin_dir: Path, name: str, body: str) -> None:
path = bin_dir / name
path.write_text(f"#!/bin/sh\n{body}\n")
path.chmod(0o755)
def _install_ffmpeg_source() -> str:
text = ENTRYPOINT_PATH.read_text()
start = text.index("install_ffmpeg() {")
return text[start : text.index("\n}\n", start) + 3]
def _run(
tmp_path: Path, *, apt_body: str, ffmpeg_works: bool = True
) -> tuple[subprocess.CompletedProcess[str], list[str]]:
bin_dir = tmp_path / "bin"
bin_dir.mkdir()
calls = tmp_path / "apt-calls.txt"
_stub(
bin_dir,
"apt-get",
f'echo "frontend=$DEBIAN_FRONTEND $*" >> "{calls}"\n{apt_body}',
)
_stub(bin_dir, "dpkg", "exit 0")
_stub(bin_dir, "ffmpeg", "exit 0" if ffmpeg_works else "exit 1")
env = os.environ.copy()
env.update({"PATH": f"{bin_dir}:{env.get('PATH', '')}", "FFMPEG_INSTALL_TIMEOUT": "1"})
result = subprocess.run(
[BASH_PATH, "-c", f"{_install_ffmpeg_source()}\ninstall_ffmpeg"],
capture_output=True,
env=env,
text=True,
check=False,
timeout=30,
)
return result, calls.read_text().splitlines() if calls.exists() else []
def test_install_downloads_first_then_installs_without_the_network(tmp_path):
result, calls = _run(tmp_path, apt_body="exit 0")
assert "ffmpeg installed" in result.stdout
assert [call.split()[1] for call in calls] == ["update", "install", "install"]
assert "--download-only" in calls[1]
assert "--no-download" in calls[2]
assert all(call.startswith("frontend=noninteractive ") for call in calls)
def test_install_gives_up_on_a_mirror_that_never_answers(tmp_path):
result, calls = _run(tmp_path, apt_body="exec sleep 60")
assert "Timed out installing ffmpeg after 1s" in result.stdout
assert "ffmpeg installed" not in result.stdout
# Killed during the download, so dpkg's install step never ran.
assert not any("--no-download" in call for call in calls)
def test_install_reports_a_failure(tmp_path):
result, calls = _run(tmp_path, apt_body="exit 100")
assert "Could not install ffmpeg (exit 100)" in result.stdout
assert not any("--no-download" in call for call in calls)
def test_install_does_not_claim_success_when_ffmpeg_does_not_run(tmp_path):
result, _ = _run(tmp_path, apt_body="exit 0", ffmpeg_works=False)
assert "ffmpeg does not run" in result.stdout
assert "ffmpeg installed" not in result.stdout
def test_install_starts_after_the_log_pipe_is_released():
"""stop_file_logging waits for the log pipe to close, so the job must not hold it."""
text = ENTRYPOINT_PATH.read_text()
assert text.rindex("stop_file_logging\n") < text.index('if [ "$INSTALL_FFMPEG" = "true" ]')
assert text.index('if [ "$INSTALL_FFMPEG" = "true" ]') < text.rindex("exec_as_target_user env")
+7 -4
View File
@@ -384,7 +384,7 @@ class TestActivityRoutes:
main_module.backend.book_queue.update_status(task_id, QueueStatus.COMPLETE)
with (
patch.object(main_module, "get_auth_mode", return_value="builtin"),
patch.object(main_module, "send_file", side_effect=failure),
patch("shelfmark.core.queue_routes.send_file", side_effect=failure),
):
response = client.get(f"/api/localdownload?id={task_id}")
finally:
@@ -1404,7 +1404,8 @@ class TestActivityRoutes:
assert response.status_code == 200
assert "stale-active-task" in response.json["status"]["error"]
assert (
response.json["status"]["error"]["stale-active-task"]["status_message"] == "Interrupted"
response.json["status"]["error"]["stale-active-task"]["status_message"]
== "Interrupted by a restart. Use Retry to run it again."
)
def test_snapshot_preserves_retry_for_stale_active_requested_download(
@@ -1453,7 +1454,9 @@ class TestActivityRoutes:
assert response.status_code == 200
viewer_id = public_download_id(task_id)
assert response.json["status"]["error"][viewer_id]["status_message"] == "Interrupted"
assert response.json["status"]["error"][viewer_id]["status_message"].startswith(
"Interrupted by a restart"
)
assert response.json["status"]["error"][viewer_id]["retry_available"] is True
def test_snapshot_includes_retry_available_for_live_terminal_downloads(
@@ -1661,7 +1664,7 @@ class TestActivityRoutes:
viewer_scope=f"user:{user['id']}",
)
main_module._record_download_queued(
main_module.backend.book_queue._queue_hook(
"task-reused-1",
SimpleNamespace(
user_id=user["id"],
+10 -9
View File
@@ -9,6 +9,7 @@ from unittest.mock import ANY, patch
import pytest
from shelfmark.core import download_recording
from shelfmark.core.models import DownloadTask, QueueStatus
from shelfmark.core.notifications import NotificationEvent
from shelfmark.core.viewer_redaction import public_download_id
@@ -172,8 +173,8 @@ class TestTerminalSnapshotCapture:
assert main_module.backend.book_queue.add(task) is True
try:
with patch.object(main_module, "notify_admin") as mock_notify:
with patch.object(main_module, "notify_user") as mock_notify_user:
with patch.object(download_recording, "notify_admin") as mock_notify:
with patch.object(download_recording, "notify_user") as mock_notify_user:
main_module.backend.book_queue.update_status(task_id, QueueStatus.COMPLETE)
mock_notify.assert_called_once()
@@ -264,8 +265,8 @@ class TestTerminalSnapshotCapture:
try:
main_module.backend.book_queue.update_status_message(task_id, "Resolver timed out")
with patch.object(main_module, "notify_admin") as mock_notify:
with patch.object(main_module, "notify_user") as mock_notify_user:
with patch.object(download_recording, "notify_admin") as mock_notify:
with patch.object(download_recording, "notify_user") as mock_notify_user:
main_module.backend.book_queue.update_status(task_id, QueueStatus.ERROR)
mock_notify.assert_called_once()
@@ -324,7 +325,7 @@ class TestTerminalSnapshotCapture:
main_module.backend.book_queue.update_status_message(
task_id, "Destination not writable"
)
with patch.object(main_module, "reopen_failed_request") as mock_reopen:
with patch.object(download_recording, "reopen_failed_request") as mock_reopen:
main_module.backend.book_queue.update_status(task_id, QueueStatus.ERROR)
mock_reopen.assert_not_called()
@@ -463,7 +464,7 @@ class TestTerminalSnapshotCapture:
with patch.object(main_module.ws_manager, "is_enabled", return_value=True):
with patch.object(main_module.ws_manager.socketio, "emit") as mock_emit:
main_module._record_download_queued(task_id, task)
main_module.backend.book_queue._queue_hook(task_id, task)
mock_emit.assert_any_call(
"activity_update",
@@ -501,7 +502,7 @@ class TestTerminalSnapshotCapture:
with patch.object(main_module.ws_manager, "is_enabled", return_value=True):
with patch.object(main_module.ws_manager.socketio, "emit") as mock_emit:
main_module._record_download_queued(task_id, task)
main_module.backend.book_queue._queue_hook(task_id, task)
payloads = _activity_update_payloads(mock_emit)
assert payloads["admins"] == {"kind": "activity_reset", "task_id": task_id}
@@ -524,8 +525,8 @@ class TestTerminalSnapshotCapture:
assert main_module.backend.book_queue.add(task) is True
try:
with patch.object(main_module, "notify_admin") as mock_notify:
with patch.object(main_module, "notify_user") as mock_notify_user:
with patch.object(download_recording, "notify_admin") as mock_notify:
with patch.object(download_recording, "notify_user") as mock_notify_user:
main_module.backend.book_queue.update_status(task_id, QueueStatus.CANCELLED)
mock_notify.assert_not_called()
+7 -2
View File
@@ -14,6 +14,11 @@ import pytest
from shelfmark.core import api_key
def _hook(main_module, name):
"""The registered before_request hook called `name` (it lives in core/auth_middleware.py)."""
return next(f for f in main_module.app.before_request_funcs[None] if f.__name__ == name)
@pytest.fixture
def user_db():
from shelfmark.core.user_db import UserDB
@@ -111,7 +116,7 @@ def _identity_for_key(wired):
the middleware in a request context sidesteps that entirely.
"""
with wired.app.test_request_context("/api/downloads/active", headers=_bearer("s3cret")):
assert wired.api_key_auth_middleware() is None
assert _hook(wired, "api_key_auth_middleware")() is None
from flask import session
return dict(session)
@@ -492,7 +497,7 @@ class TestReadOnlyKeyRequests:
def test_never_gets_an_admin_session(self, ro, user_db):
user_db.create_user(username="root", role="admin")
with ro.app.test_request_context("/api/stats", headers=_bearer("ro-key")):
assert ro.api_key_auth_middleware() is None
assert _hook(ro, "api_key_auth_middleware")() is None
from flask import session
assert "is_admin" not in session
+185
View File
@@ -0,0 +1,185 @@
"""Characterization tests for the app setup that moves out of main.py: the persisted secret key,
the session cookie settings, the security headers and the werkzeug log filter.
"""
from __future__ import annotations
import importlib
import logging
import os
from unittest.mock import patch
import pytest
from shelfmark.core.app_setup import (
FLASK_SECRET_KEY_MIN_BYTES,
LogNoiseFilter,
load_or_create_secret_key,
register_security_headers,
)
@pytest.fixture(scope="module")
def main_module():
"""Import `shelfmark.main` with background startup disabled."""
with patch("shelfmark.download.orchestrator.start"):
import shelfmark.main as main
importlib.reload(main)
return main
def _record(message: str, *, level: int = logging.INFO, exc_info=None) -> logging.LogRecord:
return logging.LogRecord("werkzeug", level, __file__, 1, message, None, exc_info)
class TestSecretKey:
def test_a_persisted_key_is_reused(self, main_module, tmp_path):
persisted = b"k" * 64
(tmp_path / ".flask_secret").write_bytes(persisted)
assert load_or_create_secret_key(tmp_path) == persisted
def test_a_missing_key_is_created_and_kept_private(self, main_module, tmp_path):
created = load_or_create_secret_key(tmp_path)
again = load_or_create_secret_key(tmp_path)
path = tmp_path / ".flask_secret"
assert len(created) == 64
assert path.read_bytes() == created == again
assert path.stat().st_mode & 0o777 == 0o600
def test_the_key_is_never_written_to_a_file_others_can_read(
self, main_module, tmp_path, monkeypatch
):
# Writing first and tightening the mode after leaves the key readable under the umask until
# the chmod, and readable for good when the chmod fails.
def chmod_fails(*_args, **_kwargs):
raise OSError("chmod not supported here")
monkeypatch.setattr("pathlib.Path.chmod", chmod_fails)
old_umask = os.umask(0o022)
try:
key = load_or_create_secret_key(tmp_path)
finally:
os.umask(old_umask)
path = tmp_path / ".flask_secret"
assert path.read_bytes() == key
assert path.stat().st_mode & 0o777 == 0o600
def test_a_replaced_key_file_is_made_private(self, main_module, tmp_path):
path = tmp_path / ".flask_secret"
path.write_bytes(b"short")
path.chmod(0o644)
key = load_or_create_secret_key(tmp_path)
assert path.read_bytes() == key
assert path.stat().st_mode & 0o777 == 0o600
def test_a_key_that_is_too_short_is_replaced(self, main_module, tmp_path):
path = tmp_path / ".flask_secret"
path.write_bytes(b"short")
key = load_or_create_secret_key(tmp_path)
assert len(key) == 64
assert path.read_bytes() == key
def test_a_key_of_exactly_the_minimum_length_is_kept(self, main_module, tmp_path):
minimum = b"m" * FLASK_SECRET_KEY_MIN_BYTES
(tmp_path / ".flask_secret").write_bytes(minimum)
assert load_or_create_secret_key(tmp_path) == minimum
def test_an_unwritable_config_dir_still_gives_a_key(self, main_module, tmp_path):
blocked = tmp_path / "file-not-dir"
blocked.write_text("x")
key = load_or_create_secret_key(blocked / "config")
assert len(key) == 64
def test_the_app_and_the_opaque_id_key_use_the_same_secret(self, main_module):
from shelfmark.core import viewer_redaction
assert len(main_module.app.config["SECRET_KEY"]) >= 32
assert viewer_redaction._public_id_key is not None
class TestSessionCookie:
def test_cookie_settings(self, main_module):
config = main_module.app.config
assert config["SESSION_COOKIE_HTTPONLY"] is True
assert config["SESSION_COOKIE_SAMESITE"] == "Lax"
assert config["SESSION_COOKIE_NAME"] == main_module.SESSION_COOKIE_NAME
assert config["SESSION_COOKIE_SECURE"] == main_module.SESSION_COOKIE_SECURE
assert config["PERMANENT_SESSION_LIFETIME"] == 604800
class TestSecurityHeaders:
def test_every_response_carries_the_baseline_headers(self, main_module):
response = main_module.app.test_client().get("/api/health")
assert response.headers["X-Content-Type-Options"] == "nosniff"
assert response.headers["Permissions-Policy"] == "camera=(), microphone=(), geolocation=()"
assert response.headers["Cross-Origin-Embedder-Policy"] == "credentialless"
csp = response.headers["Content-Security-Policy"]
assert csp.startswith("default-src 'self'; script-src 'self';")
assert "connect-src 'self' ws: wss:" in csp
def test_a_header_already_on_the_response_is_not_overridden(self, main_module):
from flask import Flask, Response
response = Response("{}")
response.headers["X-Content-Type-Options"] = "route-value"
app = Flask(__name__)
register_security_headers(app)
with app.test_request_context():
result = app.process_response(response)
assert result is response
assert response.headers["X-Content-Type-Options"] == "route-value"
assert response.headers["Permissions-Policy"] == "camera=(), microphone=(), geolocation=()"
class TestLogNoiseFilter:
def test_status_polling_is_dropped(self, main_module):
noise = LogNoiseFilter()
assert noise.filter(_record('127.0.0.1 - "GET /api/status HTTP/1.1" 200 -')) is False
def test_other_requests_are_kept(self, main_module):
noise = LogNoiseFilter()
assert noise.filter(_record('127.0.0.1 - "GET /api/releases HTTP/1.1" 200 -')) is True
assert noise.filter(_record('127.0.0.1 - "POST /api/status HTTP/1.1" 200 -')) is True
def test_the_websocket_upgrade_noise_is_dropped(self, main_module):
noise = LogNoiseFilter()
assert noise.filter(_record("write() before start_response")) is False
assert noise.filter(_record("Error on request:", level=logging.ERROR)) is False
def test_an_assertion_error_from_the_upgrade_is_dropped_but_other_errors_are_not(
self, main_module
):
noise = LogNoiseFilter()
upgrade = AssertionError("write() before start_response")
other = AssertionError("something else")
dropped = _record("boom", level=logging.ERROR, exc_info=(AssertionError, upgrade, None))
kept = _record("boom", level=logging.ERROR, exc_info=(AssertionError, other, None))
kept_type = _record("boom", level=logging.ERROR, exc_info=(ValueError, upgrade, None))
assert noise.filter(dropped) is False
assert noise.filter(kept) is True
assert noise.filter(kept_type) is True
def test_the_filter_is_installed_on_the_werkzeug_logger(self, main_module):
filters = logging.getLogger("werkzeug").filters
assert any(type(f).__name__ == "LogNoiseFilter" for f in filters)
+6 -1
View File
@@ -10,6 +10,11 @@ import pytest
from werkzeug.security import generate_password_hash
def _hook(main_module, name):
"""The registered before_request hook called `name` (it lives in core/auth_middleware.py)."""
return next(f for f in main_module.app.before_request_funcs[None] if f.__name__ == name)
@pytest.fixture(scope="module")
def main_module():
"""Import `shelfmark.main` with background startup disabled."""
@@ -309,7 +314,7 @@ class TestProxyProvisioningRole:
):
main_module.session["user_id"] = "bob"
main_module.session["db_user_id"] = bob["id"]
assert main_module.proxy_auth_middleware() is None
assert _hook(main_module, "proxy_auth_middleware")() is None
return main_module.session["is_admin"]
assert run_middleware() is False
+324
View File
@@ -0,0 +1,324 @@
"""Pin the API-key and proxy-auth request hooks registered on the Flask app."""
from __future__ import annotations
import importlib
from typing import Any
from unittest.mock import patch
import pytest
from flask import Response, g, session
from shelfmark.core import api_key
@pytest.fixture(scope="module")
def main_module():
with patch("shelfmark.download.orchestrator.start"):
import shelfmark.main as main
importlib.reload(main)
return main
@pytest.fixture(autouse=True)
def _empty_user_table(main_module):
def _clear() -> None:
for user in main_module.user_db.list_users():
main_module.user_db.delete_user(user["id"])
_clear()
yield
_clear()
def _hook(app, kind: str, name: str):
funcs = app.before_request_funcs if kind == "before" else app.after_request_funcs
for func in funcs[None]:
if func.__name__ == name:
return func
raise AssertionError(f"{kind}_request hook {name} is not registered")
def _config_getter(values: dict[str, Any]):
def _get(key: str, default: Any = None, user_id: Any = None):
return values.get(key, default)
return _get
def _proxy(main_module, path="/api/releases", *, headers=None, environ=None, config=None):
"""Run the proxy hook in a request context and return (result, session dict)."""
values = {"PROXY_AUTH_USER_HEADER": "X-Auth-User", **(config or {})}
with (
patch.object(main_module, "get_auth_mode", return_value="proxy"),
patch.object(main_module.app_config, "get", side_effect=_config_getter(values)),
main_module.app.test_request_context(path, headers=headers, environ_base=environ),
):
result = _hook(main_module.app, "before", "proxy_auth_middleware")()
return result, dict(session), session.permanent
class TestHookOrder:
def test_before_request_order(self, main_module):
names = [f.__name__ for f in main_module.app.before_request_funcs[None]]
assert names.index("api_key_auth_middleware") < names.index("proxy_auth_middleware")
def test_after_request_order(self, main_module):
names = [f.__name__ for f in main_module.app.after_request_funcs[None]]
assert names.index("set_security_headers") < names.index(
"strip_cookie_for_api_key_requests"
)
class TestProxyGroups:
@pytest.mark.parametrize(
("groups", "expected"),
[
("admins", True),
("users,admins", True),
("users, admins ,other", True),
("users|admins", True),
("users", False),
("", False),
("administrators", False),
],
)
def test_group_header_maps_to_admin(self, main_module, groups, expected):
headers = {"X-Auth-User": "gina"}
if groups:
headers["X-Auth-Groups"] = groups
result, sess, permanent = _proxy(
main_module, headers=headers, config={"PROXY_AUTH_ADMIN_GROUP_NAME": "admins"}
)
assert result is None
assert sess["is_admin"] is expected
assert permanent is False
db_user = main_module.user_db.get_user(user_id=sess["db_user_id"])
assert db_user["role"] == ("admin" if expected else "user")
def test_custom_group_header(self, main_module):
result, sess, _ = _proxy(
main_module,
headers={"X-Auth-User": "gina", "Remote-Groups": "ops"},
config={
"PROXY_AUTH_ADMIN_GROUP_NAME": "ops",
"PROXY_AUTH_ADMIN_GROUP_HEADER": "Remote-Groups",
},
)
assert result is None
assert sess["is_admin"] is True
def test_group_header_wsgi_environ_fallback(self, main_module):
result, sess, _ = _proxy(
main_module,
headers={"X-Auth-User": "gina"},
environ={"HTTP_X_AUTH_GROUPS": "admins"},
config={"PROXY_AUTH_ADMIN_GROUP_NAME": "admins"},
)
assert result is None
assert sess["is_admin"] is True
def test_configured_group_overrides_stored_role(self, main_module):
main_module.user_db.create_user(username="gina", role="admin", auth_source="proxy")
result, sess, _ = _proxy(
main_module,
headers={"X-Auth-User": "gina", "X-Auth-Groups": "users"},
config={"PROXY_AUTH_ADMIN_GROUP_NAME": "admins"},
)
assert result is None
assert sess["is_admin"] is False
def test_blank_user_header_setting_falls_back_to_default(self, main_module):
result, sess, _ = _proxy(
main_module,
headers={"X-Auth-User": "gina"},
config={"PROXY_AUTH_USER_HEADER": " "},
)
assert result is None
assert sess["user_id"] == "gina"
class TestProxyHeaderHandling:
def test_missing_header_on_protected_path_is_401(self, main_module):
result, sess, _ = _proxy(main_module)
resp, status = result
assert status == 401
assert resp.get_json() == {"error": "Authentication required. Proxy header not set."}
assert "user_id" not in sess
def test_missing_header_on_auth_path_passes(self, main_module):
result, sess, _ = _proxy(main_module, "/api/auth/login")
assert result is None
assert "user_id" not in sess
def test_non_api_path_without_header_is_401(self, main_module):
result, _, _ = _proxy(main_module, "/")
assert result[1] == 401
def test_header_ignored_outside_proxy_mode(self, main_module):
for mode in ("builtin", "none", "cwa"):
with (
patch.object(main_module, "get_auth_mode", return_value=mode),
main_module.app.test_request_context(
"/api/releases", headers={"X-Auth-User": "mallory"}
),
):
assert _hook(main_module.app, "before", "proxy_auth_middleware")() is None
assert "user_id" not in session
assert main_module.user_db.get_user(username="mallory") is None
def test_custom_user_header_is_case_insensitive(self, main_module):
result, sess, _ = _proxy(
main_module,
headers={"x-forwarded-user": "gina"},
config={"PROXY_AUTH_USER_HEADER": "X-Forwarded-User"},
)
assert result is None
assert sess["user_id"] == "gina"
def test_provisioned_once_per_session(self, main_module):
_, sess, _ = _proxy(main_module, headers={"X-Auth-User": "gina"})
first_id = sess["db_user_id"]
with (
patch.object(main_module, "get_auth_mode", return_value="proxy"),
main_module.app.test_request_context("/api/releases", headers={"X-Auth-User": "gina"}),
):
session["user_id"] = "gina"
session["db_user_id"] = first_id
assert _hook(main_module.app, "before", "proxy_auth_middleware")() is None
assert session["db_user_id"] == first_id
assert len(main_module.user_db.list_users()) == 1
def test_store_error_is_500(self, main_module):
with patch.object(main_module.user_db, "get_user_cached", side_effect=RuntimeError("boom")):
result, _, _ = _proxy(main_module, headers={"X-Auth-User": "gina"})
resp, status = result
assert status == 500
assert resp.get_json() == {"error": "Authentication error"}
class TestApiKeyScope:
@pytest.fixture
def keyed(self, main_module, monkeypatch):
monkeypatch.setattr(api_key, "SHELFMARK_API_KEY", "s3cret")
monkeypatch.setattr(api_key, "SHELFMARK_API_KEY_READONLY", "ro-key")
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
yield main_module
def _run(self, main_module, path, key="s3cret", method="GET"):
with main_module.app.test_request_context(path, method=method, headers={"X-Api-Key": key}):
result = _hook(main_module.app, "before", "api_key_auth_middleware")()
return result, dict(session), g.get("api_key_auth"), g.get("api_key_scope")
@pytest.mark.parametrize("path", ["/", "/index.html", "/static/app.js", "/apix", "/api"])
def test_non_api_paths_ignore_key(self, keyed, path):
result, sess, flag, scope = self._run(keyed, path)
assert result is None
assert sess == {}
assert flag is None
assert scope is None
@pytest.mark.parametrize("path", ["/api/health", "/api/auth/check", "/api/auth/login"])
def test_exempt_paths_ignore_key(self, keyed, path):
result, sess, flag, _ = self._run(keyed, path)
assert result is None
assert sess == {}
assert flag is None
def test_admin_key_marks_request_and_sets_admin_session(self, keyed):
admin = keyed.user_db.create_user(username="root", role="admin")
result, sess, flag, scope = self._run(keyed, "/api/downloads/active")
assert result is None
assert flag is True
assert scope == "admin"
assert sess["user_id"] == "root"
assert sess["is_admin"] is True
assert sess["db_user_id"] == admin["id"]
def test_admin_key_without_admin_account_uses_api_identity(self, keyed):
_, sess, _, _ = self._run(keyed, "/api/downloads/active")
assert sess["user_id"] == "api"
assert sess["is_admin"] is True
assert "db_user_id" not in sess
def test_admin_key_session_is_not_persisted(self, keyed):
with keyed.app.test_request_context(
"/api/downloads/active", headers={"X-Api-Key": "s3cret"}
):
_hook(keyed.app, "before", "api_key_auth_middleware")()
assert session.permanent is False
assert session.modified is False
def test_wrong_key_falls_through_untouched(self, keyed):
result, sess, flag, scope = self._run(keyed, "/api/downloads/active", key="nope")
assert result is None
assert sess == {}
assert flag is None
assert scope is None
def test_readonly_key_allowed_on_stats_get_only(self, keyed):
result, sess, flag, scope = self._run(keyed, "/api/stats", key="ro-key")
assert result is None
assert (sess, flag, scope) == ({}, True, "readonly")
@pytest.mark.parametrize(
("path", "method"),
[("/api/stats", "POST"), ("/api/downloads/active", "GET"), ("/api/stats/x", "GET")],
)
def test_readonly_key_refused_elsewhere(self, keyed, path, method):
result, _, flag, scope = self._run(keyed, path, key="ro-key", method=method)
resp, status = result
assert status == 403
assert resp.get_json() == {"error": "This API key is read-only"}
assert flag is True
assert scope == "readonly"
def test_none_mode_ignores_key(self, main_module, monkeypatch):
monkeypatch.setattr(api_key, "SHELFMARK_API_KEY", "s3cret")
with patch.object(main_module, "get_auth_mode", return_value="none"):
result, sess, flag, _ = self._run(main_module, "/api/downloads/active")
assert (result, sess, flag) == (None, {}, None)
def test_store_error_is_500_and_still_marked_keyed(self, keyed):
with patch.object(keyed.user_db, "get_first_admin", side_effect=RuntimeError("boom")):
result, _, flag, _ = self._run(keyed, "/api/downloads/active")
resp, status = result
assert status == 500
assert resp.get_json() == {"error": "Authentication error"}
assert flag is True
def test_proxy_mode_keyed_request_skips_proxy_hook(self, keyed):
with (
patch.object(keyed, "get_auth_mode", return_value="proxy"),
keyed.app.test_request_context(
"/api/downloads/active", headers={"X-Api-Key": "s3cret"}
),
):
assert _hook(keyed.app, "before", "api_key_auth_middleware")() is None
assert _hook(keyed.app, "before", "proxy_auth_middleware")() is None
assert session["is_admin"] is True
class TestCookieStripping:
def _strip(self, main_module, *, keyed: bool):
with main_module.app.test_request_context("/api/downloads/active"):
if keyed:
g.api_key_auth = True
session["dirty"] = True
session.permanent = True
resp = _hook(main_module.app, "after", "strip_cookie_for_api_key_requests")(
Response("ok")
)
return resp, session.modified, session.permanent
def test_keyed_request_resets_session_flags(self, main_module):
resp, modified, permanent = self._strip(main_module, keyed=True)
assert resp.get_data(as_text=True) == "ok"
assert modified is False
assert permanent is False
def test_unkeyed_request_is_untouched(self, main_module):
_, modified, permanent = self._strip(main_module, keyed=False)
assert modified is True
assert permanent is True
+692
View File
@@ -0,0 +1,692 @@
"""Characterization tests for `/api/auth/login`, `/api/auth/logout` and `/api/auth/check`.
They pin the behaviour the three routes have today (lockout thresholds and timing,
messages, status codes, session writes, one case per auth mode), so a refactor that
moves the routes can be checked against them. Every credential here is made up.
"""
from __future__ import annotations
import importlib
import sqlite3
from datetime import UTC, datetime, timedelta
from unittest.mock import patch
import pytest
from werkzeug.security import generate_password_hash
@pytest.fixture(scope="module")
def main_module():
"""Import `shelfmark.main` with background startup disabled."""
with patch("shelfmark.download.orchestrator.start"):
import shelfmark.main as main
importlib.reload(main)
return main
@pytest.fixture
def client(main_module):
main_module.failed_login_attempts.clear()
try:
yield main_module.app.test_client()
finally:
main_module.failed_login_attempts.clear()
@pytest.fixture
def temp_user_db(tmp_path, main_module, monkeypatch):
from shelfmark.core.user_db import UserDB
db = UserDB(str(tmp_path / "users.db"))
db.initialize()
monkeypatch.setattr(main_module, "user_db", db)
return db
def _mode(main_module, mode):
return patch.object(main_module, "get_auth_mode", return_value=mode)
def _login(client, username="alice", password="fake-1", **extra):
return client.post(
"/api/auth/login", json={"username": username, "password": password, **extra}
)
def _make_alice(db, role="user"):
return db.create_user(
username="alice", password_hash=generate_password_hash("fake-1"), role=role
)
class TestLoginInput:
def test_empty_json_object_is_no_data(self, main_module, client):
with _mode(main_module, "builtin"):
response = client.post("/api/auth/login", json={})
assert response.status_code == 400
assert response.get_json() == {"error": "No data provided"}
@pytest.mark.parametrize(
"payload",
[
{"username": "", "password": "x"},
{"username": " ", "password": "x"},
{"username": "alice", "password": ""},
{"username": "alice"},
{"password": "x"},
],
)
def test_missing_username_or_password(self, main_module, client, payload):
with _mode(main_module, "builtin"):
response = client.post("/api/auth/login", json=payload)
assert response.status_code == 400
assert response.get_json() == {"error": "Username and password are required"}
assert main_module.failed_login_attempts == {}
def test_username_is_stripped(self, main_module, client, temp_user_db):
_make_alice(temp_user_db)
with _mode(main_module, "builtin"):
response = _login(client, username=" alice ")
assert response.status_code == 200
with client.session_transaction() as sess:
assert sess["user_id"] == "alice"
def test_proxy_mode_wins_over_missing_data_checks_order(self, main_module, client):
# Proxy mode answers before the username/password check.
with _mode(main_module, "proxy"):
response = client.post("/api/auth/login", json={"username": "", "password": ""})
assert response.status_code == 401
assert response.get_json() == {"error": "Proxy authentication is enabled"}
class TestLoginBuiltin:
def test_success_sets_session_cookie_and_clears_failures(
self, main_module, client, temp_user_db
):
user = _make_alice(temp_user_db, role="user")
main_module.failed_login_attempts["alice"] = {"count": 3}
with _mode(main_module, "builtin"):
response = _login(client, remember_me=True)
assert response.status_code == 200
assert response.get_json() == {"success": True}
assert any(
header.startswith(main_module.app.config["SESSION_COOKIE_NAME"] + "=")
for header in response.headers.getlist("Set-Cookie")
)
with client.session_transaction() as sess:
assert sess["user_id"] == "alice"
assert sess["db_user_id"] == user["id"]
assert sess["is_admin"] is False
assert sess.permanent is True
assert "alice" not in main_module.failed_login_attempts
def test_oidc_mode_allows_password_fallback(self, main_module, client, temp_user_db):
_make_alice(temp_user_db, role="admin")
with _mode(main_module, "oidc"):
response = _login(client)
assert response.status_code == 200
with client.session_transaction() as sess:
assert sess["is_admin"] is True
def test_unknown_user_is_counted_and_gets_the_generic_message(
self, main_module, client, temp_user_db
):
with _mode(main_module, "builtin"):
response = _login(client, username="nobody")
assert response.status_code == 401
assert response.get_json() == {"error": "Invalid username or password."}
assert main_module.failed_login_attempts["nobody"]["count"] == 1
with client.session_transaction() as sess:
assert "user_id" not in sess
def test_user_without_password_hash_is_rejected(self, main_module, client, temp_user_db):
temp_user_db.create_user(username="alice", role="user")
with _mode(main_module, "builtin"):
response = _login(client)
assert response.status_code == 401
assert main_module.failed_login_attempts["alice"]["count"] == 1
def test_wrong_password_writes_no_session(self, main_module, client, temp_user_db):
_make_alice(temp_user_db)
with _mode(main_module, "builtin"):
response = _login(client, password="wrong")
assert response.status_code == 401
with client.session_transaction() as sess:
assert "user_id" not in sess
assert "is_admin" not in sess
def test_no_user_database_is_503(self, main_module, client, monkeypatch):
monkeypatch.setattr(main_module, "user_db", None)
with _mode(main_module, "builtin"):
response = _login(client)
assert response.status_code == 503
assert response.get_json() == {"error": "Authentication service unavailable"}
def test_database_error_is_500(self, main_module, client, temp_user_db, monkeypatch):
def boom(**kwargs):
raise sqlite3.OperationalError("fake failure")
monkeypatch.setattr(temp_user_db, "get_user", boom)
with _mode(main_module, "builtin"):
response = _login(client)
assert response.status_code == 500
assert response.get_json() == {"error": "Authentication system error"}
def test_unexpected_mode_is_500(self, main_module, client):
with _mode(main_module, "unavailable"):
response = _login(client)
assert response.status_code == 500
assert response.get_json() == {"error": "Unknown authentication mode"}
class TestLoginNoneMode:
def test_any_credentials_succeed_and_clear_failures(self, main_module, client):
main_module.failed_login_attempts["guest"] = {"count": 2}
with _mode(main_module, "none"):
response = _login(client, username="guest", password="any", remember_me=False)
assert response.status_code == 200
assert response.get_json() == {"success": True}
with client.session_transaction() as sess:
assert sess["user_id"] == "guest"
assert "is_admin" not in sess
assert sess.permanent is False
assert "guest" not in main_module.failed_login_attempts
class TestLoginCwa:
@staticmethod
def _cwa_db(path, name="cwa_fake", password="cwahash", role=0, email=None):
conn = sqlite3.connect(path)
conn.execute(
"CREATE TABLE user (name TEXT PRIMARY KEY, password TEXT, role INTEGER, email TEXT)"
)
conn.execute(
"INSERT INTO user (name, password, role, email) VALUES (?, ?, ?, ?)",
(name, password, role, email),
)
conn.commit()
conn.close()
return path
def test_success_non_admin(self, main_module, client, tmp_path, temp_user_db):
path = self._cwa_db(tmp_path / "app.db", role=0, email="c@example.invalid")
with (
_mode(main_module, "cwa"),
patch.object(main_module, "CWA_DB_PATH", path),
patch.object(main_module, "check_password_hash", return_value=True),
):
response = _login(client, username="cwa_fake", remember_me=True)
assert response.status_code == 200
assert response.get_json() == {"success": True}
with client.session_transaction() as sess:
assert sess["user_id"] == "cwa_fake"
assert sess["is_admin"] is False
assert sess["db_user_id"] == temp_user_db.get_user(username="cwa_fake")["id"]
assert sess.permanent is True
def test_admin_bit_makes_admin(self, main_module, client, tmp_path, temp_user_db):
path = self._cwa_db(tmp_path / "app.db", role=3)
with (
_mode(main_module, "cwa"),
patch.object(main_module, "CWA_DB_PATH", path),
patch.object(main_module, "check_password_hash", return_value=True),
):
response = _login(client, username="cwa_fake")
assert response.status_code == 200
with client.session_transaction() as sess:
assert sess["is_admin"] is True
def test_wrong_password_is_counted(self, main_module, client, tmp_path, temp_user_db):
path = self._cwa_db(tmp_path / "app.db")
with (
_mode(main_module, "cwa"),
patch.object(main_module, "CWA_DB_PATH", path),
patch.object(main_module, "check_password_hash", return_value=False),
):
response = _login(client, username="cwa_fake")
assert response.status_code == 401
assert response.get_json() == {"error": "Invalid username or password."}
assert main_module.failed_login_attempts["cwa_fake"]["count"] == 1
def test_unknown_cwa_user_is_counted(self, main_module, client, tmp_path, temp_user_db):
path = self._cwa_db(tmp_path / "app.db")
with _mode(main_module, "cwa"), patch.object(main_module, "CWA_DB_PATH", path):
response = _login(client, username="someone_else")
assert response.status_code == 401
assert main_module.failed_login_attempts["someone_else"]["count"] == 1
def test_missing_database_is_500(self, main_module, client, tmp_path):
with (
_mode(main_module, "cwa"),
patch.object(main_module, "CWA_DB_PATH", tmp_path / "gone.db"),
):
response = _login(client, username="cwa_fake")
assert response.status_code == 500
assert response.get_json() == {"error": "Database configuration error"}
def test_unset_database_path_is_500(self, main_module, client):
with _mode(main_module, "cwa"), patch.object(main_module, "CWA_DB_PATH", None):
response = _login(client, username="cwa_fake")
assert response.status_code == 500
assert response.get_json() == {"error": "Database configuration error"}
def test_unreadable_database_is_500(self, main_module, client, tmp_path):
path = tmp_path / "app.db"
path.write_text("this is not a sqlite file")
with _mode(main_module, "cwa"), patch.object(main_module, "CWA_DB_PATH", path):
response = _login(client, username="cwa_fake")
assert response.status_code == 500
assert response.get_json() == {"error": "Authentication system error"}
class TestLoginLockout:
def test_warning_starts_at_five_remaining(self, main_module, client, temp_user_db):
_make_alice(temp_user_db)
messages = []
with _mode(main_module, "builtin"):
for _ in range(9):
response = _login(client, password="wrong")
assert response.status_code == 401
messages.append(response.get_json()["error"])
assert messages[:4] == ["Invalid username or password."] * 4
assert messages[4] == "Invalid username or password. 5 attempts remaining."
assert messages[8] == "Invalid username or password. 1 attempts remaining."
assert main_module.failed_login_attempts["alice"]["count"] == 9
assert "lockout_until" not in main_module.failed_login_attempts["alice"]
def test_tenth_failure_locks_for_thirty_minutes(self, main_module, client, temp_user_db):
_make_alice(temp_user_db)
before = datetime.now(UTC)
with _mode(main_module, "builtin"):
for _ in range(9):
_login(client, password="wrong")
response = _login(client, password="wrong")
after = datetime.now(UTC)
assert response.status_code == 429
assert response.get_json() == {
"error": "Account locked due to 10 failed login attempts. Try again in 30 minutes."
}
until = main_module.failed_login_attempts["alice"]["lockout_until"]
assert before + timedelta(minutes=30) <= until <= after + timedelta(minutes=30)
def test_locked_account_refuses_even_the_right_password(
self, main_module, client, temp_user_db
):
_make_alice(temp_user_db)
main_module.failed_login_attempts["alice"] = {
"count": 10,
"lockout_until": datetime.now(UTC) + timedelta(minutes=12, seconds=30),
}
with _mode(main_module, "builtin"):
response = _login(client)
assert response.status_code == 429
assert response.get_json() == {
"error": "Account temporarily locked due to multiple failed login attempts. "
"Try again in 12 minutes."
}
with client.session_transaction() as sess:
assert "user_id" not in sess
# A refused attempt does not extend the lockout or bump the counter.
assert main_module.failed_login_attempts["alice"]["count"] == 10
def test_lockout_is_per_username(self, main_module, client, temp_user_db):
_make_alice(temp_user_db)
temp_user_db.create_user(
username="bob", password_hash=generate_password_hash("fake-2"), role="user"
)
main_module.failed_login_attempts["alice"] = {
"count": 10,
"lockout_until": datetime.now(UTC) + timedelta(minutes=5),
}
with _mode(main_module, "builtin"):
response = _login(client, username="bob", password="fake-2")
assert response.status_code == 200
def test_lockout_applies_in_none_and_cwa_modes_too(self, main_module, client):
main_module.failed_login_attempts["guest"] = {
"count": 10,
"lockout_until": datetime.now(UTC) + timedelta(minutes=5),
}
with _mode(main_module, "none"):
response = _login(client, username="guest")
assert response.status_code == 429
def test_expired_lockout_lets_the_user_in_and_forgets_the_entry(
self, main_module, client, temp_user_db
):
_make_alice(temp_user_db)
main_module.failed_login_attempts["alice"] = {
"count": 10,
"lockout_until": datetime.now(UTC) - timedelta(seconds=1),
}
with _mode(main_module, "builtin"):
response = _login(client)
assert response.status_code == 200
assert "alice" not in main_module.failed_login_attempts
def test_expired_lockout_restarts_the_count_after_a_failure(
self, main_module, client, temp_user_db
):
_make_alice(temp_user_db)
main_module.failed_login_attempts["alice"] = {
"count": 10,
"lockout_until": datetime.now(UTC) - timedelta(minutes=1),
}
with _mode(main_module, "builtin"):
response = _login(client, password="wrong")
assert response.status_code == 401
assert main_module.failed_login_attempts["alice"] == {"count": 1}
def test_expiry_sweep_only_drops_expired_entries(self, main_module):
now = datetime.now(UTC)
main_module.failed_login_attempts.clear()
main_module.failed_login_attempts.update(
{
"expired": {"count": 10, "lockout_until": now - timedelta(minutes=1)},
"active": {"count": 10, "lockout_until": now + timedelta(minutes=1)},
"partial": {"count": 4},
}
)
try:
assert main_module.is_account_locked("active") is True
assert set(main_module.failed_login_attempts) == {"active", "partial"}
finally:
main_module.failed_login_attempts.clear()
def test_malformed_timestamp_below_threshold_is_ignored(self, main_module):
main_module.failed_login_attempts.clear()
main_module.failed_login_attempts["odd"] = {"count": 2, "lockout_until": "tomorrow"}
try:
assert main_module.is_account_locked("odd") is False
finally:
main_module.failed_login_attempts.clear()
def test_helpers_return_and_mutate_the_shared_state(self, main_module):
main_module.failed_login_attempts.clear()
state = main_module.failed_login_attempts
try:
for attempt in range(1, 10):
assert main_module.record_failed_login("u", "203.0.113.9") is False
assert state["u"]["count"] == attempt
assert main_module.record_failed_login("u", "203.0.113.9") is True
assert main_module.is_account_locked("u") is True
main_module.clear_failed_logins("u")
assert "u" not in state
main_module.clear_failed_logins("never-seen")
assert main_module.failed_login_attempts is state
finally:
main_module.failed_login_attempts.clear()
def test_limits(self, main_module):
assert main_module.MAX_LOGIN_ATTEMPTS == 10
assert main_module.LOCKOUT_DURATION_MINUTES == 30
assert main_module.LOGIN_ATTEMPT_WARNING_THRESHOLD == 5
class TestLoginLocalAuthSwitches:
@pytest.mark.parametrize("mode", ["builtin", "oidc"])
def test_disable_local_auth_refuses_before_the_lockout_check(self, main_module, client, mode):
main_module.failed_login_attempts["alice"] = {
"count": 10,
"lockout_until": datetime.now(UTC) + timedelta(minutes=5),
}
with _mode(main_module, mode), patch.object(main_module, "DISABLE_LOCAL_AUTH", True):
response = _login(client)
assert response.status_code == 403
assert response.get_json() == {"error": "Local authentication is disabled"}
def test_hide_local_auth_only_blocks_oidc(self, main_module, client, temp_user_db):
_make_alice(temp_user_db)
with _mode(main_module, "builtin"), patch.object(main_module, "HIDE_LOCAL_AUTH", True):
assert _login(client).status_code == 200
with _mode(main_module, "oidc"), patch.object(main_module, "HIDE_LOCAL_AUTH", True):
assert _login(client).status_code == 403
def test_disable_local_auth_does_not_affect_none_mode(self, main_module, client):
with _mode(main_module, "none"), patch.object(main_module, "DISABLE_LOCAL_AUTH", True):
assert _login(client).status_code == 200
class TestLoginClientIp:
def test_the_client_address_is_looked_up_per_request(self, main_module, client, temp_user_db):
_make_alice(temp_user_db)
with (
_mode(main_module, "builtin"),
patch.object(main_module, "get_client_ip", return_value="198.51.100.7") as lookup,
):
response = _login(client, password="wrong")
assert response.status_code == 401
assert lookup.called
class TestLogout:
@pytest.mark.parametrize("mode", ["builtin", "oidc", "cwa", "none"])
def test_clears_the_session_and_answers_success(self, main_module, client, mode):
with client.session_transaction() as sess:
sess["user_id"] = "alice"
sess["db_user_id"] = 1
sess["is_admin"] = True
with _mode(main_module, mode):
response = client.post("/api/auth/logout")
assert response.status_code == 200
assert response.get_json() == {"success": True}
with client.session_transaction() as sess:
assert dict(sess) == {}
def test_without_a_session_still_succeeds(self, main_module, client):
with _mode(main_module, "builtin"):
response = client.post("/api/auth/logout")
assert response.status_code == 200
assert response.get_json() == {"success": True}
def test_proxy_without_a_configured_url_has_no_logout_url(self, main_module, client):
with (
_mode(main_module, "proxy"),
patch.object(
main_module.app_config,
"get",
side_effect=lambda key, default=None, user_id=None: default,
),
):
response = client.post("/api/auth/logout")
assert response.get_json() == {"success": True}
def test_a_non_proxy_mode_ignores_the_logout_url(self, main_module, client):
with (
_mode(main_module, "builtin"),
patch.object(
main_module.app_config,
"get",
side_effect=lambda key, default=None, user_id=None: {
"PROXY_AUTH_LOGOUT_URL": "https://auth.example.invalid/out"
}.get(key, default),
),
):
response = client.post("/api/auth/logout")
assert response.get_json() == {"success": True}
def test_only_post_is_allowed(self, main_module):
rules = [r for r in main_module.app.url_map.iter_rules() if r.rule == "/api/auth/logout"]
assert [sorted(r.methods - {"OPTIONS"}) for r in rules] == [["POST"]]
def test_error_is_500(self, main_module, client):
with (
_mode(main_module, "builtin"),
patch.object(main_module, "get_client_ip", side_effect=RuntimeError("fake")),
):
response = client.post("/api/auth/logout")
assert response.status_code == 500
assert response.get_json() == {"error": "Logout failed"}
class TestAuthCheck:
def test_none_mode_ignores_the_session(self, main_module, client):
with _mode(main_module, "none"):
response = client.get("/api/auth/check")
assert response.get_json() == {
"authenticated": True,
"auth_required": False,
"auth_mode": "none",
"is_admin": True,
}
def test_builtin_without_session(self, main_module, client, temp_user_db):
with _mode(main_module, "builtin"):
response = client.get("/api/auth/check")
assert response.status_code == 200
assert response.get_json() == {
"authenticated": False,
"auth_required": True,
"auth_mode": "builtin",
"is_admin": False,
"username": None,
"display_name": None,
}
def test_non_admin_session_reports_not_admin(self, main_module, client, temp_user_db):
user = _make_alice(temp_user_db, role="user")
with client.session_transaction() as sess:
sess["user_id"] = "alice"
sess["db_user_id"] = user["id"]
sess["is_admin"] = False
with _mode(main_module, "builtin"):
body = client.get("/api/auth/check").get_json()
assert body["authenticated"] is True
assert body["is_admin"] is False
assert body["username"] == "alice"
def test_cwa_session_admin_flag_comes_from_the_session(self, main_module, client):
with client.session_transaction() as sess:
sess["user_id"] = "cwa_fake"
sess["is_admin"] = True
with _mode(main_module, "cwa"):
body = client.get("/api/auth/check").get_json()
assert body == {
"authenticated": True,
"auth_required": True,
"auth_mode": "cwa",
"is_admin": True,
"username": "cwa_fake",
"display_name": None,
}
def test_oidc_options(self, main_module, client):
values = {"OIDC_BUTTON_LABEL": "Fake SSO"}
with (
_mode(main_module, "oidc"),
patch.object(main_module, "HIDE_LOCAL_AUTH", True),
patch.object(main_module, "OIDC_AUTO_REDIRECT", True),
patch.object(
main_module.app_config,
"get",
side_effect=lambda key, default=None, user_id=None: values.get(key, default),
),
):
body = client.get("/api/auth/check").get_json()
assert body["oidc_button_label"] == "Fake SSO"
assert body["hide_local_auth"] is True
assert body["oidc_auto_redirect"] is True
def test_oidc_without_options_has_no_extra_keys(self, main_module, client):
with (
_mode(main_module, "oidc"),
patch.object(main_module, "DISABLE_LOCAL_AUTH", False),
patch.object(main_module, "HIDE_LOCAL_AUTH", False),
patch.object(main_module, "OIDC_AUTO_REDIRECT", False),
patch.object(
main_module.app_config,
"get",
side_effect=lambda key, default=None, user_id=None: default,
),
):
body = client.get("/api/auth/check").get_json()
assert set(body) == {
"authenticated",
"auth_required",
"auth_mode",
"is_admin",
"username",
"display_name",
}
def test_proxy_logout_url_needs_the_user_header_setting(self, main_module, client):
def build(values):
return lambda key, default=None, user_id=None: values.get(key, default)
url = {"PROXY_AUTH_LOGOUT_URL": "https://auth.example.invalid/out"}
with client.session_transaction() as sess:
sess["user_id"] = "p"
sess["is_admin"] = False
with (
_mode(main_module, "proxy"),
patch.object(main_module.app_config, "get", side_effect=build(url)),
):
assert "logout_url" not in client.get("/api/auth/check").get_json()
with (
_mode(main_module, "proxy"),
patch.object(
main_module.app_config,
"get",
side_effect=build({**url, "PROXY_AUTH_USER_HEADER": "X-Fake"}),
),
):
body = client.get("/api/auth/check").get_json()
assert body["logout_url"] == "https://auth.example.invalid/out"
def test_display_name_lookup_failure_is_ignored(
self, main_module, client, temp_user_db, monkeypatch
):
user = _make_alice(temp_user_db)
with client.session_transaction() as sess:
sess["user_id"] = "alice"
sess["db_user_id"] = user["id"]
sess["is_admin"] = False
def boom(**kwargs):
raise sqlite3.Error("fake")
monkeypatch.setattr(temp_user_db, "get_user", boom)
with _mode(main_module, "builtin"):
body = client.get("/api/auth/check").get_json()
assert body["authenticated"] is True
assert body["display_name"] is None
def test_error_reports_unknown_mode(self, main_module, client):
with (
_mode(main_module, "builtin"),
patch.object(
main_module, "get_auth_check_admin_status", side_effect=RuntimeError("fake")
),
):
response = client.get("/api/auth/check")
assert response.status_code == 200
assert response.get_json() == {
"authenticated": False,
"auth_required": True,
"auth_mode": "unknown",
"is_admin": False,
}
def test_only_get_is_allowed(self, main_module):
rules = [r for r in main_module.app.url_map.iter_rules() if r.rule == "/api/auth/check"]
assert [sorted(r.methods - {"OPTIONS"}) for r in rules] == [["GET", "HEAD"]]
class TestLockoutCleanupRace:
def test_cleanup_survives_an_entry_cleared_while_it_runs(self, main_module, client):
# A successful login can clear an expired entry between the cleanup collecting it and
# removing it (the log write in between is a yield point under gevent).
from shelfmark.core import auth_routes
expired = datetime.now(UTC) - timedelta(minutes=1)
main_module.failed_login_attempts["alice"] = {"count": 10, "lockout_until": expired}
def cleared_meanwhile(*_args, **_kwargs):
auth_routes.clear_failed_logins("alice")
with patch.object(auth_routes.logger, "info", side_effect=cleared_meanwhile):
auth_routes.cleanup_old_lockouts()
assert "alice" not in main_module.failed_login_attempts
+166
View File
@@ -2621,6 +2621,9 @@ class TestRetryAfterCooldown:
"Failed to add to qbittorrent: Failed to connect to qBittorrent. This i",
"Cannot connect to qBittorrent at http://x",
"Connection refused",
"Failed to add to qbittorrent: qBittorrent authentication failed (HTTP 403)",
"Failed to add to qbittorrent: qBittorrent authentication failed (HTTP 401)",
"Failed to add to deluge: Deluge Web UI authentication failed",
],
)
def test_a_download_whose_client_was_down_is_not_held_against_its_release(
@@ -3620,3 +3623,166 @@ class TestHistoryOrder:
== 1
)
assert suite._state(user_db, rid) == "cancelled"
class TestOwnerPolicyGate:
"""Auto-download fulfils a request only where its owner could have downloaded it directly.
It runs as an admin, so without this a request made under an approval policy would be
approved by the pass instead of waiting for an admin.
"""
@staticmethod
def _setup(monkeypatch, *, policy, auth_mode="builtin", searched=None):
"""Turn auto-download on with two sources, the given policy and auth mode.
Every source has a match.
"""
monkeypatch.setattr(auto_download, "app_config", _Config(AUTO_DOWNLOAD_ENABLED=True))
monkeypatch.setattr(
auto_download, "_configured_source_priority", lambda _ct: ["first", "second"]
)
monkeypatch.setattr("shelfmark.core.auth_modes.load_active_auth_mode", lambda: auth_mode)
monkeypatch.setattr(
auto_download, "load_users_request_policy_settings", lambda: dict(policy)
)
monkeypatch.setattr(
"shelfmark.core.library_index.is_in_library", lambda *_args, **_kwargs: False
)
_stub_provider(monkeypatch, _book())
def _search(source_name, *_args, **_kwargs):
"""Return one matching release from any source asked, recording its name."""
if searched is not None:
searched.append(source_name)
return None, [_release(source=source_name)], None
monkeypatch.setattr(auto_download, "search_source_releases", _search)
@staticmethod
def _queue(release_data, priority, *, user_id, username):
return True, None
def _run(self, user_db, admin):
return auto_download.auto_download_pending(
user_db, queue_release=self._queue, admin_user_id=admin["id"]
)
APPROVAL = {"REQUESTS_ENABLED": True, "REQUEST_POLICY_DEFAULT_AUDIOBOOK": "request_book"}
DIRECT = {"REQUESTS_ENABLED": True, "REQUEST_POLICY_DEFAULT_AUDIOBOOK": "download"}
def test_a_request_awaiting_approval_is_left_exactly_as_it_was(self, user_db, monkeypatch):
searched: list[str] = []
self._setup(monkeypatch, policy=self.APPROVAL, searched=searched)
admin = user_db.create_user(username="admin", role="admin")
reader = user_db.create_user(username="reader", role="user")
row = _pending_request(user_db, reader["id"])
summary = self._run(user_db, admin)
assert summary["queued"] == 0
assert summary["awaiting_approval"] == 1
assert searched == []
# No backoff state, note or review: the same as a manual request awaiting review.
assert user_db.get_request(row["id"]) == row
def test_a_request_the_owner_could_download_directly_is_fulfilled(self, user_db, monkeypatch):
self._setup(monkeypatch, policy=self.DIRECT)
admin = user_db.create_user(username="admin", role="admin")
reader = user_db.create_user(username="reader", role="user")
row = _pending_request(user_db, reader["id"])
summary = self._run(user_db, admin)
assert summary["queued"] == 1
assert user_db.get_request(row["id"])["status"] == "fulfilled"
def test_an_admins_synced_request_is_fulfilled_whatever_the_policy(self, user_db, monkeypatch):
self._setup(monkeypatch, policy=self.APPROVAL)
admin = user_db.create_user(username="admin", role="admin")
row = _pending_request(user_db, admin["id"])
summary = self._run(user_db, admin)
assert summary["queued"] == 1
assert user_db.get_request(row["id"])["status"] == "fulfilled"
def test_a_blocked_content_type_is_left_untouched(self, user_db, monkeypatch):
self._setup(
monkeypatch,
policy={"REQUESTS_ENABLED": True, "REQUEST_POLICY_DEFAULT_AUDIOBOOK": "blocked"},
)
admin = user_db.create_user(username="admin", role="admin")
reader = user_db.create_user(username="reader", role="user")
row = _pending_request(user_db, reader["id"])
summary = self._run(user_db, admin)
assert summary["awaiting_approval"] == 1
assert user_db.get_request(row["id"]) == row
def test_only_the_sources_the_owner_may_download_from_are_searched(self, user_db, monkeypatch):
searched: list[str] = []
policy = {
**self.DIRECT,
"REQUEST_POLICY_RULES": [
{"source": "first", "content_type": "audiobook", "mode": "request_release"}
],
}
self._setup(monkeypatch, policy=policy, searched=searched)
admin = user_db.create_user(username="admin", role="admin")
reader = user_db.create_user(username="reader", role="user")
_pending_request(user_db, reader["id"])
summary = self._run(user_db, admin)
assert summary["queued"] == 1
assert searched == ["second"]
def test_a_per_user_override_counts(self, user_db, monkeypatch):
self._setup(monkeypatch, policy=self.APPROVAL)
admin = user_db.create_user(username="admin", role="admin")
reader = user_db.create_user(username="reader", role="user")
user_db.set_user_settings(reader["id"], {"REQUEST_POLICY_DEFAULT_AUDIOBOOK": "download"})
_pending_request(user_db, reader["id"])
assert self._run(user_db, admin)["queued"] == 1
def test_with_requests_off_the_owner_downloads_directly_so_the_pass_fulfils(
self, user_db, monkeypatch
):
self._setup(monkeypatch, policy={**self.APPROVAL, "REQUESTS_ENABLED": False})
admin = user_db.create_user(username="admin", role="admin")
reader = user_db.create_user(username="reader", role="user")
_pending_request(user_db, reader["id"])
assert self._run(user_db, admin)["queued"] == 1
def test_without_login_there_is_no_policy_to_apply(self, user_db, monkeypatch):
self._setup(monkeypatch, policy=self.APPROVAL, auth_mode="none")
admin = user_db.create_user(username="admin", role="admin")
reader = user_db.create_user(username="reader", role="user")
_pending_request(user_db, reader["id"])
assert self._run(user_db, admin)["queued"] == 1
@pytest.mark.parametrize(("policy", "reopened"), [(APPROVAL, 0), (DIRECT, 1)])
def test_a_failed_download_is_only_reopened_when_the_pass_could_fulfil_it(
self, user_db, monkeypatch, policy, reopened
):
"""A failed download is reopened only when its owner's policy lets the pass fulfil it."""
self._setup(monkeypatch, policy=policy)
reader = user_db.create_user(username="reader", role="user")
row = TestRetryAfterCooldown._request(user_db, reader["id"], provider_id="1")
assert (
auto_download.reopen_stale_failures(
user_db,
cooldown_days=7,
db_path=user_db._db_path,
now=TestRetryAfterCooldown.NOW,
)
== reopened
)
expected = "pending" if reopened else "fulfilled"
assert user_db.get_request(row["id"])["status"] == expected
Loaded 100 of 135 files, more files were not shown because too many files have changed in this diff. Show more