Compare commits

...
30 Commits
Author SHA1 Message Date
dependabot[bot]andCaliBrain cdd156e142 Bump the python-deps group across 1 directory with 2 updates (#1153)
Bumps the python-deps group with 2 updates in the / directory:
[tqdm](https://github.com/tqdm/tqdm) and
[seleniumbase](https://github.com/seleniumbase/SeleniumBase).

Updates `tqdm` from 4.69.1 to 4.70.0
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/tqdm/tqdm/releases">tqdm's
releases</a>.</em></p>
<blockquote>
<h2>tqdm v4.70.0 stable</h2>
<ul>
<li><code>contrib.concurrent</code>: major improvements
<ul>
<li>support <code>process_map(mp_context, max_tasks_per_child)</code>,
<code>thread_map(thread_name_prefix)</code> (<a
href="https://redirect.github.com/tqdm/tqdm/issues/1265">#1265</a>)</li>
<li>fix <code>total</code> based on shortest iterable length (<a
href="https://redirect.github.com/tqdm/tqdm/issues/1473">#1473</a>)</li>
<li>use default <code>max_workers</code> (<a
href="https://redirect.github.com/tqdm/tqdm/issues/1543">#1543</a> &lt;-
<a href="https://redirect.github.com/tqdm/tqdm/issues/1530">#1530</a>,
<a
href="https://redirect.github.com/tqdm/tqdm/issues/1518">#1518</a>)</li>
<li>support <code>timeout</code>, <code>buffersize</code> (<a
href="https://redirect.github.com/tqdm/tqdm/issues/1576">#1576</a>)</li>
<li>improve ETA (<a
href="https://redirect.github.com/tqdm/tqdm/issues/1708">#1708</a> &lt;-
<a
href="https://redirect.github.com/tqdm/tqdm/issues/1161">#1161</a>)</li>
<li>update <code>as_completed</code> (<a
href="https://redirect.github.com/tqdm/tqdm/issues/1709">#1709</a> &lt;-
<a
href="https://redirect.github.com/tqdm/tqdm/issues/1565">#1565</a>)</li>
<li>add <code>tqdm.concurrent.intepreter_map</code> (<a
href="https://redirect.github.com/tqdm/tqdm/issues/1777">#1777</a>)</li>
</ul>
</li>
<li><code>asyncio</code>: support iterables with only
<code>__aiter__</code> (<a
href="https://redirect.github.com/tqdm/tqdm/issues/1714">#1714</a> &lt;-
<a
href="https://redirect.github.com/tqdm/tqdm/issues/1686">#1686</a>)</li>
<li>support <code>reset(float(&quot;inf&quot;))</code> (<a
href="https://redirect.github.com/tqdm/tqdm/issues/1783">#1783</a> &lt;-
<a href="https://redirect.github.com/tqdm/tqdm/issues/1781">#1781</a>,
<a
href="https://redirect.github.com/tqdm/tqdm/issues/651">#651</a>)</li>
<li>framework: test &amp; reduce wheel size (<a
href="https://redirect.github.com/tqdm/tqdm/issues/1782">#1782</a>)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/tqdm/tqdm/commit/96f2e60e4584cdab57a23602e27043d0465254ad"><code>96f2e60</code></a>
Merge pull request <a
href="https://redirect.github.com/tqdm/tqdm/issues/1777">#1777</a> from
shermansiu/feat/interpreter-pool</li>
<li><a
href="https://github.com/tqdm/tqdm/commit/c27393ec3a0a91d83dd53f04b80ee61f9c0cd5e4"><code>c27393e</code></a>
misc tidy</li>
<li><a
href="https://github.com/tqdm/tqdm/commit/061c6236d56f8c1ed0934606c3b4ee5c59cf122e"><code>061c623</code></a>
Disable <code>tqdm.monitor_interval</code> for subinterpreters because
they do not have ...</li>
<li><a
href="https://github.com/tqdm/tqdm/commit/9fc160ba7f959f955058985b0d1122fc9f0b2729"><code>9fc160b</code></a>
Update how we check for <code>interpreter_map</code> support in the
tests</li>
<li><a
href="https://github.com/tqdm/tqdm/commit/b42463a20fffc17ad4df2aa145076afb0dc4486d"><code>b42463a</code></a>
Ensure that subinterpreters can import tqdm while unpickling the
initializer</li>
<li><a
href="https://github.com/tqdm/tqdm/commit/16d5486c402a2808ec9c398538e0bc56d00866de"><code>16d5486</code></a>
Add support for nested progress bars</li>
<li><a
href="https://github.com/tqdm/tqdm/commit/9f5890fda6cf43e66782fbf34647f808bdc66d58"><code>9f5890f</code></a>
Add initial implementation for interpreter_map</li>
<li><a
href="https://github.com/tqdm/tqdm/commit/321f920bb7368e27eee04fed1c0de2a8a236fd9d"><code>321f920</code></a>
Merge pull request <a
href="https://redirect.github.com/tqdm/tqdm/issues/1783">#1783</a> from
LuShadowX/reset-inf-total</li>
<li><a
href="https://github.com/tqdm/tqdm/commit/4664b5701ebc5299290a1fb31a229fb1bfc64703"><code>4664b57</code></a>
minor tidy</li>
<li><a
href="https://github.com/tqdm/tqdm/commit/426a0988a1e9970041f5a649ae8a6376c5293fbe"><code>426a098</code></a>
Treat inf total as unknown in reset() too</li>
<li>Additional commits viewable in <a
href="https://github.com/tqdm/tqdm/compare/v4.69.1...v4.70.0">compare
view</a></li>
</ul>
</details>
<br />

Updates `seleniumbase` from 4.51.7 to 4.51.8
<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.51.8 - Add <code>drag_and_drop()</code> with CDP</h2>
<h2>Add <code>drag_and_drop()</code> with CDP</h2>
<ul>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/b05c11f370e69d7566d59d90883c8e8fec1c0024">Add
a CDP-based drag_and_drop() method</a>
--&gt; This resolves <a
href="https://redirect.github.com/seleniumbase/SeleniumBase/issues/4442">seleniumbase/SeleniumBase#4442</a></li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/9d9ffb4f3b335da6674917b395958c9378186067">Update
CDP Mode examples</a>
--&gt; Specifically for <code>drag_and_drop()</code></li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/57f2a4ec5dd286222b114acb5d2272ad459d787d">Update
the docs</a></li>
</ul>
<h2>What's Changed</h2>
<ul>
<li>Add <code>drag_and_drop()</code> with CDP by <a
href="https://github.com/mdmintz"><code>@​mdmintz</code></a> in <a
href="https://redirect.github.com/seleniumbase/SeleniumBase/pull/4443">seleniumbase/SeleniumBase#4443</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/seleniumbase/SeleniumBase/compare/v4.51.7...v4.51.8">https://github.com/seleniumbase/SeleniumBase/compare/v4.51.7...v4.51.8</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/8abc55e9f5ef3f2e1d8c4d92351bcbbf7163e7ce"><code>8abc55e</code></a>
Merge pull request <a
href="https://redirect.github.com/seleniumbase/SeleniumBase/issues/4443">#4443</a>
from seleniumbase/drag-and-drop-with-cdp</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/89642ff4894e3351739dc359632566902379ca64"><code>89642ff</code></a>
Version 4.51.8</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/9d9ffb4f3b335da6674917b395958c9378186067"><code>9d9ffb4</code></a>
Update CDP Mode examples</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/57f2a4ec5dd286222b114acb5d2272ad459d787d"><code>57f2a4e</code></a>
Update the docs</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/b05c11f370e69d7566d59d90883c8e8fec1c0024"><code>b05c11f</code></a>
Add a CDP-based <code>drag_and_drop()</code> method</li>
<li>See full diff in <a
href="https://github.com/seleniumbase/SeleniumBase/compare/v4.51.7...v4.51.8">compare
view</a></li>
</ul>
</details>
<br />


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

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

---

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

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


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: CaliBrain <calibrain@l4n.xyz>
2026-07-31 00:23:42 -04:00
CaliBrainandClaude Opus 5 21f2b6b95c Fix torrent post-import category follow-ups and clear the lint backlog (#1154)
rTorrent set_category and remove now uppercase the info hash, which
reaches us lowercase while rTorrent's XML-RPC lookups are case
sensitive. remove() had this bug before #1148, making
PROWLARR_TORRENT_ACTION=remove a silent no-op for rTorrent.

Transmission set_category appends the post-import label instead of
replacing the whole label list. The unsupported-client path in
post_process_cleanup logs at debug instead of warning, so Real-Debrid
and AllDebrid users stop seeing a warning on every successful import.

The Real-Debrid and AllDebrid clients now follow the conventions used by
the other clients (_raise_runtime_error helpers, narrow error tuples,
ClassVar, Path.open), and register_client is generic over a TypeVar
bound to DownloadClient so decorated classes keep their concrete type.
make fix and the lint, format and typecheck targets all pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 00:17:28 -04:00
CaliBrain 10554444a9 Create a Tor watchguard and make iptables non mandatory (#1152)
Fix https://github.com/calibrain/shelfmark/issues/1150
2026-07-30 23:41:55 -04:00
CaliBrain ce026e2eb8 IRC health checks (#1151) 2026-07-30 23:39:12 -04:00
Tilian B ff770940ca Add torrent post-import category action (#1148)
## Summary

- add a **Change Category** torrent completion action and conditionally
show its post-import category/label setting
- update kept torrents only after a successful library import
- support qBittorrent categories, Transmission labels, Deluge's Label
plugin, and rTorrent's `custom1` label
- preserve existing Keep/Remove behavior and document the new
environment setting

## Behavior

Category changes happen from `post_process_cleanup`, after output
transfer and post-processing complete. This keeps the existing hardlink
flow unchanged. An empty post-import category is a no-op, and client API
failures are logged without turning a successful library import into a
failure.

## Validation

- `pytest -n 0 tests/prowlarr/test_qbittorrent_client.py
tests/prowlarr/test_transmission_client.py
tests/prowlarr/test_deluge_client.py
tests/prowlarr/test_rtorrent_client.py tests/prowlarr/test_handler.py
tests/config/test_generate_env_docs.py` — 161 passed
- `ruff check` on all changed Python files — passed
- `basedpyright` on the changed client implementation files — passed
- full `basedpyright shelfmark/download/clients` currently reports two
pre-existing errors in the new debrid connection-test code at
`settings.py:546` and `settings.py:563`, outside this PR's diff
- multi-architecture Docker images built successfully for `linux/amd64`
and `linux/arm64`
2026-07-30 23:38:53 -04:00
PaulVumon 340853477f feat(download): add native Real-Debrid and AllDebrid debrid service clients (#1146)
## Summary
Adds native download client support for debrid services (**Real-Debrid**
and **AllDebrid**), routing magnet links and torrents through their APIs
to download content via their CDN infrastructure.

## Features
- **Real-Debrid Client (\RealDebridClient\)**:
- API v1.0 integration (magnet upload, file selection, status polling,
link unrestricting, HTTP download)
  - Premium account validation via connection test
- **AllDebrid Client (\AllDebridClient\)**:
- API v4/v4.1 integration (magnet upload, status polling, file tree
listing, link unlocking with delayed link support, HTTP download)
  - Multi-tier link resolution strategy with automatic fallback
  - Premium account validation via connection test
- **Common & UI**:
- Ebook & Audiobook file filtering (\.epub\, \.m4b\, \.mp3\, \.pdf\,
\.flac\, etc.)
- Settings UI options in \PROWLARR_TORRENT_CLIENT\ dropdown with API key
configuration and test connection buttons
2026-07-29 22:22:48 -04:00
816a735cde Add a {Language} naming template variable, and consolidate language resolution (#1142)
Fixes #1138
Fixes #1141

## Problem

Two language editions of one book resolve to the same canonical title,
so they render to the same path and the second gets a `_1` collision
suffix. Audiobookshelf treats a folder as exactly one library item, so
the pair becomes a single book with both files as tracks and a summed
runtime.

Shelfmark already parses and displays the language. It just never
reached the template engine.

## `{Language}` template variable

A template like `{Author}/{Title}{ (Language)}/{Author} - {Title}` now
yields:

```
/library/J K Rowling/Harry Potter (sv)/J K Rowling - Harry Potter.m4b
/library/J K Rowling/Harry Potter/J K Rowling - Harry Potter.m4b
```

The untagged edition's path is byte-identical to today, so no existing
layout shifts.

Three details worth flagging:

**The value is casefolded.** On a case-insensitive filesystem `(SV)` and
`(sv)` would collapse back into one folder, reintroducing the exact
collision being fixed.

**Values meaning "we don't know" render nothing** rather than producing
`Project Hail Mary (unknown)` folders. Anna's Archive reports that
string literally (`direct_download.py`, `language = detected or
"unknown"`).

**The frontend wasn't sending the release language at all**, so the
token would have stayed empty for exactly the audiobook sources in the
report. Prowlarr and AudiobookBay do not put language in `extra` the way
`direct_download` does, hence the payload plumbing. It reads
`release.language`, never `book.language` — the latter is the provider's
canonical edition and would mislabel a translation, with a regression
test for that specifically.

Not gated to audiobooks: Calibre-Web-Automated stages ingested files by
basename and discards folder structure, so the rename (filename)
template is the only lever those users have. Verified that form works:
`J K Rowling - Harry Potter (sv).epub`.

## Language consolidation (#1141)

Three release sources each carried their own alias map, all resolving to
the same ISO 639-1 codes, alongside a bundled database that only one of
them used. Adding a language meant editing three places.

Aliases now live in `data/book-languages.json` beside the code and name
they belong to, and `shelfmark/core/languages.py` resolves any of them —
two-letter code, ISO 639-2 three-letter in either the bibliographic or
terminological form, or English name. Prowlarr and AudiobookBay drop
their tables. Direct Download keeps its own path-parsing heuristics,
including the ambiguous short codes that collide with English words
(`de`, `en`, `no`, `in`), and takes only the alias data.

This also closes a coverage gap. MyAnonamouse offers 62 languages;
Prowlarr mapped 37, and an unmapped code is *dropped* rather than passed
through, so the other 25 carried no language at all — leaving
`{Language}` empty and the collision unfixed for Latin, Farsi, Tamil,
Urdu and the rest. Seven languages MAM offers had no database entry at
all: Bosnian, Burmese, Estonian, Icelandic, Manx, Scottish Gaelic,
Sanskrit.

Also fixes the Traditional Chinese code, which used a U+2011
non-breaking hyphen. Nothing compares against the ASCII spelling today
so it was latent, but it would silently defeat the first thing that did.

## Validation

Verified end to end against a live Prowlarr and MyAnonamouse, not just
unit tests. A real search returning both an English and a Swedish
edition, through the actual `queue_release` → `DownloadTask` → naming
path:

```
STEP 1  real MAM search        -> 37 releases, languages: ['en', 'sv']
STEP 3  queue_release          -> task.language='sv'
STEP 4  build_metadata_dict    -> metadata['Language']='sv'
STEP 5  build_library_path     -> /library/J K Rowling/Harry Potter (sv)/...
two language editions resolve to DIFFERENT folders: True
```

The refactor is pinned by a snapshot of both per-source maps taken
*before* they were deleted. All 131 aliases are asserted to still
resolve to the same code, one parametrised test each, so a regression
names the specific alias.

Also verified: the filename-only template, the retry round-trip
(`serialize_task_for_retry` → `_restore_task_from_retry_payload`, plus a
legacy payload with no `language` key), and placeholder handling.

Added a `KNOWN_TOKENS` ordering invariant test — `find_placeholder()`
does a substring `.find()` in list order and nothing protected that
contract, so a future token in the wrong position could silently shadow
an existing one. And a lockstep guard on the frontend, since
`KNOWN_TOKENS` is hand-duplicated in TypeScript.

**One caveat worth stating.** Three MAM codes are confirmed by
observation (`ENG`→`en`, `SWE`→`sv`, `MAL`→`ml`, the last from a real
`[MAL / EPUB]` Tagore release). The remaining ~59 are derived from ISO
639-2 rather than observed, because MAM's catalogue is overwhelmingly
English — enabling 27 extra languages still yielded only one non-English
hit across 258 results. Mitigated rather than closed: both 639-2
variants are present for every language where they differ, and a wrong
alias is an unused entry while a missing one loses the language. Happy
to correct any code a maintainer knows differs.

## Test results

2056 Python tests pass (up from 1906). Frontend typecheck, lint, format
and 126 unit tests pass.

Pre-existing failures on my machine, unchanged by this branch and
unrelated: `tests/bypass/` needs `seleniumbase`, and
`tests/config/test_entrypoint_permissions.py` uses bash-4 syntax that
macOS bash 3.2 rejects.

---------

Co-authored-by: delize <4028612+delize@users.noreply.github.com>
Co-authored-by: CaliBrain <calibrain@l4n.xyz>
2026-07-28 15:19:59 -04:00
dependabot[bot] d1cdaaeb5e Bump the python-deps group across 1 directory with 3 updates (#1144)
Bumps the python-deps group with 3 updates in the / directory:
[tqdm](https://github.com/tqdm/tqdm),
[seleniumbase](https://github.com/seleniumbase/SeleniumBase) and
[prek](https://github.com/j178/prek).

Updates `tqdm` from 4.69.0 to 4.69.1
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/tqdm/tqdm/releases">tqdm's
releases</a>.</em></p>
<blockquote>
<h2>tqdm v4.69.1 stable</h2>
<ul>
<li><code>contrib.logging</code>: preserve filters (<a
href="https://redirect.github.com/tqdm/tqdm/issues/1775">#1775</a> &lt;-
<a
href="https://redirect.github.com/tqdm/tqdm/issues/1581">#1581</a>)</li>
<li><code>rich</code>: misc fixes (<a
href="https://redirect.github.com/tqdm/tqdm/issues/1773">#1773</a>)
<ul>
<li>support generators (without <code>len</code>) (<a
href="https://redirect.github.com/tqdm/tqdm/issues/1391">#1391</a>)</li>
<li>fix potential <code>AttributeError</code> on exit (<a
href="https://redirect.github.com/tqdm/tqdm/issues/1674">#1674</a>)</li>
</ul>
</li>
<li>fix <code>total=float('inf')</code> (<a
href="https://redirect.github.com/tqdm/tqdm/issues/1781">#1781</a> &lt;-
<a
href="https://redirect.github.com/tqdm/tqdm/issues/651">#651</a>)</li>
<li>fix potential <code>AttributeError</code> on exit (<a
href="https://redirect.github.com/tqdm/tqdm/issues/1778">#1778</a> &lt;-
<a href="https://redirect.github.com/tqdm/tqdm/issues/1668">#1668</a>,
<a href="https://redirect.github.com/tqdm/tqdm/issues/1537">#1537</a>,
<a
href="https://redirect.github.com/tqdm/tqdm/issues/261">#261</a>)</li>
<li>fix minor docs typos (<a
href="https://redirect.github.com/tqdm/tqdm/issues/1780">#1780</a>, <a
href="https://redirect.github.com/tqdm/tqdm/issues/1746">#1746</a>)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/tqdm/tqdm/commit/93370ff891582e458c40196ea78a1a9d720fbb45"><code>93370ff</code></a>
bump version, merge pull requests <a
href="https://redirect.github.com/tqdm/tqdm/issues/1781">#1781</a>, <a
href="https://redirect.github.com/tqdm/tqdm/issues/1773">#1773</a>, <a
href="https://redirect.github.com/tqdm/tqdm/issues/1778">#1778</a>, <a
href="https://redirect.github.com/tqdm/tqdm/issues/1775">#1775</a>, <a
href="https://redirect.github.com/tqdm/tqdm/issues/1780">#1780</a>, <a
href="https://redirect.github.com/tqdm/tqdm/issues/1746">#1746</a></li>
<li><a
href="https://github.com/tqdm/tqdm/commit/238501fa4f86fae2ce268710dd9de0474d36c732"><code>238501f</code></a>
docs: fix redundant word typo in tests_synchronisation.py</li>
<li><a
href="https://github.com/tqdm/tqdm/commit/b78624a9eacffcefa566e1bc6e476f6a03c6ec0a"><code>b78624a</code></a>
contrib.logging: preserve filters</li>
<li><a
href="https://github.com/tqdm/tqdm/commit/9f92bc6439b906f359a9f533d527d7be0ac2f5b7"><code>9f92bc6</code></a>
remove unnecessary comments</li>
<li><a
href="https://github.com/tqdm/tqdm/commit/494372e2be901951b230331870a458025ea1ea9b"><code>494372e</code></a>
Fix AttributeError in <code>close()</code> on failed
<code>__init__</code></li>
<li><a
href="https://github.com/tqdm/tqdm/commit/796cea14a9e43d40838fbe47c4e9287b30552042"><code>796cea1</code></a>
misc tidy</li>
<li><a
href="https://github.com/tqdm/tqdm/commit/164ef7302b4c8c9dd455b523d8e8c38c61e68d50"><code>164ef73</code></a>
rich: misc fixes</li>
<li><a
href="https://github.com/tqdm/tqdm/commit/f18c88e92707add150662885ec8d424e60c80bda"><code>f18c88e</code></a>
fix properly</li>
<li><a
href="https://github.com/tqdm/tqdm/commit/90f59704b59c69a7ea1210a76ccffec58faa8538"><code>90f5970</code></a>
minor tidy</li>
<li><a
href="https://github.com/tqdm/tqdm/commit/ef15578141f2df10f9c169ec619d8a5217f6a37f"><code>ef15578</code></a>
fix: handle total=inf in format_meter to avoid OverflowError</li>
<li>Additional commits viewable in <a
href="https://github.com/tqdm/tqdm/compare/v4.69.0...v4.69.1">compare
view</a></li>
</ul>
</details>
<br />

Updates `seleniumbase` from 4.51.6 to 4.51.7
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/seleniumbase/SeleniumBase/releases">seleniumbase's
releases</a>.</em></p>
<blockquote>
<h2>4.51.7 - Add <code>click_and_hold()</code> with CDP</h2>
<h2>Add <code>click_and_hold()</code> with CDP</h2>
<ul>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/6316b0aa95c947fc4f974662212f3cf77d1f829e">Add
a CDP-based click_and_hold() method</a></li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/e667ad0ff5e19c8f85f7b7f42ba9f672a63639e3">Adjust
timings and refactor</a></li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/f932c2d874c000d0149f2754a32bc955cfd71aa7">Update
examples</a></li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/7775f1db77594bd1642714de013d999e49c36e5c">Update
the docs</a></li>
</ul>
<h2>What's Changed</h2>
<ul>
<li>Add <code>click_and_hold()</code> with CDP by <a
href="https://github.com/mdmintz"><code>@​mdmintz</code></a> in <a
href="https://redirect.github.com/seleniumbase/SeleniumBase/pull/4441">seleniumbase/SeleniumBase#4441</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/seleniumbase/SeleniumBase/compare/v4.51.6...v4.51.7">https://github.com/seleniumbase/SeleniumBase/compare/v4.51.6...v4.51.7</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/ec403eec792569e5d5dda5ff8d8772f1d9c141ae"><code>ec403ee</code></a>
Merge pull request <a
href="https://redirect.github.com/seleniumbase/SeleniumBase/issues/4441">#4441</a>
from seleniumbase/click-and-hold-with-cdp</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/dc07cb87d21ca6991e1e156f6b60db2beb661039"><code>dc07cb8</code></a>
Version 4.51.7</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/7775f1db77594bd1642714de013d999e49c36e5c"><code>7775f1d</code></a>
Update the docs</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/f932c2d874c000d0149f2754a32bc955cfd71aa7"><code>f932c2d</code></a>
Update examples</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/e667ad0ff5e19c8f85f7b7f42ba9f672a63639e3"><code>e667ad0</code></a>
Adjust timings and refactor</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/6316b0aa95c947fc4f974662212f3cf77d1f829e"><code>6316b0a</code></a>
Add a CDP-based <code>click_and_hold()</code> method</li>
<li>See full diff in <a
href="https://github.com/seleniumbase/SeleniumBase/compare/v4.51.6...v4.51.7">compare
view</a></li>
</ul>
</details>
<br />

Updates `prek` from 0.4.10 to 0.4.11
<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.4.11</h2>
<h2>Release Notes</h2>
<p>Released on 2026-07-25.</p>
<h3>Highlights</h3>
<ul>
<li>
<p>This release adds two new builtin hooks, <code>deny-pattern</code>
and <code>require-pattern</code>,
as native alternatives for <code>pygrep</code> use cases.
<code>deny-pattern</code> fails when a
configured pattern is found, while <code>require-pattern</code> ensures
every selected
file contains a match. By matching natively without spawning a Python
subprocess, they run over 4x faster than <code>pygrep</code> in
benchmarks. Note that
they use <a href="https://docs.rs/regex/latest/regex/#syntax">Rust
<code>regex</code> syntax</a>, which does
not support look-around features such as negative lookbehind.</p>
</li>
<li>
<p><code>prek run</code> now supports <code>--glob
&lt;PATTERN&gt;</code> to run hooks on tracked files
matching a glob. It can be repeated or combined with
<code>--files</code> and
<code>--directory</code>.</p>
</li>
<li>
<p>Hook priorities now support reusable aliases:</p>
<pre lang="toml"><code>[priorities]
checks = 10
<p>[[repos]]
repo = &quot;builtin&quot;
hooks = [
{ id = &quot;check-json&quot;, priority = &quot;checks&quot; },
{ id = &quot;check-yaml&quot;, priority = &quot;checks&quot; },
]
</code></pre></p>
<p>This makes parallel scheduling easier to read and maintain.</p>
</li>
</ul>
<h3>Enhancements</h3>
<ul>
<li>Add <code>deny-pattern</code> and <code>require-pattern</code>
builtin hooks (<a
href="https://redirect.github.com/j178/prek/pull/2359">#2359</a>)</li>
<li>Support <code>--glob</code> patterns in <code>prek run</code> (<a
href="https://redirect.github.com/j178/prek/pull/2381">#2381</a>)</li>
<li>Support reusable aliases for hook priorities (<a
href="https://redirect.github.com/j178/prek/pull/2331">#2331</a>)</li>
<li>Implement <code>requirements-txt-fixer</code> as a builtin hook (<a
href="https://redirect.github.com/j178/prek/pull/2390">#2390</a>)</li>
<li>Improve user-facing warnings and errors (<a
href="https://redirect.github.com/j178/prek/pull/2380">#2380</a>)</li>
<li>Install Node hooks through git url (<a
href="https://redirect.github.com/j178/prek/pull/2394">#2394</a>)</li>
</ul>
<h3>Performance</h3>
<ul>
<li>Reduce blocking-pool overhead in file hooks (<a
href="https://redirect.github.com/j178/prek/pull/2384">#2384</a>)</li>
<li>Speed up mixed-line-ending scans with memchr2 (<a
href="https://redirect.github.com/j178/prek/pull/2391">#2391</a>)</li>
</ul>
<h3>Bug fixes</h3>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/j178/prek/blob/master/CHANGELOG.md">prek's
changelog</a>.</em></p>
<blockquote>
<h2>0.4.11</h2>
<p>Released on 2026-07-25.</p>
<h3>Highlights</h3>
<ul>
<li>
<p>This release adds two new builtin hooks, <code>deny-pattern</code>
and <code>require-pattern</code>,
as native alternatives for <code>pygrep</code> use cases.
<code>deny-pattern</code> fails when a
configured pattern is found, while <code>require-pattern</code> ensures
every selected
file contains a match. By matching natively without spawning a Python
subprocess, they run over 4x faster than <code>pygrep</code> in
benchmarks. Note that
they use
<a href="https://docs.rs/regex/latest/regex/#syntax">Rust
<code>regex</code> syntax</a>, which does
not support look-around features such as negative lookbehind.</p>
</li>
<li>
<p><code>prek run</code> now supports <code>--glob
&lt;PATTERN&gt;</code> to run hooks on tracked files
matching a glob. It can be repeated or combined with
<code>--files</code> and
<code>--directory</code>.</p>
</li>
<li>
<p>Hook priorities now support reusable aliases:</p>
<pre lang="toml"><code>[priorities]
checks = 10
<p>[[repos]]
repo = &quot;builtin&quot;
hooks = [
{ id = &quot;check-json&quot;, priority = &quot;checks&quot; },
{ id = &quot;check-yaml&quot;, priority = &quot;checks&quot; },
]
</code></pre></p>
<p>This makes parallel scheduling easier to read and maintain.</p>
</li>
</ul>
<h3>Enhancements</h3>
<ul>
<li>Add <code>deny-pattern</code> and <code>require-pattern</code>
builtin hooks (<a
href="https://redirect.github.com/j178/prek/pull/2359">#2359</a>)</li>
<li>Support <code>--glob</code> patterns in <code>prek run</code> (<a
href="https://redirect.github.com/j178/prek/pull/2381">#2381</a>)</li>
<li>Support reusable aliases for hook priorities (<a
href="https://redirect.github.com/j178/prek/pull/2331">#2331</a>)</li>
<li>Implement <code>requirements-txt-fixer</code> as a builtin hook (<a
href="https://redirect.github.com/j178/prek/pull/2390">#2390</a>)</li>
<li>Improve user-facing warnings and errors (<a
href="https://redirect.github.com/j178/prek/pull/2380">#2380</a>)</li>
<li>Install Node hooks through git url (<a
href="https://redirect.github.com/j178/prek/pull/2394">#2394</a>)</li>
</ul>
<h3>Performance</h3>
<ul>
<li>Reduce blocking-pool overhead in file hooks (<a
href="https://redirect.github.com/j178/prek/pull/2384">#2384</a>)</li>
<li>Speed up mixed-line-ending scans with memchr2 (<a
href="https://redirect.github.com/j178/prek/pull/2391">#2391</a>)</li>
</ul>
<h3>Bug fixes</h3>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/j178/prek/commit/92ba6c8c21e7acf5ce056b7c9b8dcba7325c3f5a"><code>92ba6c8</code></a>
Bump version to 0.4.11 (<a
href="https://redirect.github.com/j178/prek/issues/2402">#2402</a>)</li>
<li><a
href="https://github.com/j178/prek/commit/f575e428961414bc079060061bc570b71e553942"><code>f575e42</code></a>
Bump quinn-proto from 0.11.14 to 0.11.16 (<a
href="https://redirect.github.com/j178/prek/issues/2401">#2401</a>)</li>
<li><a
href="https://github.com/j178/prek/commit/c0ed56d92af1530e0acc98da2a8544bcf1dd01e7"><code>c0ed56d</code></a>
Preserve system download policy when applying metadata (<a
href="https://redirect.github.com/j178/prek/issues/2395">#2395</a>)</li>
<li><a
href="https://github.com/j178/prek/commit/3b8b5c53ea8ce674112e22f9f84addf3637ca9e0"><code>3b8b5c5</code></a>
Install Node hooks through git url (<a
href="https://redirect.github.com/j178/prek/issues/2394">#2394</a>)</li>
<li><a
href="https://github.com/j178/prek/commit/ff11cbb2d30478d9b2607dd1886cf90e33979365"><code>ff11cbb</code></a>
Implement <code>requirements-txt-fixer</code> as a builtin hook (<a
href="https://redirect.github.com/j178/prek/issues/2390">#2390</a>)</li>
<li><a
href="https://github.com/j178/prek/commit/c7dfc32c27f74ee04f9a4dc47b842fbb3e417631"><code>c7dfc32</code></a>
Match identify tags across filename parts (<a
href="https://redirect.github.com/j178/prek/issues/2399">#2399</a>)</li>
<li><a
href="https://github.com/j178/prek/commit/928dc5cf3b7c7486664d6a7e34ce8d89a603d7f1"><code>928dc5c</code></a>
Speed up mixed-line-ending scans with memchr2 (<a
href="https://redirect.github.com/j178/prek/issues/2391">#2391</a>)</li>
<li><a
href="https://github.com/j178/prek/commit/6c898499d8b7741f7e6b60cc42b195660e6d1afa"><code>6c89849</code></a>
Honor filenames in builtin hook entry and args (<a
href="https://redirect.github.com/j178/prek/issues/2389">#2389</a>)</li>
<li><a
href="https://github.com/j178/prek/commit/28bc3f013e0c97c39834f2f02a708355ed642462"><code>28bc3f0</code></a>
Add description for no-commit-to-branch (<a
href="https://redirect.github.com/j178/prek/issues/2388">#2388</a>)</li>
<li><a
href="https://github.com/j178/prek/commit/1d9adddb83df05770ea2a5562f141171be174d77"><code>1d9addd</code></a>
Reduce blocking-pool overhead in file hooks (<a
href="https://redirect.github.com/j178/prek/issues/2384">#2384</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/j178/prek/compare/v0.4.10...v0.4.11">compare
view</a></li>
</ul>
</details>
<br />

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-28 01:23:47 -04:00
CaliBrain a1367f431d Ship Prowlarr per-entry rows as opt-in, not the default (#1145)
#1140 fixed the guid-only dedup that hid results from filter-specific
indexer entries, but shipped the new behaviour on by default:
PROWLARR_COLLAPSE_DUPLICATES defaulted off, so every existing Prowlarr
user got extra rows for any release that two indexer entries both
returned, and the setting only let them opt back into what they already
had.

Default it on. The dedup key stays indexer-qualified, so the entries are
still distinct internally; collapse then merges them back to one row,
resolved by the Prowlarr priority rather than by query order as before.
The visible result set matches what users had prior to #1140, and anyone
who wants the per-entry rows (freeleech and the like) turns the setting
off.

Beyond the noisier list, the default mattered because split rows differ
only by indexer name while sharing a title, size and peer count. Two of
them have distinct source_ids, so the queue's duplicate guard does not
fire, and the second grab's find_existing() matches the first by
infohash and runs post-processing over the same download again,
delivering the book twice.

The search-side fallback in config.get(..., True) is flipped to agree
with the field default. In production the field default governs, since
the config cache is seeded from the registry and the fallback only
applies to an unregistered key. The source tests monkeypatch config.get
with a plain dict lookup, though, so the fallback is what they exercise:
leaving it False would have kept every default-behaviour test asserting
the opposite of what ships. A new test pins the two together.

The deduplication tests now opt out explicitly, since they assert the
split itself. test_collapse_off_by_default_keeps_both_rows becomes a
pair, one for the untouched setting collapsing to a single row and one
for opting out.

docs/environment-variables.md is regenerated rather than hand-edited. It
was already stale on main, so it also picks up RTORRENT_AUDIOBOOK_LABEL,
DIRECT_DOWNLOAD_LANGUAGE_FROM_PATH, and reworded IRC_SEARCH_BOT and
RTORRENT_LABEL text from earlier merges.

The rest is fallout from the ruff 0.16.0 bump in #1139, which enabled a
much larger default rule set and started formatting Python code blocks
in Markdown: _find_existing_alias_user() uses min() instead of
sorted()[0] (FURB192, currently failing Python Quality on main), and the
two READMEs get their code blocks reformatted.
2026-07-28 01:23:31 -04:00
Andrew Doeringanddelize a4086f5e06 Keep Prowlarr results from distinct indexer entries separate (#1140)
Results were deduplicated on `guid` alone. When one tracker is
configured in Prowlarr as several indexer entries differing only by a
server-side search filter, all of them return the same guid for the same
torrent, so every entry but the first was silently discarded. Freeleech
and other filter-specific releases became invisible, replaced by the
unfiltered entry's copy, and which copy survived depended on query
ordering rather than user intent.

Include the indexer id in the dedup key so the entries stay distinct.

Release.source_id is qualified the same way. It keys the release cache
and becomes the download task id, so rows sharing a guid would otherwise
collide and a grab would route through whichever entry cached last,
defeating the point of showing them separately. The handler's task
matcher still accepts a bare guid or infoUrl so tasks queued before this
change still resolve.

Ordering now follows the priority already configured in Prowlarr (1-50,
lower preferred) rather than a new setting, since users curate that
ranking there and filtered entries are typically ranked ahead of their
unfiltered counterparts. The enabled-indexer list is fetched once and
reused for the enrichment check, so this costs no extra round trip.
Releases carry extra.indexer_priority, and the sort dropdown gains an
"Indexer priority" entry, ascending, alongside the existing alphabetical
"Indexer" sort; SortOption grew a default_direction for that, defaulting
to desc so "Peers" is unchanged.

PROWLARR_COLLAPSE_DUPLICATES (default off) optionally collapses a
release back to one row, resolved by the same Prowlarr priority. Left
off, every entry that carried a release keeps its own row, which is what
makes filtered results visible again.

Identity handling is defensive about partial payloads: a result that
cannot be identified is never dropped or merged, and collapse only
merges on a strong identifier (guid/downloadUrl/magnetUrl/infoUrl)
because merging on title alone would discard genuinely different
releases that share a name.

Fixes #1137

Co-authored-by: delize <4028612+delize@users.noreply.github.com>
2026-07-28 01:06:39 -04:00
Adam Vigneaux 0d02c6db47 Add qBittorrent API key authentication (#1143)
[qBittorrent

5.2.0](https://www.qbittorrent.org/news#sun-may-03rd-2026---qbittorrent-v5.2.0-release)
(May 2026) added support for API key-based authentication in addition to
the existing username/password-based authentication.

This commit adds support for qBittorrent API key authentication to
Shelfmark, configurable via environment variable or settings UI. If an
API key is set at the same time as the username/password, API key will
be preferred for authentication.

Requires `qbittorrent-api` 2026.5.3, the version that added the
`api_key` argument, or newer. `403 Forbidden` responses are not retried
with API key authentication because a retry has no chance of succeeding.

Tested end-to-end with my live qBittorrent 5.2.3 instance (WebAPI
v2.15.1).

<img width="785" height="616" alt="image"
src="https://github.com/user-attachments/assets/8064e10e-9a7f-49f0-804e-4c441d23a1fc"
/>
2026-07-28 00:54:19 -04:00
dependabot[bot] 7c457ff83e Bump the python-deps group with 4 updates (#1139)
Bumps the python-deps group with 4 updates:
[gevent](https://github.com/gevent/gevent),
[rarfile](https://github.com/markokr/rarfile),
[seleniumbase](https://github.com/seleniumbase/SeleniumBase) and
[ruff](https://github.com/astral-sh/ruff).

Updates `gevent` from 26.5.0 to 26.7.0
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/gevent/gevent/commit/1ae8449b77daeec72ef4247474f1888209a89c44"><code>1ae8449</code></a>
Preparing release 26.7.0</li>
<li><a
href="https://github.com/gevent/gevent/commit/af4fad4d0c9f005c1f875483ee72fcdac3b4fd95"><code>af4fad4</code></a>
Add note about change in binary wheel building.</li>
<li><a
href="https://github.com/gevent/gevent/commit/894a6e1a30ac1f81207d03a7d2e893126b8cd238"><code>894a6e1</code></a>
gevent/resolver/_addresses.py: Apply suggestion from <a
href="https://github.com/koteshyelamati"><code>@​koteshyelamati</code></a>
and move...</li>
<li><a
href="https://github.com/gevent/gevent/commit/4bbfae89de752f1032e76be0977bc9b83df0fa83"><code>4bbfae8</code></a>
Merge pull request <a
href="https://redirect.github.com/gevent/gevent/issues/2190">#2190</a>
from gevent/os-close-no-init-hub</li>
<li><a
href="https://github.com/gevent/gevent/commit/fbc0ca7ab234480165d7db008f140ed6efaa0f69"><code>fbc0ca7</code></a>
Blanket skip two SSL tests that are highly environment sensitive.</li>
<li><a
href="https://github.com/gevent/gevent/commit/bef4cdd10002b1d9cc3c855ac5bfbeab27999e3f"><code>bef4cdd</code></a>
Disable 3.15 on windows entirely for now; everything is crashing and I'm
pret...</li>
<li><a
href="https://github.com/gevent/gevent/commit/95c5b1e102f32530933b6b882687ce9242293925"><code>95c5b1e</code></a>
Attempt some CFFI-related workarounds.</li>
<li><a
href="https://github.com/gevent/gevent/commit/2b754a55f9555d7caca680935ec600a99c517059"><code>2b754a5</code></a>
cython won't build on windows, must stick to precompiled version.
Debugging a...</li>
<li><a
href="https://github.com/gevent/gevent/commit/ebcebb0fb426ed39b8a9724e376444c222a3d5b5"><code>ebcebb0</code></a>
Track down why 3.15 was initting the hub.</li>
<li><a
href="https://github.com/gevent/gevent/commit/dcf4eae4b36046d456c685efd95bc50cc085084e"><code>dcf4eae</code></a>
Merge pull request <a
href="https://redirect.github.com/gevent/gevent/issues/2189">#2189</a>
from ddorian/fix-shutdown-atexit-order</li>
<li>Additional commits viewable in <a
href="https://github.com/gevent/gevent/compare/26.5.0...26.7.0">compare
view</a></li>
</ul>
</details>
<br />

Updates `rarfile` from 4.3 to 4.4
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/markokr/rarfile/releases">rarfile's
releases</a>.</em></p>
<blockquote>
<h2>rarfile v4.4</h2>
<p>Security fixes:</p>
<ul>
<li>Skip CRC check for some old subblocks. Previously <!-- raw HTML
omitted -->rarfile<!-- raw HTML omitted --> tried to calculate header
CRC by reading data payload for those, but that could cause excessive
allocations.</li>
</ul>
<p>Fixes:</p>
<ul>
<li>RAR5: Reattempt passwords after trying wrong password when header
encryption is used. [<a
href="https://redirect.github.com/markokr/rarfile/pull/131">#131</a>]</li>
<li>Restore fd position in file type detection functions. [<a
href="https://redirect.github.com/markokr/rarfile/pull/121">#121</a>]</li>
</ul>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/markokr/rarfile/blob/master/doc/news.rst">rarfile's
changelog</a>.</em></p>
<blockquote>
<h2>Version 4.4 (2026-07-22)</h2>
<p>Security fixes:</p>
<ul>
<li>Skip CRC check for some old subblocks.  Previously
<code>rarfile</code> tried to calculate header CRC by reading
data payload for those, but that could cause excessive
allocations.</li>
</ul>
<p>Fixes:</p>
<ul>
<li>
<p>RAR5: Reattempt passwords after trying wrong password
when header encryption is used.
[<code>[#131](https://github.com/markokr/rarfile/issues/131)
&lt;https://github.com/markokr/rarfile/pull/131&gt;</code>_]</p>
</li>
<li>
<p>Restore fd position in file type detection functions.
[<code>[#121](https://github.com/markokr/rarfile/issues/121)
&lt;https://github.com/markokr/rarfile/pull/121&gt;</code>_]</p>
</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/markokr/rarfile/commit/d106d2031e702bb187c746d9035362f0bd23a33c"><code>d106d20</code></a>
Refresh news.rst</li>
<li><a
href="https://github.com/markokr/rarfile/commit/8ab9f71684bddc60ba4631e4468727924b9816c1"><code>8ab9f71</code></a>
ci: restore CI on push</li>
<li><a
href="https://github.com/markokr/rarfile/commit/1b3db8971650236e7ddc8c80e705cf58511d2930"><code>1b3db89</code></a>
v4.4</li>
<li><a
href="https://github.com/markokr/rarfile/commit/c94c9691e3ba59816f0c0dc12fd4078d4639b7ad"><code>c94c969</code></a>
Skip CRC check for some old subblocks.</li>
<li><a
href="https://github.com/markokr/rarfile/commit/e2c0d6ef2b4b35ea20e10a76613b2aa79cdf784e"><code>e2c0d6e</code></a>
Reattempt passwords</li>
<li><a
href="https://github.com/markokr/rarfile/commit/cc6368edc1ccbdb80b619d9e962b8199b00dfed9"><code>cc6368e</code></a>
build(deps): bump tox from 4.53.1 to 4.56.1</li>
<li><a
href="https://github.com/markokr/rarfile/commit/0b3d4536a1012e1538157bc00638ebf68df3786a"><code>0b3d453</code></a>
dependabot: drop unnecessary etc</li>
<li><a
href="https://github.com/markokr/rarfile/commit/edb967cd0f531f8f49ad5106b36a028ead30fde6"><code>edb967c</code></a>
build(deps): bump actions/checkout from 4 to 7</li>
<li><a
href="https://github.com/markokr/rarfile/commit/79bd2e447823c18b8a99eeff9acea05cfbee9cd1"><code>79bd2e4</code></a>
build(deps): bump twine from 5.1.1 to 6.2.0</li>
<li><a
href="https://github.com/markokr/rarfile/commit/1c0c6a6b27c36c2a0306e33db71603950b43ee22"><code>1c0c6a6</code></a>
build(deps): bump actions/upload-artifact from 4 to 7</li>
<li>Additional commits viewable in <a
href="https://github.com/markokr/rarfile/compare/v4.3...v4.4">compare
view</a></li>
</ul>
</details>
<br />

Updates `seleniumbase` from 4.51.3 to 4.51.6
<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.51.6 - CDP Mode: Patch 124</h2>
<h2>CDP Mode: Patch 124</h2>
<ul>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/7d79aa82eb7575706799716403088b7db2604100">Make
CDP Mode stealthier</a>
--&gt; Improve fingerprint on actions and add randomization</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/f98dd28d35df3e8e25f98fa2f1fc2c7a420d7287">Sync
up BaseCase and sb_cdp methods</a>
--&gt; (For <code>clear()</code> &amp; <code>clear_input()</code>, which
are the same)</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/ea68a2eca2b3142f941d9da8537c2cc0e917afaf">Refresh
Python dependencies</a>
--&gt; (Includes <code>certifi</code> and a few optional
dependencies)</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/e5bbe21beb6817e9bb43ce23ba899e62dc6b0723">Update
CDP Mode examples</a></li>
</ul>
<h2>What's Changed</h2>
<ul>
<li>CDP Mode: Patch 124 by <a
href="https://github.com/mdmintz"><code>@​mdmintz</code></a> in <a
href="https://redirect.github.com/seleniumbase/SeleniumBase/pull/4439">seleniumbase/SeleniumBase#4439</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/seleniumbase/SeleniumBase/compare/v4.51.5...v4.51.6">https://github.com/seleniumbase/SeleniumBase/compare/v4.51.5...v4.51.6</a></p>
<h2>4.51.5 - CDP Mode: Patch 123</h2>
<h2>CDP Mode: Patch 123</h2>
<ul>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/a6e951c73c3e2b2ce1082671813692cba2d61a44">Make
CDP Mode super stealthy</a></li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/e08ed3a3baeba129ad2ecc155b5c9c26df8a1ea1">Refresh
Python dependencies</a></li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/1feb0db4187160e290595dfc6f05653c0f0fcc1e">Update
CDP Mode examples</a></li>
</ul>
<h2>What's Changed</h2>
<ul>
<li>CDP Mode: Patch 123 by <a
href="https://github.com/mdmintz"><code>@​mdmintz</code></a> in <a
href="https://redirect.github.com/seleniumbase/SeleniumBase/pull/4438">seleniumbase/SeleniumBase#4438</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/seleniumbase/SeleniumBase/compare/v4.51.4...v4.51.5">https://github.com/seleniumbase/SeleniumBase/compare/v4.51.4...v4.51.5</a></p>
<h2>4.51.4 - CDP Mode: Patch 122</h2>
<h2>CDP Mode: Patch 122</h2>
<ul>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/ae9df3ea9f6340474e8aacf2e7b952342ec53088">Update
CDP Mode</a>
--&gt; For a stealthier fingerprint</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/a8b84ff9f8051a52ae72e81dfb6aab3d62458459">Refresh
Python dependencies</a></li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/6b6599ac6689c7efe4e5e784814c7d4151de0652">Update
CDP Mode examples</a></li>
</ul>
<h2>What's Changed</h2>
<ul>
<li>CDP Mode: Patch 122 by <a
href="https://github.com/mdmintz"><code>@​mdmintz</code></a> in <a
href="https://redirect.github.com/seleniumbase/SeleniumBase/pull/4435">seleniumbase/SeleniumBase#4435</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/seleniumbase/SeleniumBase/compare/v4.51.3...v4.51.4">https://github.com/seleniumbase/SeleniumBase/compare/v4.51.3...v4.51.4</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/74c0594ed79332667a1792cf01e28cc34135f57c"><code>74c0594</code></a>
Merge pull request <a
href="https://redirect.github.com/seleniumbase/SeleniumBase/issues/4439">#4439</a>
from seleniumbase/cdp-mode-patch-124</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/cee06e0934db22fb144019e18b8f3e933c431eb8"><code>cee06e0</code></a>
Version 4.51.6</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/e5bbe21beb6817e9bb43ce23ba899e62dc6b0723"><code>e5bbe21</code></a>
Update CDP Mode examples</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/ea68a2eca2b3142f941d9da8537c2cc0e917afaf"><code>ea68a2e</code></a>
Refresh Python dependencies</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/f98dd28d35df3e8e25f98fa2f1fc2c7a420d7287"><code>f98dd28</code></a>
Sync up BaseCase and sb_cdp methods</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/7d79aa82eb7575706799716403088b7db2604100"><code>7d79aa8</code></a>
Make CDP Mode stealthier</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/0dda01d2b0690e77e2a5884c1ab7000cf33a8efb"><code>0dda01d</code></a>
Merge pull request <a
href="https://redirect.github.com/seleniumbase/SeleniumBase/issues/4438">#4438</a>
from seleniumbase/cdp-mode-patch-123</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/2897f0a992e0cffa6d0af45ac8d2b85416cac26b"><code>2897f0a</code></a>
Version 4.51.5</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/e08ed3a3baeba129ad2ecc155b5c9c26df8a1ea1"><code>e08ed3a</code></a>
Refresh Python dependencies</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/1feb0db4187160e290595dfc6f05653c0f0fcc1e"><code>1feb0db</code></a>
Update CDP Mode examples</li>
<li>Additional commits viewable in <a
href="https://github.com/seleniumbase/SeleniumBase/compare/v4.51.3...v4.51.6">compare
view</a></li>
</ul>
</details>
<br />

Updates `ruff` from 0.15.22 to 0.16.0
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/astral-sh/ruff/releases">ruff's
releases</a>.</em></p>
<blockquote>
<h2>0.16.0</h2>
<h2>Release Notes</h2>
<p>Released on 2026-07-23.</p>
<p>Check out the <a href="https://astral.sh/blog/ruff-v0.16.0">blog
post</a> for a migration guide and overview of the changes!</p>
<h3>Breaking changes</h3>
<ul>
<li>
<p>Ruff now enables a much larger set of rules by default (413, up from
59). See the blog post for more details and the new <a
href="https://docs.astral.sh/ruff/default-rules/">Default Rules</a> page
for a full listing of the enabled rules.</p>
</li>
<li>
<p>Ruff can now format Python code blocks in Markdown files and will do
this by default. See the <a
href="https://docs.astral.sh/ruff/formatter/#markdown-code-formatting">documentation</a>
for more details.</p>
</li>
<li>
<p>Ruff now supports <code>ruff: ignore</code> comments at the ends of
lines, like <code>noqa</code> comments, or on the line preceding a
diagnostic. For example, these both suppress an <a
href="https://docs.astral.sh/ruff/rules/unused-import/"><code>unused-import</code></a>
(<code>F401</code>) diagnostic:</p>
<pre lang="py"><code>import math  # ruff: ignore[F401]
<h1>ruff: ignore[F401]</h1>
<p>import os
</code></pre></p>
</li>
<li>
<p>Fixes are now shown in <code>check</code> and <code>format
--check</code> output:</p>
<pre lang="console"><code>❯ ruff format --check .
unformatted: File would be reformatted
 --&gt; try.md:1:1
  |
1 | ```python
  - import   math
2 + import math
3 | ```
  |
<p>1 file would be reformatted
</code></pre></p>
<p>This example also shows off the Markdown formatting.</p>
</li>
<li>
<p><code>format --check</code> now supports the same output formats as
the linter, including the <code>github</code> and <code>gitlab</code>
outputs for rendering annotations in CI:</p>
<pre lang="console"><code>❯ ruff format --check --output-format github .
::error title=ruff
(unformatted),file=try.md,line=2,col=8,endLine=2,endColumn=10::try.md:2:8:
unformatted: File would be reformatted
</code></pre>
<p>See the CLI help or <a
href="https://docs.astral.sh/ruff/settings/#output-format">documentation</a>
for the full list of supported formats.</p>
</li>
<li>
<p>The <code>filename</code>, <code>location</code>,
<code>end_location</code>, <code>fix.edits[].location</code>, and
<code>fix.edits[].end_location</code> fields in the JSON output format
may now be <code>null</code> rather than defaulting to the empty string
and row 1, column 1, respectively.</p>
</li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md">ruff's
changelog</a>.</em></p>
<blockquote>
<h2>0.16.0</h2>
<p>Released on 2026-07-23.</p>
<p>Check out the <a href="https://astral.sh/blog/ruff-v0.16.0">blog
post</a> for a migration
guide and overview of the changes!</p>
<h3>Breaking changes</h3>
<ul>
<li>
<p>Ruff now enables a much larger set of rules by default (413, up from
59). See the blog post for
more details and the new <a
href="https://docs.astral.sh/ruff/default-rules/">Default Rules</a> page
for a
full listing of the enabled rules.</p>
</li>
<li>
<p>Ruff can now format Python code blocks in Markdown files and will do
this by default. See the
<a
href="https://docs.astral.sh/ruff/formatter/#markdown-code-formatting">documentation</a>
for more details.</p>
</li>
<li>
<p>Ruff now supports <code>ruff: ignore</code> comments at the ends of
lines, like <code>noqa</code> comments, or on the line preceding a
diagnostic. For example, these both suppress an <a
href="https://docs.astral.sh/ruff/rules/unused-import/"><code>unused-import</code></a>
(<code>F401</code>) diagnostic:</p>
<pre lang="py"><code>import math  # ruff: ignore[F401]
<h1>ruff: ignore[F401]</h1>
<p>import os
</code></pre></p>
</li>
<li>
<p>Fixes are now shown in <code>check</code> and <code>format
--check</code> output:</p>
<pre lang="console"><code>❯ ruff format --check .
unformatted: File would be reformatted
 --&gt; try.md:1:1
  |
1 | ```python
  - import   math
2 + import math
3 | ```
  |
<p>1 file would be reformatted
</code></pre></p>
<p>This example also shows off the Markdown formatting.</p>
</li>
<li>
<p><code>format --check</code> now supports the same output formats as
the linter, including the <code>github</code> and
<code>gitlab</code> outputs for rendering annotations in CI:</p>
<pre lang="console"><code>❯ ruff format --check --output-format github .
::error title=ruff
(unformatted),file=try.md,line=2,col=8,endLine=2,endColumn=10::try.md:2:8:
unformatted: File would be reformatted
</code></pre>
</li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/astral-sh/ruff/commit/a2635fd8f39e1d34ce8074cb486809426148f3e9"><code>a2635fd</code></a>
Bump 0.16.0 (<a
href="https://redirect.github.com/astral-sh/ruff/issues/27136">#27136</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/34334491652f8ceca5246d15c5c5afe0d6bc77ae"><code>3433449</code></a>
[ty] Reuse full call diagnostics for implicit setter calls (<a
href="https://redirect.github.com/astral-sh/ruff/issues/27115">#27115</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/22400709220931375e072ad5d7460b9fc781af78"><code>2240070</code></a>
Reflect <code>ruff: ignore</code> and <code>--add-ignore</code>
stabilization in documentation (<a
href="https://redirect.github.com/astral-sh/ruff/issues/27">#27</a>...</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/17ef71142c52230b923dad46ee5554140fc3fd2e"><code>17ef711</code></a>
Stabilize <code>--add-ignore</code> (<a
href="https://redirect.github.com/astral-sh/ruff/issues/27125">#27125</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/ef912bbbe466856aa4aac10ad2a8856eb3d5aef3"><code>ef912bb</code></a>
Add newly stabilized rules to defaults (<a
href="https://redirect.github.com/astral-sh/ruff/issues/27055">#27055</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/b30f04023281b46f12011f13ce6b45c247e0d2e3"><code>b30f040</code></a>
Stabilize new default rules (<a
href="https://redirect.github.com/astral-sh/ruff/issues/27035">#27035</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/bcd70c5f10ea97ed52a785d70e7f33b83b7c697a"><code>bcd70c5</code></a>
Exclude Markdown files from <code>format-dev</code> runs (<a
href="https://redirect.github.com/astral-sh/ruff/issues/27052">#27052</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/87e51e2cbbaed376fc13dead40fd772361fa07c0"><code>87e51e2</code></a>
Fix <code>format --check</code> spans for syntax errors (<a
href="https://redirect.github.com/astral-sh/ruff/issues/27045">#27045</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/afe2723a348364ac7f4b9abd76fc67779490c05e"><code>afe2723</code></a>
[<code>flake8-gettext</code>] Stabilize qualified-name and built-in
binding resolution (...</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/a9702d8928344f77a41dbe535f655a69fb04e2df"><code>a9702d8</code></a>
[<code>flake8-bandit</code>] Stabilize string literal binding resolution
(<code>S310</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/issues/26944">#26944</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/astral-sh/ruff/compare/0.15.22...0.16.0">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-07-27 12:26:06 -04:00
dependabot[bot] 00f3fa565c Bump typescript from 6.0.3 to 7.0.2 in /src/frontend (#1133)
Bumps [typescript](https://github.com/microsoft/TypeScript) from 6.0.3
to 7.0.2.
<details>
<summary>Commits</summary>
<ul>
<li>See full diff in <a
href="https://github.com/microsoft/TypeScript/commits">compare
view</a></li>
</ul>
</details>
<details>
<summary>Maintainer changes</summary>
<p>This version was pushed to npm by <a
href="https://www.npmjs.com/~microsoft1es">microsoft1es</a>, a new
releaser for typescript since your current version.</p>
</details>
<br />

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-24 18:38:31 -04:00
CaliBrain 7fdaf3f67d Add Audiobook support to IRC (#1136) 2026-07-24 18:34:12 -04:00
dependabot[bot] e9f3b9a3ad Bump the npm-deps group in /src/frontend with 7 updates (#1132)
Bumps the npm-deps group in /src/frontend with 7 updates:

| Package | From | To |
| --- | --- | --- |
|
[@tailwindcss/vite](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/@tailwindcss-vite)
| `4.3.2` | `4.3.3` |
| [knip](https://github.com/webpro-nl/knip/tree/HEAD/packages/knip) |
`6.26.0` | `6.27.0` |
| [oxfmt](https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt) |
`0.58.0` | `0.59.0` |
| [oxlint](https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint) |
`1.73.0` | `1.74.0` |
| [oxlint-tsgolint](https://github.com/oxc-project/tsgolint) | `0.24.0`
| `0.25.0` |
|
[tailwindcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/tailwindcss)
| `4.3.2` | `4.3.3` |
| [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) |
`8.1.4` | `8.1.5` |

Updates `@tailwindcss/vite` from 4.3.2 to 4.3.3
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/tailwindlabs/tailwindcss/releases">@​tailwindcss/vite's
releases</a>.</em></p>
<blockquote>
<h2>v4.3.3</h2>
<h3>Fixed</h3>
<ul>
<li>Support <code>--watch --poll[=ms]</code> in
<code>@tailwindcss/cli</code> when filesystem events are unreliable or
unavailable (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/20297">#20297</a>)</li>
<li>Canonicalization: match arbitrary hex colors against theme colors
case-insensitively (e.g. <code>bg-[#fff]</code> and
<code>bg-[#FFF]</code> → <code>bg-white</code>) (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/20298">#20298</a>)</li>
<li>Prevent Preflight from overriding Firefox's native
<code>iframe:focus-visible</code> outline styles (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/20292">#20292</a>)</li>
<li>Ensure <code>theme('colors.foo')</code> in JS plugins resolves
correctly when both <code>--color-foo</code> and
<code>--color-foo-bar</code> exist (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/20299">#20299</a>)</li>
<li>Ensure fractional opacity modifiers work with named shadow sizes
like <code>shadow-sm/12.5</code>, <code>text-shadow-sm/12.5</code>,
<code>drop-shadow-sm/12.5</code>, and <code>inset-shadow-sm/12.5</code>
(<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/20302">#20302</a>)</li>
<li>Parse selectors like <code>[data-foo]div</code> as two selectors
instead of one (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/20303">#20303</a>)</li>
<li>Ensure <code>@tailwindcss/postcss</code> rebuilds when a
preprocessor like Sass changes the input CSS without changing the input
file on disk (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/20310">#20310</a>)</li>
<li>Ensure CSS nesting is handled even when Lightning CSS isn't run,
such as in <code>@tailwindcss/browser</code> and Tailwind Play (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/20124">#20124</a>)</li>
<li>Prevent achromatic theme colors from shifting hue when mixed in
polar color spaces like <code>oklch</code> (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/20314">#20314</a>)</li>
<li>Ensure <code>--spacing(0)</code> is optimized to <code>0px</code>
instead of <code>0</code> so it remains a <code>&lt;length&gt;</code>
when used in <code>calc(…)</code> (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/20319">#20319</a>)</li>
<li>Load <code>@parcel/watcher</code> only when needed in
<code>@tailwindcss/cli --watch</code> mode, so one-off builds and
<code>--watch --poll</code> work when <code>@parcel/watcher</code> can't
be loaded (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/20325">#20325</a>)</li>
<li>Use explicit platform fonts instead of <code>system-ui</code> and
<code>ui-sans-serif</code> so CJK text respects the page's
<code>lang</code> attribute on Windows (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/20318">#20318</a>)</li>
<li>Prevent <code>@tailwindcss/upgrade</code> from rewriting ignored
files when run from a subdirectory (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/20329">#20329</a>)</li>
<li>Ensure earlier <code>@source</code> rules pointing to nested files
are scanned when later <code>@source</code> rules point to files in
parent folders (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/20335">#20335</a>)</li>
<li>Prevent <code>@tailwindcss/vite</code> from triggering full page
reloads when scanned files are processed by Vite but haven't been loaded
as modules yet (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/20336">#20336</a>)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md">@​tailwindcss/vite's
changelog</a>.</em></p>
<blockquote>
<h2>[4.3.3] - 2026-07-16</h2>
<h3>Fixed</h3>
<ul>
<li>Support <code>--watch --poll[=ms]</code> in
<code>@tailwindcss/cli</code> when filesystem events are unreliable or
unavailable (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/20297">#20297</a>)</li>
<li>Canonicalization: match arbitrary hex colors against theme colors
case-insensitively (e.g. <code>bg-[#fff]</code> and
<code>bg-[#FFF]</code> → <code>bg-white</code>) (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/20298">#20298</a>)</li>
<li>Prevent Preflight from overriding Firefox's native
<code>iframe:focus-visible</code> outline styles (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/20292">#20292</a>)</li>
<li>Ensure <code>theme('colors.foo')</code> in JS plugins resolves
correctly when both <code>--color-foo</code> and
<code>--color-foo-bar</code> exist (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/20299">#20299</a>)</li>
<li>Ensure fractional opacity modifiers work with named shadow sizes
like <code>shadow-sm/12.5</code>, <code>text-shadow-sm/12.5</code>,
<code>drop-shadow-sm/12.5</code>, and <code>inset-shadow-sm/12.5</code>
(<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/20302">#20302</a>)</li>
<li>Parse selectors like <code>[data-foo]div</code> as two selectors
instead of one (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/20303">#20303</a>)</li>
<li>Ensure <code>@tailwindcss/postcss</code> rebuilds when a
preprocessor like Sass changes the input CSS without changing the input
file on disk (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/20310">#20310</a>)</li>
<li>Ensure CSS nesting is handled even when Lightning CSS isn't run,
such as in <code>@tailwindcss/browser</code> and Tailwind Play (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/20124">#20124</a>)</li>
<li>Prevent achromatic theme colors from shifting hue when mixed in
polar color spaces like <code>oklch</code> (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/20314">#20314</a>)</li>
<li>Ensure <code>--spacing(0)</code> is optimized to <code>0px</code>
instead of <code>0</code> so it remains a <code>&lt;length&gt;</code>
when used in <code>calc(…)</code> (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/20319">#20319</a>)</li>
<li>Load <code>@parcel/watcher</code> only when needed in
<code>@tailwindcss/cli --watch</code> mode, so one-off builds and
<code>--watch --poll</code> work when <code>@parcel/watcher</code> can't
be loaded (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/20325">#20325</a>)</li>
<li>Use explicit platform fonts instead of <code>system-ui</code> and
<code>ui-sans-serif</code> so CJK text respects the page's
<code>lang</code> attribute on Windows (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/20318">#20318</a>)</li>
<li>Prevent <code>@tailwindcss/upgrade</code> from rewriting ignored
files when run from a subdirectory (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/20329">#20329</a>)</li>
<li>Ensure earlier <code>@source</code> rules pointing to nested files
are scanned when later <code>@source</code> rules point to files in
parent folders (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/20335">#20335</a>)</li>
<li>Prevent <code>@tailwindcss/vite</code> from triggering full page
reloads when scanned files are processed by Vite but haven't been loaded
as modules yet (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/20336">#20336</a>)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/tailwindlabs/tailwindcss/commit/c2b24dd15fed1c59dd521bd86082f520c9f5ad0d"><code>c2b24dd</code></a>
4.3.3 (<a
href="https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/@tailwindcss-vite/issues/20334">#20334</a>)</li>
<li><a
href="https://github.com/tailwindlabs/tailwindcss/commit/bdcd7087b332d263353d46ed366b7b08040ded7a"><code>bdcd708</code></a>
Don't trigger a full page reload for scanned files that Vite processes
as mod...</li>
<li>See full diff in <a
href="https://github.com/tailwindlabs/tailwindcss/commits/v4.3.3/packages/@tailwindcss-vite">compare
view</a></li>
</ul>
</details>
<br />

Updates `knip` from 6.26.0 to 6.27.0
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/webpro-nl/knip/releases">knip's
releases</a>.</em></p>
<blockquote>
<h2>Release 6.27.0</h2>
<ul>
<li>Add Temporal plugin (98191a04aad0572bead191b101bfecef75067b0c)</li>
<li>Handle -d alias for react-email dir argument
(53d1fec3213db8d6a84dfa7a82050b122782e065)</li>
<li>Expand pnpm script references with forwarded arguments
(33e9799df39450033061b9c4c07daf22f5d15721)</li>
<li>Treat path-shaped script tokens as file references, extend known
global binaries (d9508c282ab81351beda4b2f859435b49f142c51)</li>
<li>Mark tsdown neverBundle dependencies as referenced
(5fd863ba12e68842f91ab1a94e7e30d1e571da92)</li>
<li>Support TanStack Start in tanstack-router plugin
(bd63cd8af88dac03a98aa17f1add322f7bdf4a94)</li>
<li>Mark vite ssr.external dependencies as referenced
(91de938032062cb667bced0eadef9e67d1e20b1f)</li>
<li>Expand bun, npm, and yarn script references with forwarded arguments
(b5a31767a83e2cec3fb20c3b146d94c0412a4fd6)</li>
<li>Add Latitude as sponsor
(42676759cad5fb10ac96fd7c0457037e906f4b68)</li>
<li>Regenerate plugin list in docs
(1d2cdffb4d4151f848c44e066eff43e9efbf44fe)</li>
<li>Support Vue 2 template auto-imports (close <a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1881">#1881</a>)
(2157f3913f77216d268b2255899b6fc58609f9bb)</li>
<li>Ignore block-commented stylesheet imports (resolve <a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1880">#1880</a>,
close <a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1882">#1882</a>)
(a627f53221547d5397d70722fcc2fd723889e579)</li>
<li>Support TypeScript 5.0 narrowing for ssr.external
(989292f79b9d19028bfc7ba988b74ba691041a45)</li>
<li>Resolve local binaries from bun run
(5e9a7f0286e2c2175bb299c514cc7b67a7cf24f1)</li>
<li>Support Vitest inline project config extensions
(f682327f6181f44e3924715efa7404aa39e532f0)</li>
<li>Resolve package names from aliased node_modules paths
(c273d5fbc1b0d3eaf27b38c3f31d3a2a4b6b184a)</li>
<li>Support Tailwind CSS URL imports
(57a4770447cc4c8f9ea4ffb9171c0c92eed4f255)</li>
<li>Resolve unbuilt workspace package exports (close <a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1877">#1877</a>)
(1b0a2c0ee784db165c7f92fe10397a6cb513c1cf)</li>
<li>Skip unresolved imports of gitignored generated files
(605c9049f73838beb1c6fee83c6a5421ae1a6d07)</li>
<li>Attribute imports of aliased packages to the declared dependency
(d05e775c9a3a5676dc9eadcc1093f7d9983efd06)</li>
<li>Flag unresolvable imports of transitive peer deps as unlisted
(resolve <a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1883">#1883</a>)
(18f0bff4ea683837f75ca744d3fbde52df49b297)</li>
<li>Don't count ambient module declarations in script files as imports
(e52aecfd7c022cbe72b1c11dde3f4968579ac409)</li>
<li>Update rolldown snapshot
(a63e92788239062682603a5727ede371db7845c8)</li>
<li>Remove obsolete workarounds from ecosystem tests
(a521cf790d726c18e644e2e316cdc09eb654f607)</li>
<li>Handle snapshotSerializers in Vitest plugin (resolve <a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1884">#1884</a>)
(b597de951f6f0e897afdbbbb8a7d0c06c8d2437d)</li>
<li>Set the stage (c059c2be5803609924b852c2bf0cd90906a6a831)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/webpro-nl/knip/commit/e463b61a1fbbc53c559eb08c92aa751b5c838bc0"><code>e463b61</code></a>
Release knip@6.27.0</li>
<li><a
href="https://github.com/webpro-nl/knip/commit/b597de951f6f0e897afdbbbb8a7d0c06c8d2437d"><code>b597de9</code></a>
Handle snapshotSerializers in Vitest plugin (resolve <a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1884">#1884</a>)</li>
<li><a
href="https://github.com/webpro-nl/knip/commit/e52aecfd7c022cbe72b1c11dde3f4968579ac409"><code>e52aecf</code></a>
Don't count ambient module declarations in script files as imports</li>
<li><a
href="https://github.com/webpro-nl/knip/commit/18f0bff4ea683837f75ca744d3fbde52df49b297"><code>18f0bff</code></a>
Flag unresolvable imports of transitive peer deps as unlisted (resolve
<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1883">#1883</a>)</li>
<li><a
href="https://github.com/webpro-nl/knip/commit/d05e775c9a3a5676dc9eadcc1093f7d9983efd06"><code>d05e775</code></a>
Attribute imports of aliased packages to the declared dependency</li>
<li><a
href="https://github.com/webpro-nl/knip/commit/605c9049f73838beb1c6fee83c6a5421ae1a6d07"><code>605c904</code></a>
Skip unresolved imports of gitignored generated files</li>
<li><a
href="https://github.com/webpro-nl/knip/commit/1b0a2c0ee784db165c7f92fe10397a6cb513c1cf"><code>1b0a2c0</code></a>
Resolve unbuilt workspace package exports (close <a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1877">#1877</a>)</li>
<li><a
href="https://github.com/webpro-nl/knip/commit/57a4770447cc4c8f9ea4ffb9171c0c92eed4f255"><code>57a4770</code></a>
Support Tailwind CSS URL imports</li>
<li><a
href="https://github.com/webpro-nl/knip/commit/c273d5fbc1b0d3eaf27b38c3f31d3a2a4b6b184a"><code>c273d5f</code></a>
Resolve package names from aliased node_modules paths</li>
<li><a
href="https://github.com/webpro-nl/knip/commit/f682327f6181f44e3924715efa7404aa39e532f0"><code>f682327</code></a>
Support Vitest inline project config extensions</li>
<li>Additional commits viewable in <a
href="https://github.com/webpro-nl/knip/commits/knip@6.27.0/packages/knip">compare
view</a></li>
</ul>
</details>
<br />

Updates `oxfmt` from 0.58.0 to 0.59.0
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/oxc-project/oxc/blob/main/npm/oxfmt/CHANGELOG.md">oxfmt's
changelog</a>.</em></p>
<blockquote>
<h2>[0.59.0] - 2026-07-13</h2>
<h3>🐛 Bug Fixes</h3>
<ul>
<li>415fe1e oxfmt: Error on ignorePatterns that cannot match files
outside the config directory (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt/issues/24286">#24286</a>)
(leaysgur)</li>
</ul>
<h2>[0.55.0] - 2026-06-15</h2>
<h3>🚀 Features</h3>
<ul>
<li>9a2788b linter/unicorn: Implement <code>prefer-export-from</code>
rule (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt/issues/22935">#22935</a>)
(AliceLanniste)</li>
</ul>
<h2>[0.54.0] - 2026-06-08</h2>
<h3>📚 Documentation</h3>
<ul>
<li>dadafe3 oxlint, oxfmt: Mention migrate skills in npm READMEs (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt/issues/22965">#22965</a>)
(Boshen)</li>
<li>f88961a oxfmt: Annotate each config option with supported languages
(<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt/issues/22953">#22953</a>)
(leaysgur)</li>
</ul>
<h2>[0.52.0] - 2026-05-26</h2>
<h3>🚀 Features</h3>
<ul>
<li>16b8058 oxfmt: Support <code>vite-plus/resolveConfig</code> for
vite.config.ts (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt/issues/22454">#22454</a>)
(leaysgur)</li>
</ul>
<h2>[0.50.0] - 2026-05-15</h2>
<h3>🐛 Bug Fixes</h3>
<ul>
<li>43b9978 formatter/sort_imports: Treat subpath imports as internal
(<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt/issues/22440">#22440</a>)
(leaysgur)</li>
</ul>
<h2>[0.49.0] - 2026-05-11</h2>
<h3>🚀 Features</h3>
<ul>
<li>6e8e818 oxfmt: Experimental .svelte support (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt/issues/21700">#21700</a>)
(leaysgur)</li>
</ul>
<h2>[0.45.0] - 2026-04-13</h2>
<h3>🐛 Bug Fixes</h3>
<ul>
<li>50c389b oxfmt: Support <code>.editorconfig</code>
<code>quote_type</code> (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt/issues/20989">#20989</a>)
(leaysgur)</li>
</ul>
<h2>[0.44.0] - 2026-04-06</h2>
<h3>🐛 Bug Fixes</h3>
<ul>
<li>dd2df87 npm: Export package.json for oxlint and oxfmt (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt/issues/20784">#20784</a>)
(kazuya kawaguchi)</li>
<li>4216380 oxfmt: Support <code>.editorconfig</code>
<code>tab_width</code> fallback (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt/issues/20988">#20988</a>)
(leaysgur)</li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/oxc-project/oxc/commit/2d4e8d20644e0e7446f0a381894b45ea339a0625"><code>2d4e8d2</code></a>
release(apps): oxlint v1.74.0 &amp;&amp; oxfmt v0.59.0 (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt/issues/24454">#24454</a>)</li>
<li><a
href="https://github.com/oxc-project/oxc/commit/415fe1e7bb423cf05019c5e2c9a5705eebbc5447"><code>415fe1e</code></a>
fix(oxfmt): error on ignorePatterns that cannot match files outside the
confi...</li>
<li>See full diff in <a
href="https://github.com/oxc-project/oxc/commits/oxfmt_v0.59.0/npm/oxfmt">compare
view</a></li>
</ul>
</details>
<br />

Updates `oxlint` from 1.73.0 to 1.74.0
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/oxc-project/oxc/releases">oxlint's
releases</a>.</em></p>
<blockquote>
<h2>oxlint v1.27.0 &amp;&amp; oxfmt v0.12.0</h2>
<h1>Oxlint v1.27.0</h1>
<h3>🚀 Features</h3>
<ul>
<li>222a8f0 linter/plugins: Implement
<code>SourceCode#isSpaceBetween</code> (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15498">#15498</a>)
(overlookmotel)</li>
<li>2f9735d linter/plugins: Implement
<code>context.languageOptions</code> (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15486">#15486</a>)
(overlookmotel)</li>
<li>bc731ff linter/plugins: Stub out all <code>Context</code> APIs (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15479">#15479</a>)
(overlookmotel)</li>
<li>5822cb4 linter/plugins: Add <code>extend</code> method to
<code>FILE_CONTEXT</code> (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15477">#15477</a>)
(overlookmotel)</li>
<li>7b1e6f3 apps: Add pure rust binaries and release to github (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15469">#15469</a>)
(Boshen)</li>
<li>2a89b43 linter: Introduce debug assertions after fixes to assert
validity (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15389">#15389</a>)
(camc314)</li>
<li>ad3c45a editor: Add <code>oxc.path.node</code> option (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15040">#15040</a>)
(Sysix)</li>
</ul>
<h3>🐛 Bug Fixes</h3>
<ul>
<li>6f3cd77 linter/no-var: Incorrect warning for blocks (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15504">#15504</a>)
(Hamir Mahal)</li>
<li>6957fb9 linter/plugins: Do not allow access to
<code>Context#id</code> in <code>createOnce</code> (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15489">#15489</a>)
(overlookmotel)</li>
<li>7409630 linter/plugins: Allow access to <code>cwd</code> in
<code>createOnce</code> in ESLint interop mode (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15488">#15488</a>)
(overlookmotel)</li>
<li>732205e parser: Reject <code>using</code> / <code>await using</code>
in a switch <code>case</code> / <code>default</code> clause (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15225">#15225</a>)
(sapphi-red)</li>
<li>a17ca32 linter/plugins: Replace <code>Context</code> class (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15448">#15448</a>)
(overlookmotel)</li>
<li>ecf2f7b language_server: Fail gracefully when tsgolint executable
not found (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15436">#15436</a>)
(camc314)</li>
<li>3c8d3a7 lang-server: Improve logging in failure case for tsgolint
(<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15299">#15299</a>)
(camc314)</li>
<li>ef71410 linter: Use jsx if source type is JS in fix debug assertion
(<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15434">#15434</a>)
(camc314)</li>
<li>e32bbf6 linter/no-var: Handle TypeScript declare keyword in fixer
(<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15426">#15426</a>)
(camc314)</li>
<li>6565dbe linter/switch-case-braces: Skip comments when searching for
<code>:</code> token (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15425">#15425</a>)
(camc314)</li>
<li>85bd19a linter/prefer-class-fields: Insert value after type
annotation in fixer (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15423">#15423</a>)
(camc314)</li>
<li>fde753e linter/plugins: Block access to
<code>context.settings</code> in <code>createOnce</code> (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15394">#15394</a>)
(overlookmotel)</li>
<li>ddd9f9f linter/forward-ref-uses-ref: Dont suggest removing wrapper
in invalid positions (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15388">#15388</a>)
(camc314)</li>
<li>dac2a9c linter/no-template-curly-in-string: Remove fixer (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15387">#15387</a>)
(camc314)</li>
<li>989b8e3 linter/no-var: Only fix to <code>const</code> if the var has
an initializer (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15385">#15385</a>)
(camc314)</li>
<li>cc403f5 linter/plugins: Return empty object for unimplemented
parserServices (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15364">#15364</a>)
(magic-akari)</li>
</ul>
<h3>⚡ Performance</h3>
<ul>
<li>25d577e language_server: Start tools in parallel (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15500">#15500</a>)
(Sysix)</li>
<li>3c57291 linter/plugins: Optimize loops (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15449">#15449</a>)
(overlookmotel)</li>
<li>3166233 linter/plugins: Remove <code>Arc</code>s (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15431">#15431</a>)
(overlookmotel)</li>
<li>9de1322 linter/plugins: Lazily deserialize settings JSON (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15395">#15395</a>)
(overlookmotel)</li>
<li>3049ec2 linter/plugins: Optimize <code>deepFreezeSettings</code> (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15392">#15392</a>)
(overlookmotel)</li>
<li>444ebfd linter/plugins: Use single object for
<code>parserServices</code> (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15378">#15378</a>)
(overlookmotel)</li>
</ul>
<h3>📚 Documentation</h3>
<ul>
<li>97d2104 linter: Update comment in lint.rs about default value for
tsconfig path (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15530">#15530</a>)
(Connor Shea)</li>
<li>2c6bd9e linter: Always refer as &quot;ES2015&quot; instead of
&quot;ES6&quot; (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15411">#15411</a>)
(sapphi-red)</li>
<li>a0c5203 linter/import/named: Update &quot;ES7&quot; comment in
examples (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15410">#15410</a>)
(sapphi-red)</li>
<li>3dc24b5 linter,minifier: Always refer as &quot;ES Modules&quot;
instead of &quot;ES6 Modules&quot; (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15409">#15409</a>)
(sapphi-red)</li>
<li>2ad77fb linter/no-this-before-super: Correct &quot;Why is this
bad?&quot; section (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15408">#15408</a>)
(sapphi-red)</li>
<li>57f0ce1 linter: Add backquotes where appropriate (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15407">#15407</a>)
(sapphi-red)</li>
</ul>
<h1>Oxfmt v0.12.0</h1>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/oxc-project/oxc/blob/main/npm/oxlint/CHANGELOG.md">oxlint's
changelog</a>.</em></p>
<blockquote>
<h2>[1.74.0] - 2026-07-13</h2>
<h3>🚀 Features</h3>
<ul>
<li>0433a83 linter/eslint/no-inner-declarations: Add
<code>namespaces</code> option (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/24044">#24044</a>)
(Boshen)</li>
</ul>
<h3>🐛 Bug Fixes</h3>
<ul>
<li>8337835 linter: Error on <code>ignorePatterns</code> that cannot
match files aoutside the config directory (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/24341">#24341</a>)
(leaysgur)</li>
<li>2ce5a33 linter: Resolve <code>ignorePatterns</code> relative to the
config dir (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/24339">#24339</a>)
(leaysgur)</li>
</ul>
<h3>⚡ Performance</h3>
<ul>
<li>7f80cac linter/vue/prop-name-casing: Precompile
<code>ignoreProps</code> regex pattern (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/24413">#24413</a>)
(connorshea)</li>
<li>6272051 linter/typescript/no-require-imports: Compile allow patterns
once (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/24417">#24417</a>)
(connorshea)</li>
<li>33805b9 linter/jsdoc/require-param: Compile checkTypesPattern regex
once (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/24420">#24420</a>)
(connorshea)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/oxc-project/oxc/commit/2d4e8d20644e0e7446f0a381894b45ea339a0625"><code>2d4e8d2</code></a>
release(apps): oxlint v1.74.0 &amp;&amp; oxfmt v0.59.0 (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/24454">#24454</a>)</li>
<li><a
href="https://github.com/oxc-project/oxc/commit/7f80cac4fdfb9f488886267fe7a85ca2b73be85e"><code>7f80cac</code></a>
perf(linter/vue/prop-name-casing): precompile <code>ignoreProps</code>
regex pattern (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/2">#2</a>...</li>
<li><a
href="https://github.com/oxc-project/oxc/commit/6272051fcd0b8ef168c8d9ec98df31cce85364b7"><code>6272051</code></a>
perf(linter/typescript/no-require-imports): compile allow patterns once
(<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/24417">#24417</a>)</li>
<li><a
href="https://github.com/oxc-project/oxc/commit/33805b96917e706adb02b373d53170297c238ce0"><code>33805b9</code></a>
perf(linter/jsdoc/require-param): compile checkTypesPattern regex once
(<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/24420">#24420</a>)</li>
<li><a
href="https://github.com/oxc-project/oxc/commit/0433a8376aa3e51ee7a8371f897553cddcf4a663"><code>0433a83</code></a>
feat(linter/eslint/no-inner-declarations): add <code>namespaces</code>
option (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/24044">#24044</a>)</li>
<li><a
href="https://github.com/oxc-project/oxc/commit/83378351e43ecd605c5a52cb1454cbed9421ac1a"><code>8337835</code></a>
fix(linter): error on <code>ignorePatterns</code> that cannot match
files aoutside the c...</li>
<li><a
href="https://github.com/oxc-project/oxc/commit/2ce5a33eccb96d1e297b54f4e7c1fc83f67e9ae2"><code>2ce5a33</code></a>
fix(linter): resolve <code>ignorePatterns</code> relative to the config
dir (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/24339">#24339</a>)</li>
<li>See full diff in <a
href="https://github.com/oxc-project/oxc/commits/oxlint_v1.74.0/npm/oxlint">compare
view</a></li>
</ul>
</details>
<br />

Updates `oxlint-tsgolint` from 0.24.0 to 0.25.0
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/oxc-project/tsgolint/releases">oxlint-tsgolint's
releases</a>.</em></p>
<blockquote>
<h2>v0.25.0</h2>
<h2>What's Changed</h2>
<ul>
<li>chore(deps): update pnpm to v11.9.0 by <a
href="https://github.com/renovate"><code>@​renovate</code></a>[bot] in
<a
href="https://redirect.github.com/oxc-project/tsgolint/pull/1043">oxc-project/tsgolint#1043</a></li>
<li>perf(no-unnecessary-type-parameters): reuse AST-walk visitor closure
by <a href="https://github.com/connorshea"><code>@​connorshea</code></a>
in <a
href="https://redirect.github.com/oxc-project/tsgolint/pull/1034">oxc-project/tsgolint#1034</a></li>
<li>perf(strict-void-return): reuse function-body walk visitor closure
by <a href="https://github.com/connorshea"><code>@​connorshea</code></a>
in <a
href="https://redirect.github.com/oxc-project/tsgolint/pull/1033">oxc-project/tsgolint#1033</a></li>
<li>chore: update typescript-go submodule by <a
href="https://github.com/camc314"><code>@​camc314</code></a> in <a
href="https://redirect.github.com/oxc-project/tsgolint/pull/1048">oxc-project/tsgolint#1048</a></li>
<li>docs: normalize README sponsor section by <a
href="https://github.com/Boshen"><code>@​Boshen</code></a> in <a
href="https://redirect.github.com/oxc-project/tsgolint/pull/1050">oxc-project/tsgolint#1050</a></li>
<li>chore: strip symbols from release binary by <a
href="https://github.com/Boshen"><code>@​Boshen</code></a> in <a
href="https://redirect.github.com/oxc-project/tsgolint/pull/1049">oxc-project/tsgolint#1049</a></li>
<li>chore: add -trimpath to release build by <a
href="https://github.com/Boshen"><code>@​Boshen</code></a> in <a
href="https://redirect.github.com/oxc-project/tsgolint/pull/1051">oxc-project/tsgolint#1051</a></li>
<li>chore(deps): update crate-ci/typos action to v1.48.0 by <a
href="https://github.com/renovate"><code>@​renovate</code></a>[bot] in
<a
href="https://redirect.github.com/oxc-project/tsgolint/pull/1052">oxc-project/tsgolint#1052</a></li>
<li>chore(deps): update github actions by <a
href="https://github.com/renovate"><code>@​renovate</code></a>[bot] in
<a
href="https://redirect.github.com/oxc-project/tsgolint/pull/1056">oxc-project/tsgolint#1056</a></li>
<li>chore(deps): update npm packages by <a
href="https://github.com/renovate"><code>@​renovate</code></a>[bot] in
<a
href="https://redirect.github.com/oxc-project/tsgolint/pull/1057">oxc-project/tsgolint#1057</a></li>
<li>fix(no-deprecated): avoid contextual union property false positives
by <a href="https://github.com/camc314"><code>@​camc314</code></a> in <a
href="https://redirect.github.com/oxc-project/tsgolint/pull/1059">oxc-project/tsgolint#1059</a></li>
<li>fix(prefer-optional-chain): preserve negated optional guard by <a
href="https://github.com/camc314"><code>@​camc314</code></a> in <a
href="https://redirect.github.com/oxc-project/tsgolint/pull/1061">oxc-project/tsgolint#1061</a></li>
<li>perf(linter): gate reportNodeWithSuggestions on FixSuggestions by <a
href="https://github.com/connorshea"><code>@​connorshea</code></a> in <a
href="https://redirect.github.com/oxc-project/tsgolint/pull/1067">oxc-project/tsgolint#1067</a></li>
<li>fix(switch-exhaustiveness-check): honor default case comment
patterns by <a
href="https://github.com/camc314"><code>@​camc314</code></a> in <a
href="https://redirect.github.com/oxc-project/tsgolint/pull/1070">oxc-project/tsgolint#1070</a></li>
<li>perf(strict-void-return): skip type queries for never-callable
expressions by <a
href="https://github.com/connorshea"><code>@​connorshea</code></a> in <a
href="https://redirect.github.com/oxc-project/tsgolint/pull/1068">oxc-project/tsgolint#1068</a></li>
<li>perf(no-unsafe-argument): skip type queries for never-unsafe
arguments by <a
href="https://github.com/connorshea"><code>@​connorshea</code></a> in <a
href="https://redirect.github.com/oxc-project/tsgolint/pull/1066">oxc-project/tsgolint#1066</a></li>
<li>Revert &quot;perf(strict-void-return): skip type queries for
never-callable expressions&quot; by <a
href="https://github.com/camc314"><code>@​camc314</code></a> in <a
href="https://redirect.github.com/oxc-project/tsgolint/pull/1071">oxc-project/tsgolint#1071</a></li>
<li>chore: update typescript-go submodule by <a
href="https://github.com/camc314"><code>@​camc314</code></a> in <a
href="https://redirect.github.com/oxc-project/tsgolint/pull/1072">oxc-project/tsgolint#1072</a></li>
<li>chore(deps): update dependency dprint-json to v0.23.0 by <a
href="https://github.com/renovate"><code>@​renovate</code></a>[bot] in
<a
href="https://redirect.github.com/oxc-project/tsgolint/pull/1080">oxc-project/tsgolint#1080</a></li>
<li>chore(deps): update taiki-e/install-action action to v2.83.0 by <a
href="https://github.com/renovate"><code>@​renovate</code></a>[bot] in
<a
href="https://redirect.github.com/oxc-project/tsgolint/pull/1082">oxc-project/tsgolint#1082</a></li>
<li>chore(deps): update gomod by <a
href="https://github.com/renovate"><code>@​renovate</code></a>[bot] in
<a
href="https://redirect.github.com/oxc-project/tsgolint/pull/1081">oxc-project/tsgolint#1081</a></li>
<li>perf(no-unnecessary-type-arguments): skip signature resolution when
node has no type arguments by <a
href="https://github.com/connorshea"><code>@​connorshea</code></a> in <a
href="https://redirect.github.com/oxc-project/tsgolint/pull/1064">oxc-project/tsgolint#1064</a></li>
<li>refactor: miscellaneous code cleanup by <a
href="https://github.com/connorshea"><code>@​connorshea</code></a> in <a
href="https://redirect.github.com/oxc-project/tsgolint/pull/1077">oxc-project/tsgolint#1077</a></li>
<li>refactor: remove dead code from rules by <a
href="https://github.com/connorshea"><code>@​connorshea</code></a> in <a
href="https://redirect.github.com/oxc-project/tsgolint/pull/1079">oxc-project/tsgolint#1079</a></li>
<li>refactor: drop dead isFunction guards built on a bitwise ast.Kind
mask by <a
href="https://github.com/connorshea"><code>@​connorshea</code></a> in <a
href="https://redirect.github.com/oxc-project/tsgolint/pull/1078">oxc-project/tsgolint#1078</a></li>
<li>chore: update Node.js to 26.5.0 by <a
href="https://github.com/camc314"><code>@​camc314</code></a> in <a
href="https://redirect.github.com/oxc-project/tsgolint/pull/1087">oxc-project/tsgolint#1087</a></li>
<li>fix(no-unnecessary-type-assertion): avoid recursive unknown checks
by <a href="https://github.com/camc314"><code>@​camc314</code></a> in <a
href="https://redirect.github.com/oxc-project/tsgolint/pull/1086">oxc-project/tsgolint#1086</a></li>
<li>fix(no-unnecessary-type-assertions): generic array false positive by
<a href="https://github.com/camc314"><code>@​camc314</code></a> in <a
href="https://redirect.github.com/oxc-project/tsgolint/pull/1045">oxc-project/tsgolint#1045</a></li>
<li>fix(no-unnecessary-type-assertion): preserve generic write casts by
<a href="https://github.com/camc314"><code>@​camc314</code></a> in <a
href="https://redirect.github.com/oxc-project/tsgolint/pull/1089">oxc-project/tsgolint#1089</a></li>
<li>docs: update sponsor section by <a
href="https://github.com/Boshen"><code>@​Boshen</code></a> in <a
href="https://redirect.github.com/oxc-project/tsgolint/pull/1090">oxc-project/tsgolint#1090</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/oxc-project/tsgolint/compare/v0.24.0...v0.25.0">https://github.com/oxc-project/tsgolint/compare/v0.24.0...v0.25.0</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/oxc-project/tsgolint/commit/1abf9a5ea5656daffd1fef16654626a37fdde794"><code>1abf9a5</code></a>
docs: update sponsor section</li>
<li><a
href="https://github.com/oxc-project/tsgolint/commit/6ee7c11801c7afe054ca61cd35d76d84215621cb"><code>6ee7c11</code></a>
fix(no-unnecessary-type-assertion): preserve generic write casts (<a
href="https://redirect.github.com/oxc-project/tsgolint/issues/1089">#1089</a>)</li>
<li><a
href="https://github.com/oxc-project/tsgolint/commit/e23cc8351b8e3698903a8a73d267b4b326a96e20"><code>e23cc83</code></a>
fix(no-unnecessary-type-assertions): generic array false positive (<a
href="https://redirect.github.com/oxc-project/tsgolint/issues/1045">#1045</a>)</li>
<li><a
href="https://github.com/oxc-project/tsgolint/commit/c3269c01a0c894a31330e1b4c3bd4edc6eb7694b"><code>c3269c0</code></a>
fix(no-unnecessary-type-assertion): avoid recursive unknown checks (<a
href="https://redirect.github.com/oxc-project/tsgolint/issues/1086">#1086</a>)</li>
<li><a
href="https://github.com/oxc-project/tsgolint/commit/c214d5c65f2533dfbbce00680282d71a54180ad8"><code>c214d5c</code></a>
chore: update Node.js to 26.5.0 (<a
href="https://redirect.github.com/oxc-project/tsgolint/issues/1087">#1087</a>)</li>
<li><a
href="https://github.com/oxc-project/tsgolint/commit/002b81870918f65cb73b54ce8899e245b3d51110"><code>002b818</code></a>
refactor: drop dead isFunction guards built on a bitwise ast.Kind mask
(<a
href="https://redirect.github.com/oxc-project/tsgolint/issues/1078">#1078</a>)</li>
<li><a
href="https://github.com/oxc-project/tsgolint/commit/d6a1cb214548dc858b3b228ada8e0f2e2cd60935"><code>d6a1cb2</code></a>
refactor: remove dead code from rules (<a
href="https://redirect.github.com/oxc-project/tsgolint/issues/1079">#1079</a>)</li>
<li><a
href="https://github.com/oxc-project/tsgolint/commit/7823d5902901a02b004a01f3623fa42c22cb4f1f"><code>7823d59</code></a>
refactor: miscellaneous code cleanup (<a
href="https://redirect.github.com/oxc-project/tsgolint/issues/1077">#1077</a>)</li>
<li><a
href="https://github.com/oxc-project/tsgolint/commit/fcf43ff2e64ad2aaa818177d6189ffdce0ba1c2d"><code>fcf43ff</code></a>
perf(no-unnecessary-type-arguments): skip signature resolution when node
has ...</li>
<li><a
href="https://github.com/oxc-project/tsgolint/commit/68759141e030b9d053d97bbe6fe4faf7e52bd5e8"><code>6875914</code></a>
chore(deps): update gomod (<a
href="https://redirect.github.com/oxc-project/tsgolint/issues/1081">#1081</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/oxc-project/tsgolint/compare/v0.24.0...v0.25.0">compare
view</a></li>
</ul>
</details>
<br />

Updates `tailwindcss` from 4.3.2 to 4.3.3
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/tailwindlabs/tailwindcss/releases">tailwindcss's
releases</a>.</em></p>
<blockquote>
<h2>v4.3.3</h2>
<h3>Fixed</h3>
<ul>
<li>Support <code>--watch --poll[=ms]</code> in
<code>@tailwindcss/cli</code> when filesystem events are unreliable or
unavailable (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/20297">#20297</a>)</li>
<li>Canonicalization: match arbitrary hex colors against theme colors
case-insensitively (e.g. <code>bg-[#fff]</code> and
<code>bg-[#FFF]</code> → <code>bg-white</code>) (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/20298">#20298</a>)</li>
<li>Prevent Preflight from overriding Firefox's native
<code>iframe:focus-visible</code> outline styles (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/20292">#20292</a>)</li>
<li>Ensure <code>theme('colors.foo')</code> in JS plugins resolves
correctly when both <code>--color-foo</code> and
<code>--color-foo-bar</code> exist (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/20299">#20299</a>)</li>
<li>Ensure fractional opacity modifiers work with named shadow sizes
like <code>shadow-sm/12.5</code>, <code>text-shadow-sm/12.5</code>,
<code>drop-shadow-sm/12.5</code>, and <code>inset-shadow-sm/12.5</code>
(<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/20302">#20302</a>)</li>
<li>Parse selectors like <code>[data-foo]div</code> as two selectors
instead of one (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/20303">#20303</a>)</li>
<li>Ensure <code>@tailwindcss/postcss</code> rebuilds when a
preprocessor like Sass changes the input CSS without changing the input
file on disk (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/20310">#20310</a>)</li>
<li>Ensure CSS nesting is handled even when Lightning CSS isn't run,
such as in <code>@tailwindcss/browser</code> and Tailwind Play (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/20124">#20124</a>)</li>
<li>Prevent achromatic theme colors from shifting hue when mixed in
polar color spaces like <code>oklch</code> (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/20314">#20314</a>)</li>
<li>Ensure <code>--spacing(0)</code> is optimized to <code>0px</code>
instead of <code>0</code> so it remains a <code>&lt;length&gt;</code>
when used in <code>calc(…)</code> (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/20319">#20319</a>)</li>
<li>Load <code>@parcel/watcher</code> only when needed in
<code>@tailwindcss/cli --watch</code> mode, so one-off builds and
<code>--watch --poll</code> work when <code>@parcel/watcher</code> can't
be loaded (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/20325">#20325</a>)</li>
<li>Use explicit platform fonts instead of <code>system-ui</code> and
<code>ui-sans-serif</code> so CJK text respects the page's
<code>lang</code> attribute on Windows (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/20318">#20318</a>)</li>
<li>Prevent <code>@tailwindcss/upgrade</code> from rewriting ignored
files when run from a subdirectory (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/20329">#20329</a>)</li>
<li>Ensure earlier <code>@source</code> rules pointing to nested files
are scanned when later <code>@source</code> rules point to files in
parent folders (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/20335">#20335</a>)</li>
<li>Prevent <code>@tailwindcss/vite</code> from triggering full page
reloads when scanned files are processed by Vite but haven't been loaded
as modules yet (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/20336">#20336</a>)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md">tailwindcss's
changelog</a>.</em></p>
<blockquote>
<h2>[4.3.3] - 2026-07-16</h2>
<h3>Fixed</h3>
<ul>
<li>Support <code>--watch --poll[=ms]</code> in
<code>@tailwindcss/cli</code> when filesystem events are unreliable or
unavailable (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/20297">#20297</a>)</li>
<li>Canonicalization: match arbitrary hex colors against theme colors
case-insensitively (e.g. <code>bg-[#fff]</code> and
<code>bg-[#FFF]</code> → <code>bg-white</code>) (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/20298">#20298</a>)</li>
<li>Prevent Preflight from overriding Firefox's native
<code>iframe:focus-visible</code> outline styles (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/20292">#20292</a>)</li>
<li>Ensure <code>theme('colors.foo')</code> in JS plugins resolves
correctly when both <code>--color-foo</code> and
<code>--color-foo-bar</code> exist (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/20299">#20299</a>)</li>
<li>Ensure fractional opacity modifiers work with named shadow sizes
like <code>shadow-sm/12.5</code>, <code>text-shadow-sm/12.5</code>,
<code>drop-shadow-sm/12.5</code>, and <code>inset-shadow-sm/12.5</code>
(<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/20302">#20302</a>)</li>
<li>Parse selectors like <code>[data-foo]div</code> as two selectors
instead of one (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/20303">#20303</a>)</li>
<li>Ensure <code>@tailwindcss/postcss</code> rebuilds when a
preprocessor like Sass changes the input CSS without changing the input
file on disk (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/20310">#20310</a>)</li>
<li>Ensure CSS nesting is handled even when Lightning CSS isn't run,
such as in <code>@tailwindcss/browser</code> and Tailwind Play (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/20124">#20124</a>)</li>
<li>Prevent achromatic theme colors from shifting hue when mixed in
polar color spaces like <code>oklch</code> (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/20314">#20314</a>)</li>
<li>Ensure <code>--spacing(0)</code> is optimized to <code>0px</code>
instead of <code>0</code> so it remains a <code>&lt;length&gt;</code>
when used in <code>calc(…)</code> (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/20319">#20319</a>)</li>
<li>Load <code>@parcel/watcher</code> only when needed in
<code>@tailwindcss/cli --watch</code> mode, so one-off builds and
<code>--watch --poll</code> work when <code>@parcel/watcher</code> can't
be loaded (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/20325">#20325</a>)</li>
<li>Use explicit platform fonts instead of <code>system-ui</code> and
<code>ui-sans-serif</code> so CJK text respects the page's
<code>lang</code> attribute on Windows (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/20318">#20318</a>)</li>
<li>Prevent <code>@tailwindcss/upgrade</code> from rewriting ignored
files when run from a subdirectory (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/20329">#20329</a>)</li>
<li>Ensure earlier <code>@source</code> rules pointing to nested files
are scanned when later <code>@source</code> rules point to files in
parent folders (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/20335">#20335</a>)</li>
<li>Prevent <code>@tailwindcss/vite</code> from triggering full page
reloads when scanned files are processed by Vite but haven't been loaded
as modules yet (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/20336">#20336</a>)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/tailwindlabs/tailwindcss/commit/c2b24dd15fed1c59dd521bd86082f520c9f5ad0d"><code>c2b24dd</code></a>
4.3.3 (<a
href="https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/tailwindcss/issues/20334">#20334</a>)</li>
<li><a
href="https://github.com/tailwindlabs/tailwindcss/commit/e48c5e80473c25fe5a27fe267fbaf4f6512424a3"><code>e48c5e8</code></a>
Fix weird character rendering on Windows with Japanese locale (<a
href="https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/tailwindcss/issues/20318">#20318</a>)</li>
<li><a
href="https://github.com/tailwindlabs/tailwindcss/commit/35a3e9c5159bea77af0d48f0c8849279211cc7e9"><code>35a3e9c</code></a>
Always produce <code>\&lt;length&gt;</code> value when optimizing
<code>--spacing(0)</code> (<a
href="https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/tailwindcss/issues/20319">#20319</a>)</li>
<li><a
href="https://github.com/tailwindlabs/tailwindcss/commit/4af47fbe942245fd05ad8cd4776c2dbd27237bee"><code>4af47fb</code></a>
Fix hues in achromatic theme colors to be <code>none</code> (<a
href="https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/tailwindcss/issues/20314">#20314</a>)</li>
<li><a
href="https://github.com/tailwindlabs/tailwindcss/commit/5835691d210b1c530160c844a562c36fa5fcbdb3"><code>5835691</code></a>
Handle CSS nesting natively (<a
href="https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/tailwindcss/issues/20124">#20124</a>)</li>
<li><a
href="https://github.com/tailwindlabs/tailwindcss/commit/67c745efde4c6b36879e812b4c3d9250f7e0b31e"><code>67c745e</code></a>
Fix bug in attribute selector parsing (<a
href="https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/tailwindcss/issues/20303">#20303</a>)</li>
<li><a
href="https://github.com/tailwindlabs/tailwindcss/commit/2683903b86d695f9c2647a06eb58bd96ade1eaee"><code>2683903</code></a>
Support fractional opacity modifiers for named shadow sizes (<a
href="https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/tailwindcss/issues/20302">#20302</a>)</li>
<li><a
href="https://github.com/tailwindlabs/tailwindcss/commit/04588b1e8f3dbc856d8a3fad97c460bee689dbf3"><code>04588b1</code></a>
Fix theme() in JS plugins returning unresolved object instead of DEFAULT
valu...</li>
<li><a
href="https://github.com/tailwindlabs/tailwindcss/commit/b53fa096c92d830fc64b7cf69581496242583446"><code>b53fa09</code></a>
fix: exclude iframes from focus-visible auto outline in Preflight (<a
href="https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/tailwindcss/issues/20292">#20292</a>)</li>
<li><a
href="https://github.com/tailwindlabs/tailwindcss/commit/ef79119d4e2b8b0f37710e736605bd0b3419afb7"><code>ef79119</code></a>
Bump dependencies (<a
href="https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/tailwindcss/issues/20300">#20300</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/tailwindlabs/tailwindcss/commits/v4.3.3/packages/tailwindcss">compare
view</a></li>
</ul>
</details>
<br />

Updates `vite` from 8.1.4 to 8.1.5
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/vitejs/vite/releases">vite's
releases</a>.</em></p>
<blockquote>
<h2>v8.1.5</h2>
<p>Please refer to <a
href="https://github.com/vitejs/vite/blob/v8.1.5/packages/vite/CHANGELOG.md">CHANGELOG.md</a>
for details.</p>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md">vite's
changelog</a>.</em></p>
<blockquote>
<h2><!-- raw HTML omitted --><a
href="https://github.com/vitejs/vite/compare/v8.1.4...v8.1.5">8.1.5</a>
(2026-07-16)<!-- raw HTML omitted --></h2>
<h3>Bug Fixes</h3>
<ul>
<li><strong>bundled-dev:</strong> avoid duplicated <code>buildEnd</code>
(<a
href="https://redirect.github.com/vitejs/vite/issues/22931">#22931</a>)
(<a
href="https://github.com/vitejs/vite/commit/810032097079be1a7da0e2b09ec9d92dd07ec13f">8100320</a>)</li>
<li><strong>client:</strong> overlay error message format align rolldown
(<a
href="https://redirect.github.com/vitejs/vite/issues/22869">#22869</a>)
(<a
href="https://github.com/vitejs/vite/commit/5a72b8780705b575026617e86b0b92dea63a56a5">5a72b87</a>)</li>
<li><strong>deps:</strong> update all non-major dependencies (<a
href="https://redirect.github.com/vitejs/vite/issues/22921">#22921</a>)
(<a
href="https://github.com/vitejs/vite/commit/fef682d3f067d534a559faf6fd9baedda2e9f8f1">fef682d</a>)</li>
<li><strong>deps:</strong> update rolldown-related dependencies (<a
href="https://redirect.github.com/vitejs/vite/issues/22922">#22922</a>)
(<a
href="https://github.com/vitejs/vite/commit/3c345e475a5546a1cc6374682af89caebfe9c593">3c345e4</a>)</li>
<li><strong>module-runner:</strong> don't crash stack-trace source
mapping when globalThis.Buffer is absent (<a
href="https://redirect.github.com/vitejs/vite/issues/22945">#22945</a>)
(<a
href="https://github.com/vitejs/vite/commit/f8b38e316bcefbf29f762f90ee49c88cd52c43b5">f8b38e3</a>)</li>
<li><strong>optimizer:</strong> respect importer module format for
dynamic import interop with CJS deps (<a
href="https://redirect.github.com/vitejs/vite/issues/22951">#22951</a>)
(<a
href="https://github.com/vitejs/vite/commit/6c08c39ac4fb5868d080a51ff976a44693fc56ab">6c08c39</a>)</li>
<li><strong>ssr:</strong> scope switch-case declarations to the switch,
not the function (<a
href="https://redirect.github.com/vitejs/vite/issues/22893">#22893</a>)
(<a
href="https://github.com/vitejs/vite/commit/b59a73f76f5557492d83d097bb33b3dd02f27d51">b59a73f</a>)</li>
</ul>
<h3>Documentation</h3>
<ul>
<li><strong>build:</strong> fix incorrect <code>@default</code> for
build.cssMinify (<a
href="https://redirect.github.com/vitejs/vite/issues/22948">#22948</a>)
(<a
href="https://github.com/vitejs/vite/commit/c88c236186d8e2384653e382dd0960ca1ff5b24c">c88c236</a>)</li>
<li><strong>build:</strong> fix incorrect <code>@default</code> for
build.lib.formats (<a
href="https://redirect.github.com/vitejs/vite/issues/22911">#22911</a>)
(<a
href="https://github.com/vitejs/vite/commit/369ed609a4aace3aee4e4194a54990694aa4e7ac">369ed60</a>)</li>
</ul>
<h3>Tests</h3>
<ul>
<li>avoid scanner scanning all files under <code>__tests__</code> (<a
href="https://redirect.github.com/vitejs/vite/issues/22912">#22912</a>)
(<a
href="https://github.com/vitejs/vite/commit/c961cae2868cc1521457ec60583867f0440e6949">c961cae</a>)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/vitejs/vite/commit/5e7fe129a4dde4f41934083b25e490059985f4e6"><code>5e7fe12</code></a>
release: v8.1.5</li>
<li><a
href="https://github.com/vitejs/vite/commit/6c08c39ac4fb5868d080a51ff976a44693fc56ab"><code>6c08c39</code></a>
fix(optimizer): respect importer module format for dynamic import
interop wit...</li>
<li><a
href="https://github.com/vitejs/vite/commit/5a72b8780705b575026617e86b0b92dea63a56a5"><code>5a72b87</code></a>
fix(client): overlay error message format align rolldown (<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/22869">#22869</a>)</li>
<li><a
href="https://github.com/vitejs/vite/commit/f8b38e316bcefbf29f762f90ee49c88cd52c43b5"><code>f8b38e3</code></a>
fix(module-runner): don't crash stack-trace source mapping when
globalThis.Bu...</li>
<li><a
href="https://github.com/vitejs/vite/commit/810032097079be1a7da0e2b09ec9d92dd07ec13f"><code>8100320</code></a>
fix(bundled-dev): avoid duplicated <code>buildEnd</code> (<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/22931">#22931</a>)</li>
<li><a
href="https://github.com/vitejs/vite/commit/c88c236186d8e2384653e382dd0960ca1ff5b24c"><code>c88c236</code></a>
docs(build): fix incorrect <code>@default</code> for build.cssMinify (<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/22948">#22948</a>)</li>
<li><a
href="https://github.com/vitejs/vite/commit/b59a73f76f5557492d83d097bb33b3dd02f27d51"><code>b59a73f</code></a>
fix(ssr): scope switch-case declarations to the switch, not the function
(<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/22">#22</a>...</li>
<li><a
href="https://github.com/vitejs/vite/commit/fef682d3f067d534a559faf6fd9baedda2e9f8f1"><code>fef682d</code></a>
fix(deps): update all non-major dependencies (<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/22921">#22921</a>)</li>
<li><a
href="https://github.com/vitejs/vite/commit/3c345e475a5546a1cc6374682af89caebfe9c593"><code>3c345e4</code></a>
fix(deps): update rolldown-related dependencies (<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/22922">#22922</a>)</li>
<li><a
href="https://github.com/vitejs/vite/commit/369ed609a4aace3aee4e4194a54990694aa4e7ac"><code>369ed60</code></a>
docs(build): fix incorrect <code>@default</code> for build.lib.formats
(<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/22911">#22911</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/vitejs/vite/commits/v8.1.5/packages/vite">compare
view</a></li>
</ul>
</details>
<br />


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

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

---

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

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


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-24 18:33:52 -04:00
dependabot[bot] 8eb9bd5c21 Bump python from d3400aa to cea0e60 (#1131)
Bumps python from `d3400aa` to `cea0e60`.


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

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

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

---

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

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


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-24 18:33:45 -04:00
dependabot[bot] a4d587037d Bump seleniumbase from 4.51.2 to 4.51.3 in the python-deps group (#1128)
Bumps the python-deps group with 1 update:
[seleniumbase](https://github.com/seleniumbase/SeleniumBase).

Updates `seleniumbase` from 4.51.2 to 4.51.3
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/seleniumbase/SeleniumBase/releases">seleniumbase's
releases</a>.</em></p>
<blockquote>
<h2>4.51.3 - CDP Mode: Patch 121</h2>
<h2>CDP Mode: Patch 121</h2>
<ul>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/a190a7e3f9360d04d14f31ecdb773b0d803babd1">Update
CDP Mode</a>
-- Add <code>scroll</code> &amp; <code>timeout</code> args for some
methods.
-- Handle a special case for clicking <code>svg</code> elements.</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/01c6cec2fedb22ea8afd98333341477716a886e9">Refresh
Python dependencies</a>
-- (<code>websockets</code>, <code>filelock</code>,
<code>platformdirs</code>)</li>
</ul>
<h2>What's Changed</h2>
<ul>
<li>CDP Mode: Patch 121 by <a
href="https://github.com/mdmintz"><code>@​mdmintz</code></a> in <a
href="https://redirect.github.com/seleniumbase/SeleniumBase/pull/4431">seleniumbase/SeleniumBase#4431</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/seleniumbase/SeleniumBase/compare/v4.51.2...v4.51.3">https://github.com/seleniumbase/SeleniumBase/compare/v4.51.2...v4.51.3</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/2d5d3fafb22d894abeb941ab51b0428ad55cb48b"><code>2d5d3fa</code></a>
Merge pull request <a
href="https://redirect.github.com/seleniumbase/SeleniumBase/issues/4431">#4431</a>
from seleniumbase/cdp-mode-patch-121</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/2cf34788cc111bcf415180dbbe4440490a776b9d"><code>2cf3478</code></a>
Version 4.51.3</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/01c6cec2fedb22ea8afd98333341477716a886e9"><code>01c6cec</code></a>
Refresh Python dependencies</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/a190a7e3f9360d04d14f31ecdb773b0d803babd1"><code>a190a7e</code></a>
Update CDP Mode</li>
<li>See full diff in <a
href="https://github.com/seleniumbase/SeleniumBase/compare/v4.51.2...v4.51.3">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=seleniumbase&package-manager=uv&previous-version=4.51.2&new-version=4.51.3)](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-07-24 18:33:38 -04:00
Calibrain 1747b3466e Fix linting issues 2026-07-21 19:52:25 +00:00
CaliBrain 81a057c8a0 Try and fix OIDC (#1127) 2026-07-21 15:43:54 -04:00
Matt Van HornandMatt Van Horn 91b1064689 fix(naming): respect subfolder order when assigning audiobook part numbers (#1123)
## Summary

Audiobooks whose files are split across nested subfolders now keep their
on-disk folder order when "Rename and Organize" assigns part numbers.
Previously `natural_sort_key` keyed on `Path(path).name`, so identically
named files from different folders (`001.mp3` in `00_Introduction/` vs
`06_Side 6/`) collapsed together and the part sequence scrambled.

## Background

zazizou reported in #1007 that the total file count was right but
`06_Side 6/001.mp3` came out as part 1 instead of
`00_Introduction/001_About.mp3`. `scan.py` flattens every nested file
into one list and `assign_part_numbers` sorts it with
`natural_sort_key`, which discarded the parent-folder prefix. The fix
pads the existing `PAD_NUMBERS_PATTERN` numbers over the full relative
path (`str(path).lower()`) rather than the basename, so nested
audiobooks sort by folder first and then by filename. Flat directories
are unaffected because their files share a prefix, and
`natural_sort_key` is only called from `assign_part_numbers`, so nothing
else changes. I updated the test that pinned the old basename-only
ordering and added nested-subfolder and duplicate-basename cases;
`pytest tests/core/test_naming.py
tests/core/test_part_number_extraction.py` passes (102 tests).

Closes #1007

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
2026-07-21 15:41:03 -04:00
dependabot[bot] c9a2b5eccf Bump the python-deps group across 1 directory with 4 updates (#1126)
Bumps the python-deps group with 4 updates in the / directory:
[tqdm](https://github.com/tqdm/tqdm),
[seleniumbase](https://github.com/seleniumbase/SeleniumBase),
[prek](https://github.com/j178/prek) and
[ruff](https://github.com/astral-sh/ruff).

Updates `tqdm` from 4.68.4 to 4.69.0
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/tqdm/tqdm/releases">tqdm's
releases</a>.</em></p>
<blockquote>
<h2>tqdm v4.69.0 stable</h2>
<ul>
<li>add <code>tqdm.asyncio.gather(..., return_exceptions=False)</code>
(<a href="https://redirect.github.com/tqdm/tqdm/issues/1776">#1776</a>,
<a href="https://redirect.github.com/tqdm/tqdm/issues/1671">#1671</a>
&lt;- <a
href="https://redirect.github.com/tqdm/tqdm/issues/1286">#1286</a>)</li>
<li>misc minor framework updates
<ul>
<li>bump workflow actions &amp; pre-commit hooks</li>
</ul>
</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/tqdm/tqdm/commit/6ab24dcc5df910044f1f6e0685f95dbf9cd424f3"><code>6ab24dc</code></a>
bump version, workflow actions, pre-commit hooks</li>
<li><a
href="https://github.com/tqdm/tqdm/commit/99615b978702dcf61986de62dfd252d82720af76"><code>99615b9</code></a>
asyncio: add gather(return_exceptions=False) (<a
href="https://redirect.github.com/tqdm/tqdm/issues/1776">#1776</a>)</li>
<li>See full diff in <a
href="https://github.com/tqdm/tqdm/compare/v4.68.4...v4.69.0">compare
view</a></li>
</ul>
</details>
<br />

Updates `seleniumbase` from 4.51.0 to 4.51.2
<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.51.2 - CDP Mode: Patch 120</h2>
<h2>CDP Mode: Patch 120</h2>
<ul>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/7614ca495d8a52a2100516c0fcad079e191c13f4">Fix
WebRTC leak for non-proxied navigation</a>
-- This resolves <a
href="https://redirect.github.com/seleniumbase/SeleniumBase/issues/4428">seleniumbase/SeleniumBase#4428</a></li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/92c528ae17d0e07294671d09cb253c5e5ebbb8ca">Drop
support for the deprecated Atlas Browser</a>
-- This resolves <a
href="https://redirect.github.com/seleniumbase/SeleniumBase/issues/4427">seleniumbase/SeleniumBase#4427</a></li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/9c0f401a9977efbb7d13114aaf98a52a0532af9c">Remove
Copilot examples (requires login now)</a>
-- This resolves <a
href="https://redirect.github.com/seleniumbase/SeleniumBase/issues/4429">seleniumbase/SeleniumBase#4429</a></li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/aca0363058c95c4590ff6639e4e51d05010ddbe8">Add
a CDP Mode fingerprint test</a>
-- (Uses the Clearcote fingerprint scanner / auditor)</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/e152f11df548e76ec34822188a5234f6cb09cd57">Update
Stealthy Playwright Mode examples</a>
-- (<code>sb.goto(url)</code> is stealthier than
<code>page.goto(url)</code>)</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/93de30adf71d6b3277512ab78f1ddf1eb0b586d3">Refresh
Python dependencies</a>
-- (<code>filelock</code> and <code>coverage</code>)</li>
</ul>
<h2>What's Changed</h2>
<ul>
<li>CDP Mode: Patch 120 by <a
href="https://github.com/mdmintz"><code>@​mdmintz</code></a> in <a
href="https://redirect.github.com/seleniumbase/SeleniumBase/pull/4430">seleniumbase/SeleniumBase#4430</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/seleniumbase/SeleniumBase/compare/v4.51.1...v4.51.2">https://github.com/seleniumbase/SeleniumBase/compare/v4.51.1...v4.51.2</a></p>
<h2>4.51.1 - Updates to multiple areas</h2>
<h2>Updates to multiple areas</h2>
<ul>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/706e23c5b25000e64f9b97dbb9118717eafaed05">Update
the xpath-to-css converter</a></li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/1c7fa3bbefd870e79e3b3e75044b2a88258f204e">Update
the CDP Mode ad-block list</a></li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/cb6fbb103b4dc61d2f6d425c54dc966b4fd6181e">Update
error messages for proxy failures</a></li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/fe392b5b3fd90a04d0d266ddbcd76cbcc7ccab7c">Update
proxy validation</a></li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/dd14d5e0c7832150d70b6bd3bd950cc3a8ad10cb">Update
the Slider CAPTCHA solver</a></li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/97d53f601faa2f8247eea00271ce6140e3d7d158">Update
CDP Mode examples</a></li>
</ul>
<h2>What's Changed</h2>
<ul>
<li>Updates to multiple areas by <a
href="https://github.com/mdmintz"><code>@​mdmintz</code></a> in <a
href="https://redirect.github.com/seleniumbase/SeleniumBase/pull/4424">seleniumbase/SeleniumBase#4424</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/seleniumbase/SeleniumBase/compare/v4.51.0...v4.51.1">https://github.com/seleniumbase/SeleniumBase/compare/v4.51.0...v4.51.1</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/f9fca2dc604067020363b285b0fff15910e64cea"><code>f9fca2d</code></a>
Merge pull request <a
href="https://redirect.github.com/seleniumbase/SeleniumBase/issues/4430">#4430</a>
from seleniumbase/cdp-mode-patch-120</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/e4f803f5adc47880c5563c7ae528d1a190249865"><code>e4f803f</code></a>
Version 4.51.2</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/93de30adf71d6b3277512ab78f1ddf1eb0b586d3"><code>93de30a</code></a>
Refresh Python dependencies</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/e152f11df548e76ec34822188a5234f6cb09cd57"><code>e152f11</code></a>
Update Stealthy Playwright Mode examples</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/aca0363058c95c4590ff6639e4e51d05010ddbe8"><code>aca0363</code></a>
Add a CDP Mode fingerprint test</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/9c0f401a9977efbb7d13114aaf98a52a0532af9c"><code>9c0f401</code></a>
Remove Copilot examples (requires login now)</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/92c528ae17d0e07294671d09cb253c5e5ebbb8ca"><code>92c528a</code></a>
Drop support for the deprecated Atlas Browser</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/7614ca495d8a52a2100516c0fcad079e191c13f4"><code>7614ca4</code></a>
Fix WebRTC leak for non-proxied navigation</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/45a338be063e2ae0561779c1ca5433804242d98d"><code>45a338b</code></a>
Update CDP Mode examples</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/a1fd937c94825d1f668eacc457cb9539bfb22d93"><code>a1fd937</code></a>
Update CDP Mode examples</li>
<li>Additional commits viewable in <a
href="https://github.com/seleniumbase/SeleniumBase/compare/v4.51.0...v4.51.2">compare
view</a></li>
</ul>
</details>
<br />

Updates `prek` from 0.4.9 to 0.4.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.4.10</h2>
<h2>Release Notes</h2>
<p>Released on 2026-07-16.</p>
<h3>Enhancements</h3>
<ul>
<li>Add PHP language support (<a
href="https://redirect.github.com/j178/prek/pull/2314">#2314</a>)</li>
<li>Add freeze option to update settings (<a
href="https://redirect.github.com/j178/prek/pull/2323">#2323</a>)</li>
<li>Add tag filters to update configuration (<a
href="https://redirect.github.com/j178/prek/pull/2354">#2354</a>)</li>
<li>Identify 'mts' and 'cts' as TypeScript files (<a
href="https://redirect.github.com/j178/prek/pull/2209">#2209</a>)</li>
<li>Publish Alpine Docker images (<a
href="https://redirect.github.com/j178/prek/pull/2352">#2352</a>)</li>
<li>Support builtin and meta in try-repo (<a
href="https://redirect.github.com/j178/prek/pull/2350">#2350</a>)</li>
</ul>
<h3>Bug fixes</h3>
<ul>
<li>Fix Python discovery order (<a
href="https://redirect.github.com/j178/prek/pull/2348">#2348</a>)</li>
<li>Fix Windows progress rendering (<a
href="https://redirect.github.com/j178/prek/pull/2328">#2328</a>)</li>
<li>Preserve configured repo values for updates (<a
href="https://redirect.github.com/j178/prek/pull/2324">#2324</a>)</li>
<li>Scope synthetic <code>GIT_WORK_TREE</code> to git commands (<a
href="https://redirect.github.com/j178/prek/pull/2356">#2356</a>)</li>
</ul>
<h3>Documentation</h3>
<ul>
<li>Expand common workflows guide (<a
href="https://redirect.github.com/j178/prek/pull/2351">#2351</a>)</li>
</ul>
<h3>Contributors</h3>
<ul>
<li><a href="https://github.com/j178"><code>@​j178</code></a></li>
</ul>
<h2>Install prek 0.4.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.4.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.4.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.4.10</h2>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/j178/prek/blob/master/CHANGELOG.md">prek's
changelog</a>.</em></p>
<blockquote>
<h2>0.4.10</h2>
<p>Released on 2026-07-16.</p>
<h3>Enhancements</h3>
<ul>
<li>Add PHP language support (<a
href="https://redirect.github.com/j178/prek/pull/2314">#2314</a>)</li>
<li>Add freeze option to update settings (<a
href="https://redirect.github.com/j178/prek/pull/2323">#2323</a>)</li>
<li>Add tag filters to update configuration (<a
href="https://redirect.github.com/j178/prek/pull/2354">#2354</a>)</li>
<li>Identify 'mts' and 'cts' as TypeScript files (<a
href="https://redirect.github.com/j178/prek/pull/2209">#2209</a>)</li>
<li>Publish Alpine Docker images (<a
href="https://redirect.github.com/j178/prek/pull/2352">#2352</a>)</li>
<li>Support builtin and meta in try-repo (<a
href="https://redirect.github.com/j178/prek/pull/2350">#2350</a>)</li>
</ul>
<h3>Bug fixes</h3>
<ul>
<li>Fix Python discovery order (<a
href="https://redirect.github.com/j178/prek/pull/2348">#2348</a>)</li>
<li>Fix Windows progress rendering (<a
href="https://redirect.github.com/j178/prek/pull/2328">#2328</a>)</li>
<li>Preserve configured repo values for updates (<a
href="https://redirect.github.com/j178/prek/pull/2324">#2324</a>)</li>
<li>Scope synthetic <code>GIT_WORK_TREE</code> to git commands (<a
href="https://redirect.github.com/j178/prek/pull/2356">#2356</a>)</li>
</ul>
<h3>Documentation</h3>
<ul>
<li>Expand common workflows guide (<a
href="https://redirect.github.com/j178/prek/pull/2351">#2351</a>)</li>
</ul>
<h3>Contributors</h3>
<ul>
<li><a href="https://github.com/j178"><code>@​j178</code></a></li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/j178/prek/commit/b45e6710082ed28943c96268d0ea99026dbb13ba"><code>b45e671</code></a>
Bump version to 0.4.10 (<a
href="https://redirect.github.com/j178/prek/issues/2358">#2358</a>)</li>
<li><a
href="https://github.com/j178/prek/commit/0cf3be5280613835759de9912ec57eb724d3db22"><code>0cf3be5</code></a>
Scope synthetic <code>GIT_WORK_TREE</code> to git commands (<a
href="https://redirect.github.com/j178/prek/issues/2356">#2356</a>)</li>
<li><a
href="https://github.com/j178/prek/commit/0265dddfff97f1be7fcf3aaeefdeeb12572473de"><code>0265ddd</code></a>
Add tag filters to update configuration (<a
href="https://redirect.github.com/j178/prek/issues/2354">#2354</a>)</li>
<li><a
href="https://github.com/j178/prek/commit/dcfa91b31b3b165bee4bc472291aadaf7910069f"><code>dcfa91b</code></a>
Publish Alpine Docker images (<a
href="https://redirect.github.com/j178/prek/issues/2352">#2352</a>)</li>
<li><a
href="https://github.com/j178/prek/commit/1e0b2c108458e781c827695bd075406a13f15cb6"><code>1e0b2c1</code></a>
Expand common workflows guide (<a
href="https://redirect.github.com/j178/prek/issues/2351">#2351</a>)</li>
<li><a
href="https://github.com/j178/prek/commit/753e13c4fe94d83695da6bc16f2939770c9aa219"><code>753e13c</code></a>
Support builtin and meta in try-repo (<a
href="https://redirect.github.com/j178/prek/issues/2350">#2350</a>)</li>
<li><a
href="https://github.com/j178/prek/commit/1e2abfb81f5213990413f222ad4d7444af430518"><code>1e2abfb</code></a>
Fix Python discovery order (<a
href="https://redirect.github.com/j178/prek/issues/2348">#2348</a>)</li>
<li><a
href="https://github.com/j178/prek/commit/ec15d683e1b9bd8e265d92d7116bf19b574fedc9"><code>ec15d68</code></a>
Update Rust crate indicatif to v0.18.6 (<a
href="https://redirect.github.com/j178/prek/issues/2330">#2330</a>)</li>
<li><a
href="https://github.com/j178/prek/commit/ba4939a4856803ea1c4b886d5c8b3b9ba19df74a"><code>ba4939a</code></a>
Update Rust crate fs-err to v3.3.1 (<a
href="https://redirect.github.com/j178/prek/issues/2329">#2329</a>)</li>
<li><a
href="https://github.com/j178/prek/commit/e43acba74e33728d30277a2c229eaed1def17b49"><code>e43acba</code></a>
Improve contribution templates (<a
href="https://redirect.github.com/j178/prek/issues/2347">#2347</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/j178/prek/compare/v0.4.9...v0.4.10">compare
view</a></li>
</ul>
</details>
<br />

Updates `ruff` from 0.15.21 to 0.15.22
<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.15.22</h2>
<h2>Release Notes</h2>
<p>Released on 2026-07-16.</p>
<h3>Preview features</h3>
<ul>
<li>[<code>pycodestyle</code>] Add an autofix for <code>E402</code> (<a
href="https://redirect.github.com/astral-sh/ruff/pull/22212">#22212</a>)</li>
<li>[<code>refurb</code>] Allow subclassing builtins in stub files
(<code>FURB189</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/26812">#26812</a>)</li>
<li>[<code>ruff</code>] Add rule to replace <code>noqa</code> comments
with <code>ruff:ignore</code> (<code>RUF105</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/26423">#26423</a>)</li>
<li>[<code>ruff</code>] Add rule to use human-readable names in
<code>ruff:ignore</code> comments (<code>RUF106</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/26682">#26682</a>)</li>
<li>[<code>ruff</code>] Add rule to use human-readable names in
configuration selectors (<code>RUF201</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/26772">#26772</a>)</li>
</ul>
<h3>Bug fixes</h3>
<ul>
<li>[<code>flake8-pyi</code>] Fix false positive in <code>__all__</code>
(<code>PYI053</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/26872">#26872</a>)</li>
</ul>
<h3>Rule changes</h3>
<ul>
<li>[<code>pylint</code>] Ignore mutable type updates in
<code>redefined-loop-name</code> (<code>PLW2901</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/25733">#25733</a>)</li>
</ul>
<h3>Performance</h3>
<ul>
<li>Avoid redundant lexer token bookkeeping (<a
href="https://redirect.github.com/astral-sh/ruff/pull/26765">#26765</a>)</li>
<li>Avoid redundant pending-indentation writes (<a
href="https://redirect.github.com/astral-sh/ruff/pull/26774">#26774</a>)</li>
<li>Avoid unnecessary identifier lookahead (<a
href="https://redirect.github.com/astral-sh/ruff/pull/26525">#26525</a>)</li>
<li>Reuse parser scratch buffers (<a
href="https://redirect.github.com/astral-sh/ruff/pull/26798">#26798</a>)</li>
</ul>
<h3>Documentation</h3>
<ul>
<li>Document argfile support (<a
href="https://redirect.github.com/astral-sh/ruff/pull/26803">#26803</a>)</li>
<li>[<code>flake8-datetimez</code>] Clarify naming guidance for
<code>datetime.today</code> (<code>DTZ002</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/26658">#26658</a>)</li>
<li>[<code>pycodestyle</code>] Document <code>E731</code> fix safety (<a
href="https://redirect.github.com/astral-sh/ruff/pull/26847">#26847</a>)</li>
<li>[<code>ruff</code>] Clarify intentional async contexts for
<code>unused-async</code> (<code>RUF029</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/26641">#26641</a>)</li>
</ul>
<h3>Contributors</h3>
<ul>
<li><a href="https://github.com/dwego"><code>@​dwego</code></a></li>
<li><a
href="https://github.com/MichaReiser"><code>@​MichaReiser</code></a></li>
<li><a href="https://github.com/Joosboy"><code>@​Joosboy</code></a></li>
<li><a
href="https://github.com/KaufmanDmitriy"><code>@​KaufmanDmitriy</code></a></li>
<li><a
href="https://github.com/PeterJCLaw"><code>@​PeterJCLaw</code></a></li>
<li><a href="https://github.com/ntBre"><code>@​ntBre</code></a></li>
<li><a
href="https://github.com/charliermarsh"><code>@​charliermarsh</code></a></li>
</ul>
<h2>Install ruff 0.15.22</h2>
<h3>Install prebuilt binaries via shell script</h3>
<pre lang="sh"><code>&lt;/tr&gt;&lt;/table&gt; 
</code></pre>
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md">ruff's
changelog</a>.</em></p>
<blockquote>
<h2>0.15.22</h2>
<p>Released on 2026-07-16.</p>
<h3>Preview features</h3>
<ul>
<li>[<code>pycodestyle</code>] Add an autofix for <code>E402</code> (<a
href="https://redirect.github.com/astral-sh/ruff/pull/22212">#22212</a>)</li>
<li>[<code>refurb</code>] Allow subclassing builtins in stub files
(<code>FURB189</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/26812">#26812</a>)</li>
<li>[<code>ruff</code>] Add rule to replace <code>noqa</code> comments
with <code>ruff:ignore</code> (<code>RUF105</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/26423">#26423</a>)</li>
<li>[<code>ruff</code>] Add rule to use human-readable names in
<code>ruff:ignore</code> comments (<code>RUF106</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/26682">#26682</a>)</li>
<li>[<code>ruff</code>] Add rule to use human-readable names in
configuration selectors (<code>RUF201</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/26772">#26772</a>)</li>
</ul>
<h3>Bug fixes</h3>
<ul>
<li>[<code>flake8-pyi</code>] Fix false positive in <code>__all__</code>
(<code>PYI053</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/26872">#26872</a>)</li>
</ul>
<h3>Rule changes</h3>
<ul>
<li>[<code>pylint</code>] Ignore mutable type updates in
<code>redefined-loop-name</code> (<code>PLW2901</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/25733">#25733</a>)</li>
</ul>
<h3>Performance</h3>
<ul>
<li>Avoid redundant lexer token bookkeeping (<a
href="https://redirect.github.com/astral-sh/ruff/pull/26765">#26765</a>)</li>
<li>Avoid redundant pending-indentation writes (<a
href="https://redirect.github.com/astral-sh/ruff/pull/26774">#26774</a>)</li>
<li>Avoid unnecessary identifier lookahead (<a
href="https://redirect.github.com/astral-sh/ruff/pull/26525">#26525</a>)</li>
<li>Reuse parser scratch buffers (<a
href="https://redirect.github.com/astral-sh/ruff/pull/26798">#26798</a>)</li>
</ul>
<h3>Documentation</h3>
<ul>
<li>Document argfile support (<a
href="https://redirect.github.com/astral-sh/ruff/pull/26803">#26803</a>)</li>
<li>[<code>flake8-datetimez</code>] Clarify naming guidance for
<code>datetime.today</code> (<code>DTZ002</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/26658">#26658</a>)</li>
<li>[<code>pycodestyle</code>] Document <code>E731</code> fix safety (<a
href="https://redirect.github.com/astral-sh/ruff/pull/26847">#26847</a>)</li>
<li>[<code>ruff</code>] Clarify intentional async contexts for
<code>unused-async</code> (<code>RUF029</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/26641">#26641</a>)</li>
</ul>
<h3>Contributors</h3>
<ul>
<li><a href="https://github.com/dwego"><code>@​dwego</code></a></li>
<li><a
href="https://github.com/MichaReiser"><code>@​MichaReiser</code></a></li>
<li><a href="https://github.com/Joosboy"><code>@​Joosboy</code></a></li>
<li><a
href="https://github.com/KaufmanDmitriy"><code>@​KaufmanDmitriy</code></a></li>
<li><a
href="https://github.com/PeterJCLaw"><code>@​PeterJCLaw</code></a></li>
<li><a href="https://github.com/ntBre"><code>@​ntBre</code></a></li>
<li><a
href="https://github.com/charliermarsh"><code>@​charliermarsh</code></a></li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/astral-sh/ruff/commit/0177a7e0d2c4a3805afa1960f106d72878766cbc"><code>0177a7e</code></a>
Bump 0.15.22 (<a
href="https://redirect.github.com/astral-sh/ruff/issues/26884">#26884</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/fe8ba85a55b76d838e6058d813b64468b5f81eca"><code>fe8ba85</code></a>
[<code>pycodestyle</code>] Document <code>E731</code> fix safety (<a
href="https://redirect.github.com/astral-sh/ruff/issues/26847">#26847</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/f3cf7c81fa35f8b819a719303bf1789714f1c780"><code>f3cf7c8</code></a>
[<code>ruff</code>] Add <code>rule-codes-in-selectors</code>
(<code>RUF201</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/issues/26772">#26772</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/d244fd75e9036e61be4896b823fe760d7aa632a3"><code>d244fd7</code></a>
Document argfile support (<a
href="https://redirect.github.com/astral-sh/ruff/issues/26803">#26803</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/f23975849a0db64a2851029c68fc054799d5e7da"><code>f239758</code></a>
[<code>flake8-pyi</code>] Fix false positive in <code>__all__</code>
(<code>PYI053</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/issues/26872">#26872</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/54acbcde56d76bc47b0b3dfe61e57fbaadd2c06b"><code>54acbcd</code></a>
[ty] Support <code>TypeVarTuple</code> and <code>Unpack</code> (<a
href="https://redirect.github.com/astral-sh/ruff/issues/25240">#25240</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/87fc38cacd7e2bd906674c6dc5b8be97aa3a5bc3"><code>87fc38c</code></a>
[ty] Parallelize subtype hierarchy search (<a
href="https://redirect.github.com/astral-sh/ruff/issues/26875">#26875</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/e454f91fa63bd745d874ead751ad15d9f521195a"><code>e454f91</code></a>
[ty] Share interned query keys across Salsa queries (<a
href="https://redirect.github.com/astral-sh/ruff/issues/26794">#26794</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/6f361a74a232ad283cc6e5e5d3a62109802aab65"><code>6f361a7</code></a>
[ty] only collect expected types for files open in the editor (<a
href="https://redirect.github.com/astral-sh/ruff/issues/25546">#25546</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/6d6b2b37686da8b1ee56e54fd99831f1f5fa0381"><code>6d6b2b3</code></a>
[ty] Avoid allocations during Salsa interned lookups (<a
href="https://redirect.github.com/astral-sh/ruff/issues/26877">#26877</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/astral-sh/ruff/compare/0.15.21...0.15.22">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-07-21 15:40:08 -04:00
dependabot[bot]andCaliBrain 28969ac828 Bump the npm-deps group across 1 directory with 7 updates (#1112)
Bumps the npm-deps group with 7 updates in the /src/frontend directory:

| Package | From | To |
| --- | --- | --- |
|
[@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node)
| `26.0.1` | `26.1.1` |
| [knip](https://github.com/webpro-nl/knip/tree/HEAD/packages/knip) |
`6.23.0` | `6.26.0` |
| [oxfmt](https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt) |
`0.57.0` | `0.58.0` |
| [oxlint](https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint) |
`1.72.0` | `1.73.0` |
| [oxlint-tsgolint](https://github.com/oxc-project/tsgolint) | `0.23.0`
| `0.24.0` |
| [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) |
`8.1.0` | `8.1.4` |
|
[vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest)
| `4.1.9` | `4.1.10` |


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

Updates `knip` from 6.23.0 to 6.26.0
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/webpro-nl/knip/releases">knip's
releases</a>.</em></p>
<blockquote>
<h2>Release 6.26.0</h2>
<ul>
<li>ci: add path filters (<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1871">#1871</a>)
(4249935adffe1b8eca9570fb325fa19cc8010584) - thanks <a
href="https://github.com/trueberryless"><code>@​trueberryless</code></a>!</li>
<li>Add CodeRabbit as gold sponsor
(1da09fdc8f4d851f3cad30659200018bfb47a5e0)</li>
<li>Fix up docs a bit more
(39125a7f473e006f61629781bff1ac4a050ca460)</li>
<li>Don't report ambient declaration files as unused
(aed361c00a82829c2fc80a65a2f7af174316782e)</li>
<li>Register oclif command files as entries
(3b4d58c9da83d93b6821a2b24f57b919ba886756)</li>
<li>Add electron-vite plugin
(d92107ea504833b3ee7ad2b695e22d40528b24c3)</li>
<li>Add esbuild plugin (ef3b601d957546554de2cb1fbff859f5bdafb741)</li>
<li>Ignore more globally available binaries
(829298129fdfd1b41a1f8dff5022b9fbbe25f454)</li>
<li>Resolve <code>#</code>-imports to source when node condition is
unbuilt (resolve <a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1873">#1873</a>)
(f2713ed1e499ab286b86615d4b8ec34f711d1bd2)</li>
<li>Ignore gh as a globally available binary
(a6f0772e5db03a8412a3ac036756779f22d1a27d)</li>
<li>Resolve Vitest benchmark files as entries
(57429139f7e93f2a4bee35a53d450c4f45dea9ff)</li>
<li>Extract shared Vue auto-import machinery into plugins/_vue
(73010753d432ae58eb12c7eec9c5723036009749)</li>
<li>Scope compiler extensions per workspace
(5e6f82b963a83e5801484efbc01a4b6a9ce39ea3)</li>
<li>Resolve auto-imported components in the Vue SFC compiler
(009aad8f9dab589898e5dba4c85fa41ea57c1a49)</li>
<li>Add plugins for the Vue auto-import ecosystem
(f638c8302cdd26a63e477344a7cf44ef60af7788)</li>
<li>Enable Vue SFC compiler on unplugin-vue and
<code>@​vitejs/plugin-vue</code>
(9396ab159919147ef81dad16e365e531b32b4b12)</li>
<li>Recognize vite-plugin-vue-meta-layouts (same layouts convention)
(3ceee89228b0173eddc0466bf50e8181286ffebc)</li>
<li>Add vite-plugin-pages and unplugin-icons plugins
(9bc17540bcf8eb142474ed84837c1e2cebb7fe73)</li>
<li>Add vite-plugin-pwa and <code>@​intlify/unplugin-vue-i18n</code>
plugins (45dea0a397ed754c34617ed18deef4dc6f0ec0d4)</li>
<li>Dog, food. (e1249ade2558d3801d85e2dc63349d884f3443d4)</li>
<li>Update query snapshot
(a45941d2db381c04f8a38416533217ee84f77ca7)</li>
<li>Don't misread Nitro route types as Vue component auto-imports
(43aecd5414eb63bec3637f37ffac0c62a71e7e74)</li>
<li>Read oxlint jsPlugins from vite-plus vite.config and .oxlintrc.json
(589ffda4e3a7d0c0d772191ad6f8d6e60dd298cf)</li>
<li>Add vite-plus plugin for run.tasks and staged scripts
(f041c191e4e10dd181ad212b71079e3e10bcf341)</li>
<li>Support <code>@​vite-pwa/nuxt</code> PWA config in nuxt.config
(8fa7b116690e37484798ad98eca002d26c0c80fe)</li>
<li>Add <code>@​vite-pwa/assets-generator</code> plugin
(4254f7d53a284a4ea4612f650ae03495bcb0d582)</li>
<li>Add <code>@​nuxtjs/i18n</code> plugin
(dfb9acbe5a08ebc85562f59a04e7f4bedb4925f1)</li>
<li>Read plugin entries from vite.config options and index.html
(d533da8cc2b431d206eb7c55323ea51846eca0bb)</li>
<li>Fix false positives in VitePress, next-mdx and unplugin-vue-i18n
plugins (b99702a76e26b1943a16517f1633de3a0713c196)</li>
<li>Respect optional peers in pnpm and Yarn packageExtensions
(aab080bbd436494316df045011ab22cbebca06b4)</li>
<li>Detect babel plugins in the Storybook config
(5dea975adf9b88971ad56434132282a099c7d58c)</li>
<li>Add shared AST helpers for imported calls and first property values
(c84bb7a87a17f1921c12bb02bcfd43f905cae595)</li>
<li>Inline trivial resolveFromAST wrappers and normalize orval and sst
(620079d1d07b69a69bbb67b6a6e68963b6c2fc21)</li>
<li>Extract inline resolveFromAST implementations to dedicated files
(b5231c12f915428067d790187d2dd4c44e2559d4)</li>
<li>Normalize resolveFromAST files to a uniform contract
(fc1ba0fb45aaf623de678a57bea3b5567ea04750)</li>
<li>chore: migrate to Astro 7 and Sätteri (<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1875">#1875</a>)
(f256a5b276c3d69dd5081c591bbb183aacebf766) - thanks <a
href="https://github.com/trueberryless"><code>@​trueberryless</code></a>!</li>
<li>Show contributor count on docs homepage
(6b8454a2841905ce7f08e1a23b54bc17a0854608)</li>
<li>Exclude gitignored package entry points
(606e5d051b4f0620c776aa23352153b8253bd75b)</li>
<li>Add Tauri plugin (199180da79c71acc80865dc65ce89653a26d9579)</li>
<li>Add Laravel plugin (197453378f2b60e5143a30ec78b438f02b37c53b)</li>
<li>Add Quasar plugin (a220729074bfbb9e7d03ba329e89ad553c3a7799)</li>
</ul>
<h2>Release 6.25.0</h2>
<ul>
<li>fix: derive package name from package.json under Yarn
<code>nodeLinker: pnpm</code> store (<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1852">#1852</a>)
(3764605ed150b975fb6c88c4c4aa47f5bf9ae149) - thanks <a
href="https://github.com/blowery"><code>@​blowery</code></a>!</li>
<li>Pick up types used in module augmentations via path aliases (resolve
<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1850">#1850</a>)
(9cc5a4132793990b5fcf9eae6f3db37426d979c0)</li>
<li>feat: add support for <code>packageExtensions</code> in yarn and
pnpm (<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1847">#1847</a>)
(61d3164cdac6e9252b3c52ce3bb2909949b4690e) - thanks <a
href="https://github.com/trueberryless"><code>@​trueberryless</code></a>!</li>
<li>fix(ci): do not run integration on forks (<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1855">#1855</a>)
(d23c10d1ae764c0b70addaafc79f01c543cc5efa) - thanks <a
href="https://github.com/trueberryless"><code>@​trueberryless</code></a>!</li>
<li>chore(ci): improve security through specific permissions (<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1856">#1856</a>)
(1eb42e639f9ec12d6d2ef88e1aaf07397b5bf226) - thanks <a
href="https://github.com/trueberryless"><code>@​trueberryless</code></a>!</li>
<li>chore(ci): add concurrency blocks (<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1857">#1857</a>)
(3bf3d115cf677fadcfe8fa72ce0e68cfd435d399) - thanks <a
href="https://github.com/trueberryless"><code>@​trueberryless</code></a>!</li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/webpro-nl/knip/commit/441faf08dfa1e87daa342620026947c99a98ec40"><code>441faf0</code></a>
Release knip@6.26.0</li>
<li><a
href="https://github.com/webpro-nl/knip/commit/a220729074bfbb9e7d03ba329e89ad553c3a7799"><code>a220729</code></a>
Add Quasar plugin</li>
<li><a
href="https://github.com/webpro-nl/knip/commit/197453378f2b60e5143a30ec78b438f02b37c53b"><code>1974533</code></a>
Add Laravel plugin</li>
<li><a
href="https://github.com/webpro-nl/knip/commit/199180da79c71acc80865dc65ce89653a26d9579"><code>199180d</code></a>
Add Tauri plugin</li>
<li><a
href="https://github.com/webpro-nl/knip/commit/606e5d051b4f0620c776aa23352153b8253bd75b"><code>606e5d0</code></a>
Exclude gitignored package entry points</li>
<li><a
href="https://github.com/webpro-nl/knip/commit/fc1ba0fb45aaf623de678a57bea3b5567ea04750"><code>fc1ba0f</code></a>
Normalize resolveFromAST files to a uniform contract</li>
<li><a
href="https://github.com/webpro-nl/knip/commit/b5231c12f915428067d790187d2dd4c44e2559d4"><code>b5231c1</code></a>
Extract inline resolveFromAST implementations to dedicated files</li>
<li><a
href="https://github.com/webpro-nl/knip/commit/620079d1d07b69a69bbb67b6a6e68963b6c2fc21"><code>620079d</code></a>
Inline trivial resolveFromAST wrappers and normalize orval and sst</li>
<li><a
href="https://github.com/webpro-nl/knip/commit/c84bb7a87a17f1921c12bb02bcfd43f905cae595"><code>c84bb7a</code></a>
Add shared AST helpers for imported calls and first property values</li>
<li><a
href="https://github.com/webpro-nl/knip/commit/5dea975adf9b88971ad56434132282a099c7d58c"><code>5dea975</code></a>
Detect babel plugins in the Storybook config</li>
<li>Additional commits viewable in <a
href="https://github.com/webpro-nl/knip/commits/knip@6.26.0/packages/knip">compare
view</a></li>
</ul>
</details>
<br />

Updates `oxfmt` from 0.57.0 to 0.58.0
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/oxc-project/oxc/commit/39677ba50d908ea09f6d9e58ded328461212f52a"><code>39677ba</code></a>
release(apps): oxlint v1.73.0 &amp;&amp; oxfmt v0.58.0 (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt/issues/24219">#24219</a>)</li>
<li>See full diff in <a
href="https://github.com/oxc-project/oxc/commits/oxfmt_v0.58.0/npm/oxfmt">compare
view</a></li>
</ul>
</details>
<br />

Updates `oxlint` from 1.72.0 to 1.73.0
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/oxc-project/oxc/releases">oxlint's
releases</a>.</em></p>
<blockquote>
<h2>oxlint v1.27.0 &amp;&amp; oxfmt v0.12.0</h2>
<h1>Oxlint v1.27.0</h1>
<h3>🚀 Features</h3>
<ul>
<li>222a8f0 linter/plugins: Implement
<code>SourceCode#isSpaceBetween</code> (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15498">#15498</a>)
(overlookmotel)</li>
<li>2f9735d linter/plugins: Implement
<code>context.languageOptions</code> (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15486">#15486</a>)
(overlookmotel)</li>
<li>bc731ff linter/plugins: Stub out all <code>Context</code> APIs (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15479">#15479</a>)
(overlookmotel)</li>
<li>5822cb4 linter/plugins: Add <code>extend</code> method to
<code>FILE_CONTEXT</code> (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15477">#15477</a>)
(overlookmotel)</li>
<li>7b1e6f3 apps: Add pure rust binaries and release to github (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15469">#15469</a>)
(Boshen)</li>
<li>2a89b43 linter: Introduce debug assertions after fixes to assert
validity (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15389">#15389</a>)
(camc314)</li>
<li>ad3c45a editor: Add <code>oxc.path.node</code> option (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15040">#15040</a>)
(Sysix)</li>
</ul>
<h3>🐛 Bug Fixes</h3>
<ul>
<li>6f3cd77 linter/no-var: Incorrect warning for blocks (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15504">#15504</a>)
(Hamir Mahal)</li>
<li>6957fb9 linter/plugins: Do not allow access to
<code>Context#id</code> in <code>createOnce</code> (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15489">#15489</a>)
(overlookmotel)</li>
<li>7409630 linter/plugins: Allow access to <code>cwd</code> in
<code>createOnce</code> in ESLint interop mode (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15488">#15488</a>)
(overlookmotel)</li>
<li>732205e parser: Reject <code>using</code> / <code>await using</code>
in a switch <code>case</code> / <code>default</code> clause (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15225">#15225</a>)
(sapphi-red)</li>
<li>a17ca32 linter/plugins: Replace <code>Context</code> class (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15448">#15448</a>)
(overlookmotel)</li>
<li>ecf2f7b language_server: Fail gracefully when tsgolint executable
not found (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15436">#15436</a>)
(camc314)</li>
<li>3c8d3a7 lang-server: Improve logging in failure case for tsgolint
(<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15299">#15299</a>)
(camc314)</li>
<li>ef71410 linter: Use jsx if source type is JS in fix debug assertion
(<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15434">#15434</a>)
(camc314)</li>
<li>e32bbf6 linter/no-var: Handle TypeScript declare keyword in fixer
(<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15426">#15426</a>)
(camc314)</li>
<li>6565dbe linter/switch-case-braces: Skip comments when searching for
<code>:</code> token (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15425">#15425</a>)
(camc314)</li>
<li>85bd19a linter/prefer-class-fields: Insert value after type
annotation in fixer (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15423">#15423</a>)
(camc314)</li>
<li>fde753e linter/plugins: Block access to
<code>context.settings</code> in <code>createOnce</code> (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15394">#15394</a>)
(overlookmotel)</li>
<li>ddd9f9f linter/forward-ref-uses-ref: Dont suggest removing wrapper
in invalid positions (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15388">#15388</a>)
(camc314)</li>
<li>dac2a9c linter/no-template-curly-in-string: Remove fixer (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15387">#15387</a>)
(camc314)</li>
<li>989b8e3 linter/no-var: Only fix to <code>const</code> if the var has
an initializer (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15385">#15385</a>)
(camc314)</li>
<li>cc403f5 linter/plugins: Return empty object for unimplemented
parserServices (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15364">#15364</a>)
(magic-akari)</li>
</ul>
<h3>⚡ Performance</h3>
<ul>
<li>25d577e language_server: Start tools in parallel (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15500">#15500</a>)
(Sysix)</li>
<li>3c57291 linter/plugins: Optimize loops (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15449">#15449</a>)
(overlookmotel)</li>
<li>3166233 linter/plugins: Remove <code>Arc</code>s (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15431">#15431</a>)
(overlookmotel)</li>
<li>9de1322 linter/plugins: Lazily deserialize settings JSON (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15395">#15395</a>)
(overlookmotel)</li>
<li>3049ec2 linter/plugins: Optimize <code>deepFreezeSettings</code> (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15392">#15392</a>)
(overlookmotel)</li>
<li>444ebfd linter/plugins: Use single object for
<code>parserServices</code> (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15378">#15378</a>)
(overlookmotel)</li>
</ul>
<h3>📚 Documentation</h3>
<ul>
<li>97d2104 linter: Update comment in lint.rs about default value for
tsconfig path (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15530">#15530</a>)
(Connor Shea)</li>
<li>2c6bd9e linter: Always refer as &quot;ES2015&quot; instead of
&quot;ES6&quot; (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15411">#15411</a>)
(sapphi-red)</li>
<li>a0c5203 linter/import/named: Update &quot;ES7&quot; comment in
examples (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15410">#15410</a>)
(sapphi-red)</li>
<li>3dc24b5 linter,minifier: Always refer as &quot;ES Modules&quot;
instead of &quot;ES6 Modules&quot; (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15409">#15409</a>)
(sapphi-red)</li>
<li>2ad77fb linter/no-this-before-super: Correct &quot;Why is this
bad?&quot; section (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15408">#15408</a>)
(sapphi-red)</li>
<li>57f0ce1 linter: Add backquotes where appropriate (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15407">#15407</a>)
(sapphi-red)</li>
</ul>
<h1>Oxfmt v0.12.0</h1>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/oxc-project/oxc/blob/main/npm/oxlint/CHANGELOG.md">oxlint's
changelog</a>.</em></p>
<blockquote>
<h2>[1.73.0] - 2026-07-06</h2>
<h3>🚀 Features</h3>
<ul>
<li>a2c97f3 linter/unicorn: Implement <code>explicit-timer-delay</code>
rule (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/23612">#23612</a>)
(Mikhail Baev)</li>
<li>85735cb linter/unicorn: Implement
<code>no-confusing-array-with</code> rule (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/23638">#23638</a>)
(Shekhu☺️)</li>
<li>cb4fbb9 linter/eslint: Implement no-unreachable-loop rule (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/23975">#23975</a>)
(Todor Andonov)</li>
<li>dc32112 linter/eslint/no-constant-binary-expression: Check
relational comparisons (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/24088">#24088</a>)
(camc314)</li>
<li>d963967 linter/unicorn/no-array-sort: Add
<code>allowAfterSpread</code> option (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/24043">#24043</a>)
(Boshen)</li>
<li>0a75682 linter: Add per-rule timings for type-aware linting (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/22488">#22488</a>)
(camchenry)</li>
<li>743e222 linter/react: Add <code>disallowedValues</code> option for
<code>forbid-dom-props</code> rule (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/23970">#23970</a>)
(Mikhail Baev)</li>
</ul>
<h3>🐛 Bug Fixes</h3>
<ul>
<li>bdb51c7 linter/jest/prefer-ending-with-an-expect: Validate config
patterns (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/24122">#24122</a>)
(camc314)</li>
<li>45d607d linter/react/forbid-component-props: Make allow/disallow
lists optional in schema (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/24024">#24024</a>)
(Boshen)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/oxc-project/oxc/commit/39677ba50d908ea09f6d9e58ded328461212f52a"><code>39677ba</code></a>
release(apps): oxlint v1.73.0 &amp;&amp; oxfmt v0.58.0 (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/24219">#24219</a>)</li>
<li><a
href="https://github.com/oxc-project/oxc/commit/a2c97f348c8bdabe99a08b2fbb45d32ffe49a529"><code>a2c97f3</code></a>
feat(linter/unicorn): implement <code>explicit-timer-delay</code> rule
(<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/23612">#23612</a>)</li>
<li><a
href="https://github.com/oxc-project/oxc/commit/bdb51c73da9edeef02ce39477167765d971fb5cb"><code>bdb51c7</code></a>
fix(linter/jest/prefer-ending-with-an-expect): validate config patterns
(<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/24122">#24122</a>)</li>
<li><a
href="https://github.com/oxc-project/oxc/commit/85735cb5df27daffd32651f808a382680ee92aaf"><code>85735cb</code></a>
feat(linter/unicorn): implement <code>no-confusing-array-with</code>
rule (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/23638">#23638</a>)</li>
<li><a
href="https://github.com/oxc-project/oxc/commit/cb4fbb927f237b0ba4937a8de5a7b28c444b4114"><code>cb4fbb9</code></a>
feat(linter/eslint): implement no-unreachable-loop rule (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/23975">#23975</a>)</li>
<li><a
href="https://github.com/oxc-project/oxc/commit/dc321127faa1e0f7bff293319717c52f9c8f6b95"><code>dc32112</code></a>
feat(linter/eslint/no-constant-binary-expression): check relational
compariso...</li>
<li><a
href="https://github.com/oxc-project/oxc/commit/d96396707923bcffa92e732b15bf23631561f35c"><code>d963967</code></a>
feat(linter/unicorn/no-array-sort): add <code>allowAfterSpread</code>
option (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/24043">#24043</a>)</li>
<li><a
href="https://github.com/oxc-project/oxc/commit/45d607d48529dc9f32f53436ee816fc02494c64b"><code>45d607d</code></a>
fix(linter/react/forbid-component-props): make allow/disallow lists
optional ...</li>
<li><a
href="https://github.com/oxc-project/oxc/commit/0a75682fcb38014878d53f37075f32e12101e265"><code>0a75682</code></a>
feat(linter): add per-rule timings for type-aware linting (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/22488">#22488</a>)</li>
<li><a
href="https://github.com/oxc-project/oxc/commit/743e22293bc5317c79b6b083ffbb2bd5ab5ebefc"><code>743e222</code></a>
feat(linter/react): add <code>disallowedValues</code> option for
<code>forbid-dom-props</code> rule...</li>
<li>See full diff in <a
href="https://github.com/oxc-project/oxc/commits/oxlint_v1.73.0/npm/oxlint">compare
view</a></li>
</ul>
</details>
<br />

Updates `oxlint-tsgolint` from 0.23.0 to 0.24.0
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/oxc-project/tsgolint/releases">oxlint-tsgolint's
releases</a>.</em></p>
<blockquote>
<h2>v0.24.0</h2>
<h2>What's Changed</h2>
<ul>
<li>ci: run windows e2e pnpm commands outside workspace by <a
href="https://github.com/camc314"><code>@​camc314</code></a> in <a
href="https://redirect.github.com/oxc-project/tsgolint/pull/969">oxc-project/tsgolint#969</a></li>
<li>test(no-deprecated): add test for only <code>allow</code> by <a
href="https://github.com/camc314"><code>@​camc314</code></a> in <a
href="https://redirect.github.com/oxc-project/tsgolint/pull/970">oxc-project/tsgolint#970</a></li>
<li>fix(no-deprecated): report contextual object literal properties by
<a href="https://github.com/camc314"><code>@​camc314</code></a> in <a
href="https://redirect.github.com/oxc-project/tsgolint/pull/944">oxc-project/tsgolint#944</a></li>
<li>Update README with PRs note on new rules by <a
href="https://github.com/camchenry"><code>@​camchenry</code></a> in <a
href="https://redirect.github.com/oxc-project/tsgolint/pull/972">oxc-project/tsgolint#972</a></li>
<li>fix(strict-boolean-expressions): guard invalid predicate indexes by
<a href="https://github.com/camchenry"><code>@​camchenry</code></a> in
<a
href="https://redirect.github.com/oxc-project/tsgolint/pull/971">oxc-project/tsgolint#971</a></li>
<li>fix(no-deprecated): fix crash when getting prop name for non-literal
by <a href="https://github.com/camchenry"><code>@​camchenry</code></a>
in <a
href="https://redirect.github.com/oxc-project/tsgolint/pull/973">oxc-project/tsgolint#973</a></li>
<li>test(no-deprecated): add non-literal computed key regression by <a
href="https://github.com/camc314"><code>@​camc314</code></a> in <a
href="https://redirect.github.com/oxc-project/tsgolint/pull/974">oxc-project/tsgolint#974</a></li>
<li>test(no-floating-promises): sync tests with typescript-eslint by <a
href="https://github.com/camc314"><code>@​camc314</code></a> in <a
href="https://redirect.github.com/oxc-project/tsgolint/pull/975">oxc-project/tsgolint#975</a></li>
<li>fix(no-floating-promises): handle spread arguments before rejection
handlers by <a
href="https://github.com/camc314"><code>@​camc314</code></a> in <a
href="https://redirect.github.com/oxc-project/tsgolint/pull/976">oxc-project/tsgolint#976</a></li>
<li>chore(deps): update crate-ci/typos action to v1.46.2 by <a
href="https://github.com/renovate"><code>@​renovate</code></a>[bot] in
<a
href="https://redirect.github.com/oxc-project/tsgolint/pull/979">oxc-project/tsgolint#979</a></li>
<li>refactor: remove unneeded <code>nil</code> checks in
<code>strict-boolean-expressions</code> by <a
href="https://github.com/camchenry"><code>@​camchenry</code></a> in <a
href="https://redirect.github.com/oxc-project/tsgolint/pull/980">oxc-project/tsgolint#980</a></li>
<li>perf(no-unsafe-assignment): add fast-path for sender type by <a
href="https://github.com/camchenry"><code>@​camchenry</code></a> in <a
href="https://redirect.github.com/oxc-project/tsgolint/pull/981">oxc-project/tsgolint#981</a></li>
<li>feat: add per-rule debug timings by <a
href="https://github.com/camchenry"><code>@​camchenry</code></a> in <a
href="https://redirect.github.com/oxc-project/tsgolint/pull/960">oxc-project/tsgolint#960</a></li>
<li>chore: update typescript-go submodule by <a
href="https://github.com/camc314"><code>@​camc314</code></a> in <a
href="https://redirect.github.com/oxc-project/tsgolint/pull/982">oxc-project/tsgolint#982</a></li>
<li>chore(deps): update dprint plugins by <a
href="https://github.com/renovate"><code>@​renovate</code></a>[bot] in
<a
href="https://redirect.github.com/oxc-project/tsgolint/pull/984">oxc-project/tsgolint#984</a></li>
<li>chore(deps): update github actions by <a
href="https://github.com/renovate"><code>@​renovate</code></a>[bot] in
<a
href="https://redirect.github.com/oxc-project/tsgolint/pull/985">oxc-project/tsgolint#985</a></li>
<li>chore(deps): update gomod by <a
href="https://github.com/renovate"><code>@​renovate</code></a>[bot] in
<a
href="https://redirect.github.com/oxc-project/tsgolint/pull/986">oxc-project/tsgolint#986</a></li>
<li>chore(deps): update crate-ci/typos action to v1.46.3 by <a
href="https://github.com/renovate"><code>@​renovate</code></a>[bot] in
<a
href="https://redirect.github.com/oxc-project/tsgolint/pull/988">oxc-project/tsgolint#988</a></li>
<li>test(no-deprecated): cover call and jsx deprecated properties by <a
href="https://github.com/camc314"><code>@​camc314</code></a> in <a
href="https://redirect.github.com/oxc-project/tsgolint/pull/989">oxc-project/tsgolint#989</a></li>
<li>chore: update typescript-go submodule by <a
href="https://github.com/camc314"><code>@​camc314</code></a> in <a
href="https://redirect.github.com/oxc-project/tsgolint/pull/991">oxc-project/tsgolint#991</a></li>
<li>chore(deps): update github actions by <a
href="https://github.com/renovate"><code>@​renovate</code></a>[bot] in
<a
href="https://redirect.github.com/oxc-project/tsgolint/pull/993">oxc-project/tsgolint#993</a></li>
<li>chore(deps): update crate-ci/typos action to v1.47.0 by <a
href="https://github.com/renovate"><code>@​renovate</code></a>[bot] in
<a
href="https://redirect.github.com/oxc-project/tsgolint/pull/995">oxc-project/tsgolint#995</a></li>
<li>chore(deps): update module github.com/dlclark/regexp2/v2 to v2.1.1
by <a
href="https://github.com/renovate"><code>@​renovate</code></a>[bot] in
<a
href="https://redirect.github.com/oxc-project/tsgolint/pull/994">oxc-project/tsgolint#994</a></li>
<li>fix(prefer-optional-chain): suggest unsafe nullish rewrite by <a
href="https://github.com/camc314"><code>@​camc314</code></a> in <a
href="https://redirect.github.com/oxc-project/tsgolint/pull/996">oxc-project/tsgolint#996</a></li>
<li>chore(deps): update pnpm to v11.4.0 by <a
href="https://github.com/renovate"><code>@​renovate</code></a>[bot] in
<a
href="https://redirect.github.com/oxc-project/tsgolint/pull/997">oxc-project/tsgolint#997</a></li>
<li>refactor: cache suppress program diagnostics env by <a
href="https://github.com/camc314"><code>@​camc314</code></a> in <a
href="https://redirect.github.com/oxc-project/tsgolint/pull/999">oxc-project/tsgolint#999</a></li>
<li>refactor: use <code>sync.OnceValue</code> for logLevel by <a
href="https://github.com/camc314"><code>@​camc314</code></a> in <a
href="https://redirect.github.com/oxc-project/tsgolint/pull/1000">oxc-project/tsgolint#1000</a></li>
<li>test(no-unnecessary-type-assertion): sync tests with
typescript-eslint by <a
href="https://github.com/camc314"><code>@​camc314</code></a> in <a
href="https://redirect.github.com/oxc-project/tsgolint/pull/998">oxc-project/tsgolint#998</a></li>
<li>fix(no-unnecessary-type-assertion): enable skipped upstream cases by
<a href="https://github.com/camc314"><code>@​camc314</code></a> in <a
href="https://redirect.github.com/oxc-project/tsgolint/pull/1001">oxc-project/tsgolint#1001</a></li>
<li>fix(justfile): use --ignore-workspace for e2e pnpm commands by <a
href="https://github.com/swandir"><code>@​swandir</code></a> in <a
href="https://redirect.github.com/oxc-project/tsgolint/pull/983">oxc-project/tsgolint#983</a></li>
<li>test(no-unnecessary-type-arguments): sync tests with
typescript-eslint by <a
href="https://github.com/camc314"><code>@​camc314</code></a> in <a
href="https://redirect.github.com/oxc-project/tsgolint/pull/1003">oxc-project/tsgolint#1003</a></li>
<li>test(no-unnecessary-type-assertion): cover nullable literal
assertion by <a
href="https://github.com/camc314"><code>@​camc314</code></a> in <a
href="https://redirect.github.com/oxc-project/tsgolint/pull/1002">oxc-project/tsgolint#1002</a></li>
<li>fix(no-unnecessary-type-arguments): preserve constructor inference
by <a href="https://github.com/camc314"><code>@​camc314</code></a> in <a
href="https://redirect.github.com/oxc-project/tsgolint/pull/1004">oxc-project/tsgolint#1004</a></li>
<li>refactor: remove <code>OXLINT_TSGOLINT_TIMINGS</code> env var by <a
href="https://github.com/camc314"><code>@​camc314</code></a> in <a
href="https://redirect.github.com/oxc-project/tsgolint/pull/1005">oxc-project/tsgolint#1005</a></li>
<li>chore: update typescript-go submodule by <a
href="https://github.com/camc314"><code>@​camc314</code></a> in <a
href="https://redirect.github.com/oxc-project/tsgolint/pull/1007">oxc-project/tsgolint#1007</a></li>
<li>fix(prefer-optional-chain): optional chain type assertion handling
by <a href="https://github.com/camc314"><code>@​camc314</code></a> in <a
href="https://redirect.github.com/oxc-project/tsgolint/pull/1009">oxc-project/tsgolint#1009</a></li>
<li>chore(deps): update typos to v1.47.1 by <a
href="https://github.com/renovate"><code>@​renovate</code></a>[bot] in
<a
href="https://redirect.github.com/oxc-project/tsgolint/pull/1010">oxc-project/tsgolint#1010</a></li>
<li>chore(deps): update typos to v1.47.2 by <a
href="https://github.com/renovate"><code>@​renovate</code></a>[bot] in
<a
href="https://redirect.github.com/oxc-project/tsgolint/pull/1012">oxc-project/tsgolint#1012</a></li>
<li>chore(deps): update github actions by <a
href="https://github.com/renovate"><code>@​renovate</code></a>[bot] in
<a
href="https://redirect.github.com/oxc-project/tsgolint/pull/1013">oxc-project/tsgolint#1013</a></li>
<li>chore(deps): update gomod by <a
href="https://github.com/renovate"><code>@​renovate</code></a>[bot] in
<a
href="https://redirect.github.com/oxc-project/tsgolint/pull/1014">oxc-project/tsgolint#1014</a></li>
<li>chore(deps): update github actions to v2.81.5 by <a
href="https://github.com/renovate"><code>@​renovate</code></a>[bot] in
<a
href="https://redirect.github.com/oxc-project/tsgolint/pull/1015">oxc-project/tsgolint#1015</a></li>
<li>chore: update typescript-go submodule by <a
href="https://github.com/camc314"><code>@​camc314</code></a> in <a
href="https://redirect.github.com/oxc-project/tsgolint/pull/1016">oxc-project/tsgolint#1016</a></li>
<li>chore(deps): update github actions by <a
href="https://github.com/renovate"><code>@​renovate</code></a>[bot] in
<a
href="https://redirect.github.com/oxc-project/tsgolint/pull/1017">oxc-project/tsgolint#1017</a></li>
<li>chore(deps): update gomod to v0.46.0 by <a
href="https://github.com/renovate"><code>@​renovate</code></a>[bot] in
<a
href="https://redirect.github.com/oxc-project/tsgolint/pull/1018">oxc-project/tsgolint#1018</a></li>
<li>chore(deps): update gomod to v2.2.2 by <a
href="https://github.com/renovate"><code>@​renovate</code></a>[bot] in
<a
href="https://redirect.github.com/oxc-project/tsgolint/pull/1021">oxc-project/tsgolint#1021</a></li>
<li>chore(deps): update github actions to v2.82.0 by <a
href="https://github.com/renovate"><code>@​renovate</code></a>[bot] in
<a
href="https://redirect.github.com/oxc-project/tsgolint/pull/1023">oxc-project/tsgolint#1023</a></li>
<li>chore(deps): update dprint plugins to v0.22.0 by <a
href="https://github.com/renovate"><code>@​renovate</code></a>[bot] in
<a
href="https://redirect.github.com/oxc-project/tsgolint/pull/1022">oxc-project/tsgolint#1022</a></li>
<li>chore(deps): update github actions to v7 by <a
href="https://github.com/renovate"><code>@​renovate</code></a>[bot] in
<a
href="https://redirect.github.com/oxc-project/tsgolint/pull/1024">oxc-project/tsgolint#1024</a></li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/oxc-project/tsgolint/commit/5a37e8902f65440900be1436b814919fcdb4e3d4"><code>5a37e89</code></a>
fix(dot-notation): determine the relevant accessor (<a
href="https://redirect.github.com/oxc-project/tsgolint/issues/1028">#1028</a>)</li>
<li><a
href="https://github.com/oxc-project/tsgolint/commit/67a281f613312a30665e7d39dfb47f5c15b0ca60"><code>67a281f</code></a>
perf(consistent-return): defer per-function type resolution (<a
href="https://redirect.github.com/oxc-project/tsgolint/issues/1031">#1031</a>)</li>
<li><a
href="https://github.com/oxc-project/tsgolint/commit/a5e2ff0be1102d9082e13ec35f1f0e4ca7fa65ad"><code>a5e2ff0</code></a>
perf(no-unnecessary-qualifier): skip symbol resolution outside
namespaces. (#...</li>
<li><a
href="https://github.com/oxc-project/tsgolint/commit/a8fc6685df0a817e7c5045a4e3e141d342839959"><code>a8fc668</code></a>
perf(no-confusing-void-expression): check ancestor position before type
query...</li>
<li><a
href="https://github.com/oxc-project/tsgolint/commit/03158cc4c0a0ef2c8bdccab71e6d2723fcc1c4f2"><code>03158cc</code></a>
perf(no-unnecessary-type-conversion): hoist constant builtin-name slices
(<a
href="https://redirect.github.com/oxc-project/tsgolint/issues/1040">#1040</a>)</li>
<li><a
href="https://github.com/oxc-project/tsgolint/commit/d9e645c266b6c4b4671b0a3dc5d3935dbda3a5ef"><code>d9e645c</code></a>
perf(prefer-optional-chain): lazily allocate chain-processor caches (<a
href="https://redirect.github.com/oxc-project/tsgolint/issues/1041">#1041</a>)</li>
<li><a
href="https://github.com/oxc-project/tsgolint/commit/63f578abe093d7a7e8cb523a1e5e96aacf06b220"><code>63f578a</code></a>
refactor(no-unnecessary-condition): remove dead
containsUnguardedElementAcces...</li>
<li><a
href="https://github.com/oxc-project/tsgolint/commit/f174876a906e0350aff2b75d5607775529c9f845"><code>f174876</code></a>
chore(deps): update gomod (<a
href="https://redirect.github.com/oxc-project/tsgolint/issues/1035">#1035</a>)</li>
<li><a
href="https://github.com/oxc-project/tsgolint/commit/47de9cfea53da0ae274bc78d47388e3bdd5b7341"><code>47de9cf</code></a>
chore(deps): update github actions (<a
href="https://redirect.github.com/oxc-project/tsgolint/issues/1036">#1036</a>)</li>
<li><a
href="https://github.com/oxc-project/tsgolint/commit/e209b5b27f5fd22810735bfbfdb0785cbc5d4215"><code>e209b5b</code></a>
chore(deps): update actions/cache action to v6 (<a
href="https://redirect.github.com/oxc-project/tsgolint/issues/1037">#1037</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/oxc-project/tsgolint/compare/v0.23.0...v0.24.0">compare
view</a></li>
</ul>
</details>
<br />

Updates `vite` from 8.1.0 to 8.1.4
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/vitejs/vite/releases">vite's
releases</a>.</em></p>
<blockquote>
<h2>v8.1.4</h2>
<p>Please refer to <a
href="https://github.com/vitejs/vite/blob/v8.1.4/packages/vite/CHANGELOG.md">CHANGELOG.md</a>
for details.</p>
<h2>v8.1.3</h2>
<p>Please refer to <a
href="https://github.com/vitejs/vite/blob/v8.1.3/packages/vite/CHANGELOG.md">CHANGELOG.md</a>
for details.</p>
<h2>v8.1.2</h2>
<p>Please refer to <a
href="https://github.com/vitejs/vite/blob/v8.1.2/packages/vite/CHANGELOG.md">CHANGELOG.md</a>
for details.</p>
<h2>v8.1.1</h2>
<p>Please refer to <a
href="https://github.com/vitejs/vite/blob/v8.1.1/packages/vite/CHANGELOG.md">CHANGELOG.md</a>
for details.</p>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md">vite's
changelog</a>.</em></p>
<blockquote>
<h2><!-- raw HTML omitted --><a
href="https://github.com/vitejs/vite/compare/v8.1.3...v8.1.4">8.1.4</a>
(2026-07-09)<!-- raw HTML omitted --></h2>
<h3>Features</h3>
<ul>
<li><strong>legacy:</strong> prefer oxc as minifier (fix <a
href="https://redirect.github.com/vitejs/vite/issues/21973">#21973</a>)
(<a
href="https://redirect.github.com/vitejs/vite/issues/22468">#22468</a>)
(<a
href="https://github.com/vitejs/vite/commit/ab5dafa8e66296ef201f615489fb57954bb740ce">ab5dafa</a>)</li>
</ul>
<h3>Bug Fixes</h3>
<ul>
<li><strong>build:</strong> add workaround for building on stackblitz
(<a
href="https://redirect.github.com/vitejs/vite/issues/22840">#22840</a>)
(<a
href="https://github.com/vitejs/vite/commit/575c32c29925c554f4ef4068738ab89c6878f615">575c32c</a>)</li>
<li><strong>build:</strong> keep <code>import.meta.url</code> in preload
function as-is (<a
href="https://redirect.github.com/vitejs/vite/issues/22839">#22839</a>)
(<a
href="https://github.com/vitejs/vite/commit/f1f90ed4742b3cf453428c7e581a6016a4d47321">f1f90ed</a>)</li>
<li><strong>deps:</strong> update all non-major dependencies (<a
href="https://redirect.github.com/vitejs/vite/issues/22865">#22865</a>)
(<a
href="https://github.com/vitejs/vite/commit/d4295a9ffce428c0e51892373e00c07fccc0498a">d4295a9</a>)</li>
<li><strong>deps:</strong> update rolldown-related dependencies (<a
href="https://redirect.github.com/vitejs/vite/issues/22866">#22866</a>)
(<a
href="https://github.com/vitejs/vite/commit/7cf07e4c5f7a6af276012e0c2ec06e08499e951e">7cf07e4</a>)</li>
<li><strong>html:</strong> avoid backtracking in import-only check (<a
href="https://redirect.github.com/vitejs/vite/issues/22848">#22848</a>)
(<a
href="https://github.com/vitejs/vite/commit/b5868c01a124d345664450aaebd677bfba964c05">b5868c0</a>)</li>
<li><strong>optimizer:</strong> avoid optimizer run for transform
request before init (<a
href="https://redirect.github.com/vitejs/vite/issues/22852">#22852</a>)
(<a
href="https://github.com/vitejs/vite/commit/72a5e2192506f51a5efd09c059815a5c43a9eacb">72a5e21</a>)</li>
<li><strong>ssr:</strong> align named export function call stacktrace
column with Node (<a
href="https://redirect.github.com/vitejs/vite/issues/22829">#22829</a>)
(<a
href="https://github.com/vitejs/vite/commit/173a1b648c321e0f836e5a94fc47c4fa9b081bfc">173a1b6</a>)</li>
<li>strip pure CSS chunk imports when chunkImportMap is enabled (<a
href="https://redirect.github.com/vitejs/vite/issues/22841">#22841</a>)
(<a
href="https://github.com/vitejs/vite/commit/648bd04933093d0aac9565f21a49811437776886">648bd04</a>)</li>
</ul>
<h3>Documentation</h3>
<ul>
<li>fix incorrect <code>@default</code> for <code>server.cors</code> (<a
href="https://redirect.github.com/vitejs/vite/issues/22859">#22859</a>)
(<a
href="https://github.com/vitejs/vite/commit/70435b2551ee4fe3a0d55c8a3bb61b96f44d2763">70435b2</a>)</li>
</ul>
<h3>Miscellaneous Chores</h3>
<ul>
<li><strong>deps:</strong> update dependency postcss-modules to v9 (<a
href="https://redirect.github.com/vitejs/vite/issues/22867">#22867</a>)
(<a
href="https://github.com/vitejs/vite/commit/a9539d69efc7a4ccf988bbf5da31c2b416ba990e">a9539d6</a>)</li>
</ul>
<h3>Code Refactoring</h3>
<ul>
<li>eliminate ineffectiveDynamicImport warn (<a
href="https://redirect.github.com/vitejs/vite/issues/22876">#22876</a>)
(<a
href="https://github.com/vitejs/vite/commit/ea22fb352aad9a42c0a9d08f39d8a0bae8c111a5">ea22fb3</a>)</li>
</ul>
<h3>Tests</h3>
<ul>
<li>avoid warnings (<a
href="https://redirect.github.com/vitejs/vite/issues/22851">#22851</a>)
(<a
href="https://github.com/vitejs/vite/commit/af21ab68adac3380dc9a854d2fe3f776654301cd">af21ab6</a>)</li>
</ul>
<h3>Build System</h3>
<ul>
<li>remove the custom onLog function (<a
href="https://redirect.github.com/vitejs/vite/issues/22878">#22878</a>)
(<a
href="https://github.com/vitejs/vite/commit/2c4a217a63dc0a7fcb10bc710a988f32b173f481">2c4a217</a>)</li>
<li>replace deprecated <code>onwarn</code> with <code>onLog</code> (<a
href="https://redirect.github.com/vitejs/vite/issues/22741">#22741</a>)
(<a
href="https://github.com/vitejs/vite/commit/c581b5588cb8e94603d17bce2ff9fec8e8e0a3bf">c581b55</a>)</li>
</ul>
<h2><!-- raw HTML omitted --><a
href="https://github.com/vitejs/vite/compare/v8.1.2...v8.1.3">8.1.3</a>
(2026-07-02)<!-- raw HTML omitted --></h2>
<h3>Bug Fixes</h3>
<ul>
<li><strong>css:</strong> inject inlined CSS after the shebang line (<a
href="https://redirect.github.com/vitejs/vite/issues/22717">#22717</a>)
(<a
href="https://github.com/vitejs/vite/commit/1534d362794e856cc718b123cb245e0efcf36b9a">1534d36</a>)</li>
<li><strong>deps:</strong> bump <code>es-module-lexer</code> to 2.3.0
(<a
href="https://redirect.github.com/vitejs/vite/issues/22838">#22838</a>)
(<a
href="https://github.com/vitejs/vite/commit/7103c3af795396e2ad87e1125eb3fc20d60d9430">7103c3a</a>)</li>
<li>preload css for nested dynamic imports (<a
href="https://redirect.github.com/vitejs/vite/issues/22759">#22759</a>)
(<a
href="https://github.com/vitejs/vite/commit/2c530542da1c50b95b7f0cb17adf82f64ecf97f3">2c53054</a>)</li>
<li><strong>ssr:</strong> correct stacktrace column position for first
line (<a
href="https://redirect.github.com/vitejs/vite/issues/22828">#22828</a>)
(<a
href="https://github.com/vitejs/vite/commit/c4acd6982a051fa10dbd7e3a37e00f0c467793b8">c4acd69</a>)</li>
</ul>
<h2><!-- raw HTML omitted --><a
href="https://github.com/vitejs/vite/compare/v8.1.1...v8.1.2">8.1.2</a>
(2026-06-30)<!-- raw HTML omitted --></h2>
<h3>Bug Fixes</h3>
<ul>
<li><strong>deps:</strong> revert es-module-lexer to 2.1.0 (<a
href="https://redirect.github.com/vitejs/vite/issues/22827">#22827</a>)
(<a
href="https://github.com/vitejs/vite/commit/0d3bd7c4111b8345cfc353c7bbb2c30601752e26">0d3bd7c</a>)</li>
<li>restore, &quot;fix: resolve pnpm .modules.yaml from workspace root
instead of cwd (<a
href="https://redirect.github.com/vitejs/vite/issues/22757">#22757</a>)&quot;
(<a
href="https://redirect.github.com/vitejs/vite/issues/22825">#22825</a>)
(<a
href="https://github.com/vitejs/vite/commit/efb98cce73f3f89c4f03936ab7741cd5b0636f5c">efb98cc</a>)</li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/vitejs/vite/commit/a477454442eff649b430f9e3c6caf2500fcb7183"><code>a477454</code></a>
release: v8.1.4</li>
<li><a
href="https://github.com/vitejs/vite/commit/ab5dafa8e66296ef201f615489fb57954bb740ce"><code>ab5dafa</code></a>
feat(legacy): prefer oxc as minifier (fix <a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/21973">#21973</a>)
(<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/22468">#22468</a>)</li>
<li><a
href="https://github.com/vitejs/vite/commit/173a1b648c321e0f836e5a94fc47c4fa9b081bfc"><code>173a1b6</code></a>
fix(ssr): align named export function call stacktrace column with Node
(<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/22829">#22829</a>)</li>
<li><a
href="https://github.com/vitejs/vite/commit/575c32c29925c554f4ef4068738ab89c6878f615"><code>575c32c</code></a>
fix(build): add workaround for building on stackblitz (<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/22840">#22840</a>)</li>
<li><a
href="https://github.com/vitejs/vite/commit/72a5e2192506f51a5efd09c059815a5c43a9eacb"><code>72a5e21</code></a>
fix(optimizer): avoid optimizer run for transform request before init
(<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/22852">#22852</a>)</li>
<li><a
href="https://github.com/vitejs/vite/commit/a9539d69efc7a4ccf988bbf5da31c2b416ba990e"><code>a9539d6</code></a>
chore(deps): update dependency postcss-modules to v9 (<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/22867">#22867</a>)</li>
<li><a
href="https://github.com/vitejs/vite/commit/70435b2551ee4fe3a0d55c8a3bb61b96f44d2763"><code>70435b2</code></a>
docs: fix incorrect <code>@default</code> for <code>server.cors</code>
(<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/22859">#22859</a>)</li>
<li><a
href="https://github.com/vitejs/vite/commit/2c4a217a63dc0a7fcb10bc710a988f32b173f481"><code>2c4a217</code></a>
build: remove the custom onLog function (<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/22878">#22878</a>)</li>
<li><a
href="https://github.com/vitejs/vite/commit/c581b5588cb8e94603d17bce2ff9fec8e8e0a3bf"><code>c581b55</code></a>
build: replace deprecated <code>onwarn</code> with <code>onLog</code>
(<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/22741">#22741</a>)</li>
<li><a
href="https://github.com/vitejs/vite/commit/ea22fb352aad9a42c0a9d08f39d8a0bae8c111a5"><code>ea22fb3</code></a>
refactor: eliminate ineffectiveDynamicImport warn (<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/22876">#22876</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/vitejs/vite/commits/v8.1.4/packages/vite">compare
view</a></li>
</ul>
</details>
<br />

Updates `vitest` from 4.1.9 to 4.1.10
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/vitest-dev/vitest/releases">vitest's
releases</a>.</em></p>
<blockquote>
<h2>v4.1.10</h2>
<h3>   🐞 Bug Fixes</h3>
<ul>
<li><strong>browser</strong>: Check fs access in builtin commands
[backport to v4]  -  by <a
href="https://github.com/hi-ogawa"><code>@​hi-ogawa</code></a>,
<strong>Hiroshi Ogawa</strong> and <strong>OpenCode
(claude-opus-4-8)</strong> in <a
href="https://redirect.github.com/vitest-dev/vitest/issues/10680">vitest-dev/vitest#10680</a>
<a href="https://github.com/vitest-dev/vitest/commit/5c18dd267"><!-- raw
HTML omitted -->(5c18d)<!-- raw HTML omitted --></a></li>
<li><strong>vm</strong>: Fix external module resolve error with deps
optimizer query for encoded URI [backport to v4]  -  by <a
href="https://github.com/SveLil"><code>@​SveLil</code></a> and <a
href="https://github.com/hi-ogawa"><code>@​hi-ogawa</code></a> in <a
href="https://redirect.github.com/vitest-dev/vitest/issues/10661">vitest-dev/vitest#10661</a>
<a href="https://github.com/vitest-dev/vitest/commit/bae52b511"><!-- raw
HTML omitted -->(bae52)<!-- raw HTML omitted --></a></li>
</ul>
<h5>    <a
href="https://github.com/vitest-dev/vitest/compare/v4.1.9...v4.1.10">View
changes on GitHub</a></h5>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/vitest-dev/vitest/commit/db616d227b6e0cb07a94f5d1bba262ee95db7e46"><code>db616d2</code></a>
chore: release v4.1.10 (<a
href="https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest/issues/10718">#10718</a>)</li>
<li><a
href="https://github.com/vitest-dev/vitest/commit/bae52b5112a6fd8200101b88bf8af9685d077295"><code>bae52b5</code></a>
fix(vm): fix external module resolve error with deps optimizer query for
enco...</li>
<li>See full diff in <a
href="https://github.com/vitest-dev/vitest/commits/v4.1.10/packages/vitest">compare
view</a></li>
</ul>
</details>
<br />

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: CaliBrain <calibrain@l4n.xyz>
2026-07-19 03:29:15 -04:00
dependabot[bot] 2f5d3dde7d Bump seleniumbase from 4.50.6 to 4.51.0 in the python-deps group (#1118)
Bumps the python-deps group with 1 update:
[seleniumbase](https://github.com/seleniumbase/SeleniumBase).

Updates `seleniumbase` from 4.50.6 to 4.51.0
<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.51.0 - CDP Mode: Patch 119</h2>
<h2>CDP Mode: Patch 119</h2>
<ul>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/5ca71c54fa8e22004591788a7d76aa77add3fccf">Update
CDP Mode</a>
--&gt; New methods for logging from CDP Mode</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/a1a0cdf6476505a5fb10190196ced67487749879">Don't
ignore the cache by default on reload()</a></li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/97b6bf3fef6d3cda8835a1af730c31e06ee27505">Update
the docs</a></li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/775a91bc1843508b9655af396585de376b558e7b">Update
examples</a></li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/6f38a2469aa9122eeb188ea683f5b39a55753ee5">Refresh
Python dependencies</a></li>
</ul>
<h2>What's Changed</h2>
<ul>
<li>CDP Mode: Patch 119 by <a
href="https://github.com/mdmintz"><code>@​mdmintz</code></a> in <a
href="https://redirect.github.com/seleniumbase/SeleniumBase/pull/4422">seleniumbase/SeleniumBase#4422</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/seleniumbase/SeleniumBase/compare/v4.50.6...v4.51.0">https://github.com/seleniumbase/SeleniumBase/compare/v4.50.6...v4.51.0</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/36627158b4a395a3793dd97e762e282dc97e2a54"><code>3662715</code></a>
Merge pull request <a
href="https://redirect.github.com/seleniumbase/SeleniumBase/issues/4422">#4422</a>
from seleniumbase/cdp-mode-patch-119</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/78abd3effed503e5abfa64fd5697a556229dfe02"><code>78abd3e</code></a>
Version 4.51.0</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/6f38a2469aa9122eeb188ea683f5b39a55753ee5"><code>6f38a24</code></a>
Refresh Python dependencies</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/775a91bc1843508b9655af396585de376b558e7b"><code>775a91b</code></a>
Update examples</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/97b6bf3fef6d3cda8835a1af730c31e06ee27505"><code>97b6bf3</code></a>
Update the docs</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/a1a0cdf6476505a5fb10190196ced67487749879"><code>a1a0cdf</code></a>
Don't ignore the cache by default on reload()</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/5ca71c54fa8e22004591788a7d76aa77add3fccf"><code>5ca71c5</code></a>
Update CDP Mode</li>
<li>See full diff in <a
href="https://github.com/seleniumbase/SeleniumBase/compare/v4.50.6...v4.51.0">compare
view</a></li>
</ul>
</details>
<br />


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

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

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

---

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

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


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-19 02:52:13 -04:00
dependabot[bot] 2d949d4e7c Bump python from b877e50 to d3400aa (#1119)
Bumps python from `b877e50` to `d3400aa`.


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

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

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

---

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

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


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-19 02:52:03 -04:00
dependabot[bot] 100e0dd9f8 Bump the gh-actions group across 1 directory with 10 updates (#1120)
Bumps the gh-actions group with 10 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [docker/login-action](https://github.com/docker/login-action) |
`4.2.0` | `4.4.0` |
| [docker/metadata-action](https://github.com/docker/metadata-action) |
`6.1.0` | `6.2.0` |
|
[docker/setup-buildx-action](https://github.com/docker/setup-buildx-action)
| `4.1.0` | `4.2.0` |
|
[docker/build-push-action](https://github.com/docker/build-push-action)
| `7.2.0` | `7.3.0` |
| [astral-sh/setup-uv](https://github.com/astral-sh/setup-uv) | `8.2.0`
| `8.3.2` |
| [actions/setup-node](https://github.com/actions/setup-node) | `6.4.0`
| `7.0.0` |
| [github/codeql-action/init](https://github.com/github/codeql-action) |
`4.36.2` | `4.37.0` |
|
[github/codeql-action/autobuild](https://github.com/github/codeql-action)
| `4.36.2` | `4.37.0` |
|
[github/codeql-action/analyze](https://github.com/github/codeql-action)
| `4.36.2` | `4.37.0` |
| [dorny/paths-filter](https://github.com/dorny/paths-filter) | `4.0.1`
| `4.0.2` |


Updates `docker/login-action` from 4.2.0 to 4.4.0
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/docker/login-action/releases">docker/login-action's
releases</a>.</em></p>
<blockquote>
<h2>v4.4.0</h2>
<ul>
<li>Skip empty <code>registry-auth</code> secret mask by <a
href="https://github.com/crazy-max"><code>@​crazy-max</code></a> in <a
href="https://redirect.github.com/docker/login-action/pull/1035">docker/login-action#1035</a></li>
<li>Bump <code>@​aws-sdk/client-ecr</code> and
<code>@​aws-sdk/client-ecr-public</code> to 3.1077.0 <a
href="https://redirect.github.com/docker/login-action/pull/1034">docker/login-action#1034</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/docker/login-action/compare/v4.3.0...v4.4.0">https://github.com/docker/login-action/compare/v4.3.0...v4.4.0</a></p>
<h2>v4.3.0</h2>
<ul>
<li>Preserve names in esbuild bundle by <a
href="https://github.com/crazy-max"><code>@​crazy-max</code></a> in <a
href="https://redirect.github.com/docker/login-action/pull/1022">docker/login-action#1022</a></li>
<li>Bump <code>@​aws-sdk/client-ecr</code> and
<code>@​aws-sdk/client-ecr-public</code> to 3.1076.0 <a
href="https://redirect.github.com/docker/login-action/pull/999">docker/login-action#999</a>
<a
href="https://redirect.github.com/docker/login-action/pull/1030">docker/login-action#1030</a></li>
<li>Bump <code>@​docker/actions-toolkit</code> from 0.90.0 to 0.92.0 in
<a
href="https://redirect.github.com/docker/login-action/pull/1004">docker/login-action#1004</a>
<a
href="https://redirect.github.com/docker/login-action/pull/1027">docker/login-action#1027</a></li>
<li>Bump <code>@​sigstore/core</code> from 3.1.0 to 3.2.1 in <a
href="https://redirect.github.com/docker/login-action/pull/1023">docker/login-action#1023</a></li>
<li>Bump <code>@​sigstore/verify</code> from 3.1.0 to 3.1.1 in <a
href="https://redirect.github.com/docker/login-action/pull/1029">docker/login-action#1029</a></li>
<li>Bump http-proxy-agent and https-proxy-agent to 9.1.0 in <a
href="https://redirect.github.com/docker/login-action/pull/1017">docker/login-action#1017</a></li>
<li>Bump js-yaml from 4.1.1 to 5.2.0 in <a
href="https://redirect.github.com/docker/login-action/pull/1028">docker/login-action#1028</a></li>
<li>Bump sigstore from 4.1.0 to 4.1.1 in <a
href="https://redirect.github.com/docker/login-action/pull/1031">docker/login-action#1031</a></li>
<li>Bump tmp from 0.2.5 to 0.2.7 in <a
href="https://redirect.github.com/docker/login-action/pull/1002">docker/login-action#1002</a></li>
<li>Bump undici from 6.24.1 to 6.27.0 in <a
href="https://redirect.github.com/docker/login-action/pull/1020">docker/login-action#1020</a></li>
<li>Bump vite from 7.3.3 to 7.3.6 in <a
href="https://redirect.github.com/docker/login-action/pull/1019">docker/login-action#1019</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/docker/login-action/compare/v4.2.0...v4.3.0">https://github.com/docker/login-action/compare/v4.2.0...v4.3.0</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/docker/login-action/commit/af1e73f918a031802d376d3c8bbc3fe56130a9b0"><code>af1e73f</code></a>
Merge pull request <a
href="https://redirect.github.com/docker/login-action/issues/1034">#1034</a>
from docker/dependabot/npm_and_yarn/aws-sdk-dependen...</li>
<li><a
href="https://github.com/docker/login-action/commit/da722bde43bacb027adfc67d42dbaa4c0f9e550b"><code>da722bd</code></a>
[dependabot skip] chore: update generated content</li>
<li><a
href="https://github.com/docker/login-action/commit/2916ad60bd5cb72f07aa54c69fdcc61749c09b7a"><code>2916ad6</code></a>
build(deps): bump the aws-sdk-dependencies group across 1 directory with
2 up...</li>
<li><a
href="https://github.com/docker/login-action/commit/ca0a662f786e4cfddce972005bd68f3dafc3a903"><code>ca0a662</code></a>
Merge pull request <a
href="https://redirect.github.com/docker/login-action/issues/1035">#1035</a>
from crazy-max/fix-registry-auth-empty-mask</li>
<li><a
href="https://github.com/docker/login-action/commit/c455755a579833bf0d2e4e54e3beb413ef10cc80"><code>c455755</code></a>
chore: update generated content</li>
<li><a
href="https://github.com/docker/login-action/commit/48351901f89581a7c12870c787d3f06d1f498438"><code>4835190</code></a>
skip empty registry-auth secret mask</li>
<li><a
href="https://github.com/docker/login-action/commit/992421c6e6806a7f6df609d1bfff374f9eca3004"><code>992421c</code></a>
Merge pull request <a
href="https://redirect.github.com/docker/login-action/issues/1033">#1033</a>
from docker/dependabot/github_actions/docker/bake-ac...</li>
<li><a
href="https://github.com/docker/login-action/commit/b249b43765525dd7951068267a34cf63f22ab4f0"><code>b249b43</code></a>
Merge pull request <a
href="https://redirect.github.com/docker/login-action/issues/1032">#1032</a>
from docker/dependabot/github_actions/docker/bake-ac...</li>
<li><a
href="https://github.com/docker/login-action/commit/1b67977736863551a88ff218642a2d7628b10520"><code>1b67977</code></a>
build(deps): bump docker/bake-action from 7.2.0 to 7.3.0</li>
<li><a
href="https://github.com/docker/login-action/commit/9d49d6a3234c78daa10c3c12183ef7b6caa8e69e"><code>9d49d6a</code></a>
build(deps): bump docker/bake-action/subaction/matrix</li>
<li>Additional commits viewable in <a
href="https://github.com/docker/login-action/compare/650006c6eb7dba73a995cc03b0b2d7f5ca915bee...af1e73f918a031802d376d3c8bbc3fe56130a9b0">compare
view</a></li>
</ul>
</details>
<br />

Updates `docker/metadata-action` from 6.1.0 to 6.2.0
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/docker/metadata-action/releases">docker/metadata-action's
releases</a>.</em></p>
<blockquote>
<h2>v6.2.0</h2>
<ul>
<li>Preserve names in esbuild bundle by <a
href="https://github.com/crazy-max"><code>@​crazy-max</code></a> in <a
href="https://redirect.github.com/docker/metadata-action/pull/689">docker/metadata-action#689</a></li>
<li>Bump <code>@​actions/core</code> from 3.0.0 to 3.0.1 in <a
href="https://redirect.github.com/docker/metadata-action/pull/663">docker/metadata-action#663</a></li>
<li>Bump <code>@​actions/github</code> from 9.0.0 to 9.1.1 in <a
href="https://redirect.github.com/docker/metadata-action/pull/666">docker/metadata-action#666</a></li>
<li>Bump <code>@​docker/actions-toolkit</code> from 0.90.0 to 0.92.0 in
<a
href="https://redirect.github.com/docker/metadata-action/pull/672">docker/metadata-action#672</a>
<a
href="https://redirect.github.com/docker/metadata-action/pull/696">docker/metadata-action#696</a></li>
<li>Bump <code>@​sigstore/core</code> from 3.1.0 to 3.2.1 in <a
href="https://redirect.github.com/docker/metadata-action/pull/690">docker/metadata-action#690</a></li>
<li>Bump <code>@​sigstore/verify</code> from 3.1.0 to 3.1.1 in <a
href="https://redirect.github.com/docker/metadata-action/pull/693">docker/metadata-action#693</a></li>
<li>Bump csv-parse from 6.2.1 to 7.0.0 in <a
href="https://redirect.github.com/docker/metadata-action/pull/683">docker/metadata-action#683</a></li>
<li>Bump js-yaml from 4.1.1 to 4.3.0 in <a
href="https://redirect.github.com/docker/metadata-action/pull/688">docker/metadata-action#688</a></li>
<li>Bump moment-timezone from 0.6.1 to 0.6.2 in <a
href="https://redirect.github.com/docker/metadata-action/pull/664">docker/metadata-action#664</a></li>
<li>Bump semver from 7.7.4 to 7.8.5 in <a
href="https://redirect.github.com/docker/metadata-action/pull/665">docker/metadata-action#665</a>
<a
href="https://redirect.github.com/docker/metadata-action/pull/695">docker/metadata-action#695</a></li>
<li>Bump sigstore from 4.1.0 to 4.1.1 in <a
href="https://redirect.github.com/docker/metadata-action/pull/694">docker/metadata-action#694</a></li>
<li>Bump tmp from 0.2.5 to 0.2.7 in <a
href="https://redirect.github.com/docker/metadata-action/pull/673">docker/metadata-action#673</a></li>
<li>Bump undici from 6.25.0 to 6.27.0 in <a
href="https://redirect.github.com/docker/metadata-action/pull/686">docker/metadata-action#686</a></li>
<li>Bump vite from 7.3.2 to 7.3.6 in <a
href="https://redirect.github.com/docker/metadata-action/pull/685">docker/metadata-action#685</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/docker/metadata-action/compare/v6.1.0...v6.2.0">https://github.com/docker/metadata-action/compare/v6.1.0...v6.2.0</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/docker/metadata-action/commit/dc802804100637a589fabce1cb79ff13a1411302"><code>dc80280</code></a>
Merge pull request <a
href="https://redirect.github.com/docker/metadata-action/issues/696">#696</a>
from docker/dependabot/npm_and_yarn/docker/actions-to...</li>
<li><a
href="https://github.com/docker/metadata-action/commit/2b9fe830efc58cb7b0fab7c94300b1afa01864db"><code>2b9fe83</code></a>
[dependabot skip] chore: update generated content</li>
<li><a
href="https://github.com/docker/metadata-action/commit/8128ce30ab370fd360c8423698dcc077f2db8087"><code>8128ce3</code></a>
chore(deps): Bump <code>@​docker/actions-toolkit</code> from 0.91.0 to
0.92.0</li>
<li><a
href="https://github.com/docker/metadata-action/commit/1d1c89551edb081628e818368680beffb2bbccee"><code>1d1c895</code></a>
Merge pull request <a
href="https://redirect.github.com/docker/metadata-action/issues/695">#695</a>
from docker/dependabot/npm_and_yarn/semver-7.8.5</li>
<li><a
href="https://github.com/docker/metadata-action/commit/7f0c2dd4c83ea6258f78bf2fc6a02908417518ad"><code>7f0c2dd</code></a>
Merge pull request <a
href="https://redirect.github.com/docker/metadata-action/issues/694">#694</a>
from docker/dependabot/npm_and_yarn/sigstore-4.1.1</li>
<li><a
href="https://github.com/docker/metadata-action/commit/025f8c5c8167edab853571a387d8e80ccc1e91ed"><code>025f8c5</code></a>
[dependabot skip] chore: update generated content</li>
<li><a
href="https://github.com/docker/metadata-action/commit/e98d63c3081990566e174100a3afaa97aefbbcf3"><code>e98d63c</code></a>
chore(deps): Bump semver from 7.8.1 to 7.8.5</li>
<li><a
href="https://github.com/docker/metadata-action/commit/37d93799507b94cf1c1885efd7278d52a0fb806a"><code>37d9379</code></a>
chore(deps): Bump sigstore from 4.1.0 to 4.1.1</li>
<li><a
href="https://github.com/docker/metadata-action/commit/a1b80728476a956a25c5eeaff91c4dc46e7ac1bc"><code>a1b8072</code></a>
Merge pull request <a
href="https://redirect.github.com/docker/metadata-action/issues/690">#690</a>
from docker/dependabot/npm_and_yarn/sigstore/core-3.2.1</li>
<li><a
href="https://github.com/docker/metadata-action/commit/e0e338106d6ae0da5b44537049a34620b3559a5c"><code>e0e3381</code></a>
[dependabot skip] chore: update generated content</li>
<li>Additional commits viewable in <a
href="https://github.com/docker/metadata-action/compare/80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9...dc802804100637a589fabce1cb79ff13a1411302">compare
view</a></li>
</ul>
</details>
<br />

Updates `docker/setup-buildx-action` from 4.1.0 to 4.2.0
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/docker/setup-buildx-action/releases">docker/setup-buildx-action's
releases</a>.</em></p>
<blockquote>
<h2>v4.2.0</h2>
<ul>
<li>Preserve names in esbuild bundle by <a
href="https://github.com/crazy-max"><code>@​crazy-max</code></a> in <a
href="https://redirect.github.com/docker/setup-buildx-action/pull/572">docker/setup-buildx-action#572</a></li>
<li>Bump <code>@​actions/core</code> from 3.0.0 to 3.0.1 in <a
href="https://redirect.github.com/docker/setup-buildx-action/pull/551">docker/setup-buildx-action#551</a></li>
<li>Bump <code>@​docker/actions-toolkit</code> from 0.90.0 to 0.92.0 in
<a
href="https://redirect.github.com/docker/setup-buildx-action/pull/557">docker/setup-buildx-action#557</a>
<a
href="https://redirect.github.com/docker/setup-buildx-action/pull/580">docker/setup-buildx-action#580</a></li>
<li>Bump <code>@​sigstore/core</code> from 3.1.0 to 3.2.1 in <a
href="https://redirect.github.com/docker/setup-buildx-action/pull/573">docker/setup-buildx-action#573</a></li>
<li>Bump <code>@​sigstore/verify</code> from 3.1.0 to 3.1.1 in <a
href="https://redirect.github.com/docker/setup-buildx-action/pull/576">docker/setup-buildx-action#576</a></li>
<li>Bump js-yaml from 4.1.1 to 5.2.0 in <a
href="https://redirect.github.com/docker/setup-buildx-action/pull/562">docker/setup-buildx-action#562</a></li>
<li>Bump sigstore from 4.1.0 to 4.1.1 in <a
href="https://redirect.github.com/docker/setup-buildx-action/pull/577">docker/setup-buildx-action#577</a></li>
<li>Bump tmp from 0.2.5 to 0.2.7 in <a
href="https://redirect.github.com/docker/setup-buildx-action/pull/556">docker/setup-buildx-action#556</a></li>
<li>Bump undici from 6.25.0 to 6.27.0 in <a
href="https://redirect.github.com/docker/setup-buildx-action/pull/570">docker/setup-buildx-action#570</a></li>
<li>Bump vite from 7.3.2 to 7.3.6 in <a
href="https://redirect.github.com/docker/setup-buildx-action/pull/569">docker/setup-buildx-action#569</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/docker/setup-buildx-action/compare/v4.1.0...v4.2.0">https://github.com/docker/setup-buildx-action/compare/v4.1.0...v4.2.0</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/docker/setup-buildx-action/commit/bb05f3f5519dd87d3ba754cc423b652a5edd6d2c"><code>bb05f3f</code></a>
Merge pull request <a
href="https://redirect.github.com/docker/setup-buildx-action/issues/580">#580</a>
from docker/dependabot/npm_and_yarn/docker/actions-to...</li>
<li><a
href="https://github.com/docker/setup-buildx-action/commit/321c814cb51fbe4af8eca00249525cc0973ea66f"><code>321c814</code></a>
[dependabot skip] chore: update generated content</li>
<li><a
href="https://github.com/docker/setup-buildx-action/commit/b9a36ef79ba42cfc611885a1e8c388fbf8b8cb3f"><code>b9a36ef</code></a>
build(deps): bump <code>@​docker/actions-toolkit</code> from 0.91.0 to
0.92.0</li>
<li><a
href="https://github.com/docker/setup-buildx-action/commit/ebeab241289497cd564ac98b3cfc9e64607bb276"><code>ebeab24</code></a>
Merge pull request <a
href="https://redirect.github.com/docker/setup-buildx-action/issues/570">#570</a>
from docker/dependabot/npm_and_yarn/undici-6.27.0</li>
<li><a
href="https://github.com/docker/setup-buildx-action/commit/5c7b8ae78cec97a3215d4d86679b1d072eaa80cb"><code>5c7b8ae</code></a>
[dependabot skip] chore: update generated content</li>
<li><a
href="https://github.com/docker/setup-buildx-action/commit/037e618cd98e95e81525b15ff0e9c96f507e6a0e"><code>037e618</code></a>
build(deps): bump undici from 6.25.0 to 6.27.0</li>
<li><a
href="https://github.com/docker/setup-buildx-action/commit/66080e5802281ec2e72b7f3108915643e702db85"><code>66080e5</code></a>
Merge pull request <a
href="https://redirect.github.com/docker/setup-buildx-action/issues/577">#577</a>
from docker/dependabot/npm_and_yarn/sigstore-4.1.1</li>
<li><a
href="https://github.com/docker/setup-buildx-action/commit/409aef0aa3f48f0a742e7dec4e0e04ab19afe93c"><code>409aef0</code></a>
Merge pull request <a
href="https://redirect.github.com/docker/setup-buildx-action/issues/562">#562</a>
from docker/dependabot/npm_and_yarn/js-yaml-4.2.0</li>
<li><a
href="https://github.com/docker/setup-buildx-action/commit/49c6e42949280fa0d70fb327633591be54efbfb6"><code>49c6e42</code></a>
build(deps): bump sigstore from 4.1.0 to 4.1.1</li>
<li><a
href="https://github.com/docker/setup-buildx-action/commit/2211273e8121ecf9ecb7d6c7c0fcd55526d530c7"><code>2211273</code></a>
[dependabot skip] chore: update generated content</li>
<li>Additional commits viewable in <a
href="https://github.com/docker/setup-buildx-action/compare/d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5...bb05f3f5519dd87d3ba754cc423b652a5edd6d2c">compare
view</a></li>
</ul>
</details>
<br />

Updates `docker/build-push-action` from 7.2.0 to 7.3.0
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/docker/build-push-action/releases">docker/build-push-action's
releases</a>.</em></p>
<blockquote>
<h2>v7.3.0</h2>
<ul>
<li>Preserve names in esbuild bundle by <a
href="https://github.com/crazy-max"><code>@​crazy-max</code></a> in <a
href="https://redirect.github.com/docker/build-push-action/pull/1567">docker/build-push-action#1567</a></li>
<li>Bump <code>@​docker/actions-toolkit</code> from 0.90.0 to 0.92.0 in
<a
href="https://redirect.github.com/docker/build-push-action/pull/1545">docker/build-push-action#1545</a>
<a
href="https://redirect.github.com/docker/build-push-action/pull/1572">docker/build-push-action#1572</a></li>
<li>Bump <code>@​sigstore/core</code> from 3.1.0 to 3.2.1 in <a
href="https://redirect.github.com/docker/build-push-action/pull/1568">docker/build-push-action#1568</a></li>
<li>Bump js-yaml from 4.1.1 to 4.3.0 in <a
href="https://redirect.github.com/docker/build-push-action/pull/1566">docker/build-push-action#1566</a></li>
<li>Bump tmp from 0.2.5 to 0.2.7 in <a
href="https://redirect.github.com/docker/build-push-action/pull/1547">docker/build-push-action#1547</a></li>
<li>Bump undici from 6.24.1 to 6.27.0 in <a
href="https://redirect.github.com/docker/build-push-action/pull/1564">docker/build-push-action#1564</a></li>
<li>Bump vite from 7.3.2 to 7.3.6 in <a
href="https://redirect.github.com/docker/build-push-action/pull/1563">docker/build-push-action#1563</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/docker/build-push-action/compare/v7.2.0...v7.3.0">https://github.com/docker/build-push-action/compare/v7.2.0...v7.3.0</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/docker/build-push-action/commit/53b7df96c91f9c12dcc8a07bcb9ccacbed38856a"><code>53b7df9</code></a>
Merge pull request <a
href="https://redirect.github.com/docker/build-push-action/issues/1572">#1572</a>
from docker/dependabot/npm_and_yarn/docker/actions-t...</li>
<li><a
href="https://github.com/docker/build-push-action/commit/154298c1ca89be1c0e019084f0611ddca621aafc"><code>154298c</code></a>
[dependabot skip] chore: update generated content</li>
<li><a
href="https://github.com/docker/build-push-action/commit/cb1238b9c9eb453d106b4e4142a5bd9cde710040"><code>cb1238b</code></a>
chore(deps): Bump <code>@​docker/actions-toolkit</code> from 0.91.0 to
0.92.0</li>
<li><a
href="https://github.com/docker/build-push-action/commit/24f845d5cbe75d2d350a984fd0e18cb7a3f29c1c"><code>24f845d</code></a>
Merge pull request <a
href="https://redirect.github.com/docker/build-push-action/issues/1566">#1566</a>
from docker/dependabot/npm_and_yarn/js-yaml-4.2.0</li>
<li><a
href="https://github.com/docker/build-push-action/commit/9c6973007b52c322651c38915d5e8824cea95c50"><code>9c69730</code></a>
[dependabot skip] chore: update generated content</li>
<li><a
href="https://github.com/docker/build-push-action/commit/bc3a3a5f72a6dca16c2c2468d1dfc55ee66d2193"><code>bc3a3a5</code></a>
Merge pull request <a
href="https://redirect.github.com/docker/build-push-action/issues/1574">#1574</a>
from docker/dependabot/github_actions/aws-actions/co...</li>
<li><a
href="https://github.com/docker/build-push-action/commit/a82c504a2387bb8bedc50072f9c554ae2a7dab5d"><code>a82c504</code></a>
chore(deps): Bump js-yaml from 4.1.1 to 4.3.0</li>
<li><a
href="https://github.com/docker/build-push-action/commit/0285a75190c039d6dac52b7711abcef3f5d8f6f6"><code>0285a75</code></a>
Merge pull request <a
href="https://redirect.github.com/docker/build-push-action/issues/1573">#1573</a>
from docker/dependabot/github_actions/actions/cache-...</li>
<li><a
href="https://github.com/docker/build-push-action/commit/c6ad2a3f9644680619de938b97c8a10a87b2a88d"><code>c6ad2a3</code></a>
Merge pull request <a
href="https://redirect.github.com/docker/build-push-action/issues/1575">#1575</a>
from docker/dependabot/github_actions/actions/checko...</li>
<li><a
href="https://github.com/docker/build-push-action/commit/d37484fb9737c5442a257e2f0ae5a8d756ed7d92"><code>d37484f</code></a>
Merge pull request <a
href="https://redirect.github.com/docker/build-push-action/issues/1564">#1564</a>
from docker/dependabot/npm_and_yarn/undici-6.27.0</li>
<li>Additional commits viewable in <a
href="https://github.com/docker/build-push-action/compare/f9f3042f7e2789586610d6e8b85c8f03e5195baf...53b7df96c91f9c12dcc8a07bcb9ccacbed38856a">compare
view</a></li>
</ul>
</details>
<br />

Updates `astral-sh/setup-uv` from 8.2.0 to 8.3.2
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/astral-sh/setup-uv/commit/11f9893b081a58869d3b5fccaea48c9e9e46f990"><code>11f9893</code></a>
chore: roll up Dependabot updates (<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/948">#948</a>)</li>
<li><a
href="https://github.com/astral-sh/setup-uv/commit/f79855603231e1609d02bec6956bd0e05cbc46b5"><code>f798556</code></a>
docs: update version references to v8.3.1 (<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/946">#946</a>)</li>
<li><a
href="https://github.com/astral-sh/setup-uv/commit/e80544d808267c93733c3fd1e2c8c65e0c8707d6"><code>e80544d</code></a>
chore: update known checksums for 0.11.28 (<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/947">#947</a>)</li>
<li><a
href="https://github.com/astral-sh/setup-uv/commit/f98e06938123ccabd21905ea5d0069192241f9f1"><code>f98e069</code></a>
Change update-docs PR labels from 'update-docs' to 'documentation' (<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/945">#945</a>)</li>
<li><a
href="https://github.com/astral-sh/setup-uv/commit/cd462639a967553a16241af35461402a96978d48"><code>cd46263</code></a>
chore: update known checksums for 0.11.27 (<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/944">#944</a>)</li>
<li><a
href="https://github.com/astral-sh/setup-uv/commit/11245c7e122cd1c2297e8115d1e43fe1570f6270"><code>11245c7</code></a>
docs: update version references to v8.3.0 (<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/939">#939</a>)</li>
<li><a
href="https://github.com/astral-sh/setup-uv/commit/d31148d669074a8d0a63714ba94f3201e7020bc3"><code>d31148d</code></a>
Strip environment markers from detected uv dependency pins (<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/938">#938</a>)</li>
<li><a
href="https://github.com/astral-sh/setup-uv/commit/17c398959b4611a88929fabb5c563a8e43a0ff60"><code>17c3989</code></a>
Fix cache keys for Python version ranges (<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/937">#937</a>)</li>
<li><a
href="https://github.com/astral-sh/setup-uv/commit/3cc3c11fdf511cab39136b7c946d973d4ad0df20"><code>3cc3c11</code></a>
chore(deps): roll up Dependabot updates (<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/936">#936</a>)</li>
<li><a
href="https://github.com/astral-sh/setup-uv/commit/9225f843d7a9f80a757cf25ef48901fda69ba4bc"><code>9225f84</code></a>
chore(deps): bump release-drafter/release-drafter from 7.3.1 to 7.4.0
(<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/924">#924</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/astral-sh/setup-uv/compare/fac544c07dec837d0ccb6301d7b5580bf5edae39...11f9893b081a58869d3b5fccaea48c9e9e46f990">compare
view</a></li>
</ul>
</details>
<br />

Updates `actions/setup-node` from 6.4.0 to 7.0.0
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/actions/setup-node/releases">actions/setup-node's
releases</a>.</em></p>
<blockquote>
<h2>v7.0.0</h2>
<h2>What's Changed</h2>
<h3>Enhancements:</h3>
<ul>
<li>Add cache-primary-key and cache-matched-key as outputs by <a
href="https://github.com/gowridurgad"><code>@​gowridurgad</code></a> in
<a
href="https://redirect.github.com/actions/setup-node/pull/1577">actions/setup-node#1577</a></li>
<li>Migrate to ESM and upgrade dependencies by <a
href="https://github.com/gowridurgad"><code>@​gowridurgad</code></a> in
<a
href="https://redirect.github.com/actions/setup-node/pull/1574">actions/setup-node#1574</a></li>
</ul>
<h3>Bug fixes:</h3>
<ul>
<li>Remove dummy NODE_AUTH_TOKEN export by <a
href="https://github.com/gowridurgad"><code>@​gowridurgad</code></a> in
<a
href="https://redirect.github.com/actions/setup-node/pull/1558">actions/setup-node#1558</a></li>
<li>Only use <code>mirrorToken</code> in <code>getManifest</code> if
it's provided by <a
href="https://github.com/deiga"><code>@​deiga</code></a> in <a
href="https://redirect.github.com/actions/setup-node/pull/1548">actions/setup-node#1548</a></li>
</ul>
<h3>Documentation updates:</h3>
<ul>
<li>Add documentation for publishing to npm with Trusted Publisher
(OIDC) by <a
href="https://github.com/chiranjib-swain"><code>@​chiranjib-swain</code></a>
in <a
href="https://redirect.github.com/actions/setup-node/pull/1536">actions/setup-node#1536</a></li>
<li>docs: Update restore-only cache documentation by <a
href="https://github.com/priya-kinthali"><code>@​priya-kinthali</code></a>
in <a
href="https://redirect.github.com/actions/setup-node/pull/1550">actions/setup-node#1550</a></li>
<li>docs: Update caching recommendations to mitigate cache poisoning
risks by <a
href="https://github.com/chiranjib-swain"><code>@​chiranjib-swain</code></a>
in <a
href="https://redirect.github.com/actions/setup-node/pull/1567">actions/setup-node#1567</a></li>
</ul>
<h3>Dependency update:</h3>
<ul>
<li>Upgrade <code>@​actions/cache</code> to 5.1.0, log cache write
denied by <a
href="https://github.com/jasongin"><code>@​jasongin</code></a> in <a
href="https://redirect.github.com/actions/setup-node/pull/1569">actions/setup-node#1569</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a
href="https://github.com/chiranjib-swain"><code>@​chiranjib-swain</code></a>
made their first contribution in <a
href="https://redirect.github.com/actions/setup-node/pull/1536">actions/setup-node#1536</a></li>
<li><a href="https://github.com/deiga"><code>@​deiga</code></a> made
their first contribution in <a
href="https://redirect.github.com/actions/setup-node/pull/1548">actions/setup-node#1548</a></li>
<li><a href="https://github.com/jasongin"><code>@​jasongin</code></a>
made their first contribution in <a
href="https://redirect.github.com/actions/setup-node/pull/1569">actions/setup-node#1569</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/actions/setup-node/compare/v6...v7.0.0">https://github.com/actions/setup-node/compare/v6...v7.0.0</a></p>
<h2>v6.5.0</h2>
<h2>What's Changed</h2>
<ul>
<li>Update <code>@​actions/cache</code> to 5.1.0 and add security
overrides for undici and fast-xml-parser by <a
href="https://github.com/HarithaVattikuti"><code>@​HarithaVattikuti</code></a>
in <a
href="https://redirect.github.com/actions/setup-node/pull/1579">actions/setup-node#1579</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/actions/setup-node/compare/v6.4.0...v6.5.0">https://github.com/actions/setup-node/compare/v6.4.0...v6.5.0</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/actions/setup-node/commit/820762786026740c76f36085b0efc47a31fe5020"><code>8207627</code></a>
Migrate to ESM and upgrade dependencies (<a
href="https://redirect.github.com/actions/setup-node/issues/1574">#1574</a>)</li>
<li><a
href="https://github.com/actions/setup-node/commit/04be95cf3511ea51ebf9f224ddfb99cc7ab87cd4"><code>04be95c</code></a>
Add cache-primary-key and cache-matched-key as outputs (<a
href="https://redirect.github.com/actions/setup-node/issues/1577">#1577</a>)</li>
<li><a
href="https://github.com/actions/setup-node/commit/7c2c68d20d402ed6a201ada70a81341941093140"><code>7c2c68d</code></a>
docs: Update caching recommendations to mitigate cache poisoning risks
(<a
href="https://redirect.github.com/actions/setup-node/issues/1567">#1567</a>)</li>
<li><a
href="https://github.com/actions/setup-node/commit/6a61c0375d66246de94630495909f12cf8dac84d"><code>6a61c03</code></a>
Merge pull request <a
href="https://redirect.github.com/actions/setup-node/issues/1569">#1569</a>
from jasongin/update-actions-cache-5.1.0</li>
<li><a
href="https://github.com/actions/setup-node/commit/30eb73b41ded577900c1ebf968ef95cdf8f7434f"><code>30eb73b</code></a>
Resolve high-severity audit issues</li>
<li><a
href="https://github.com/actions/setup-node/commit/4e1a87a501d0302f99e30e2748568adcb388d09f"><code>4e1a87a</code></a>
Update dist</li>
<li><a
href="https://github.com/actions/setup-node/commit/360237f0c01778d0c17291f75c56d6feae4f7574"><code>360237f</code></a>
Strict equality</li>
<li><a
href="https://github.com/actions/setup-node/commit/4f8aac5beb2f0854bc79651567a18c67eb0b9de3"><code>4f8aac5</code></a>
Bump <code>@​actions/cache</code> to 5.1.0, log cache write denied</li>
<li><a
href="https://github.com/actions/setup-node/commit/f4a67bbeca970f103397d3d2b9462cf787cd2980"><code>f4a67bb</code></a>
Only use <code>mirrorToken</code> in <code>getManifest</code> if it's
provided (<a
href="https://redirect.github.com/actions/setup-node/issues/1548">#1548</a>)</li>
<li><a
href="https://github.com/actions/setup-node/commit/0355742c943ddb13ca8a6b700f824231caa91e75"><code>0355742</code></a>
Remove dummy NODE_AUTH_TOKEN export (<a
href="https://redirect.github.com/actions/setup-node/issues/1558">#1558</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/actions/setup-node/compare/48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e...820762786026740c76f36085b0efc47a31fe5020">compare
view</a></li>
</ul>
</details>
<br />

Updates `github/codeql-action/init` from 4.36.2 to 4.37.0
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/github/codeql-action/releases">github/codeql-action/init's
releases</a>.</em></p>
<blockquote>
<h2>v4.37.0</h2>
<ul>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.26.0">2.26.0</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/3995">#3995</a></li>
<li>In addition to the existing input format, the
<code>config-file</code> input for the <code>codeql-action/init</code>
step will soon support a new <code>[owner/]repo[@ref][:path]</code>
format. All components except the repository name are optional. If
omitted, <code>owner</code> defaults to the same owner as the repository
the analysis is running for, <code>ref</code> to <code>main</code>, and
<code>path</code> to <code>.github/codeql-action.yaml</code>. Support
for this format ships in this version of the CodeQL Action, but will
only be enabled over the coming weeks. <a
href="https://redirect.github.com/github/codeql-action/pull/3973">#3973</a></li>
</ul>
<h2>v4.36.3</h2>
<p>No user facing changes.</p>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/github/codeql-action/blob/main/CHANGELOG.md">github/codeql-action/init's
changelog</a>.</em></p>
<blockquote>
<h1>CodeQL Action Changelog</h1>
<p>See the <a
href="https://github.com/github/codeql-action/releases">releases
page</a> for the relevant changes to the CodeQL CLI and language
packs.</p>
<h2>[UNRELEASED]</h2>
<p>No user facing changes.</p>
<h2>4.37.1 - 16 Jul 2026</h2>
<ul>
<li><em>Upcoming breaking change</em>: Add a deprecation warning for
customers using CodeQL version 2.20.6 and earlier. These versions of
CodeQL were discontinued on 1 July 2026 alongside GitHub Enterprise
Server 3.16, and will be unsupported by the next minor release of the
CodeQL Action. <a
href="https://redirect.github.com/github/codeql-action/pull/3956">#3956</a></li>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.26.1">2.26.1</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/4019">#4019</a></li>
</ul>
<h2>4.37.0 - 08 Jul 2026</h2>
<ul>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.26.0">2.26.0</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/3995">#3995</a></li>
<li>In addition to the existing input format, the
<code>config-file</code> input for the <code>codeql-action/init</code>
step will soon support a new <code>[owner/]repo[@ref][:path]</code>
format. All components except the repository name are optional. If
omitted, <code>owner</code> defaults to the same owner as the repository
the analysis is running for, <code>ref</code> to <code>main</code>, and
<code>path</code> to <code>.github/codeql-action.yaml</code>. Support
for this format ships in this version of the CodeQL Action, but will
only be enabled over the coming weeks. <a
href="https://redirect.github.com/github/codeql-action/pull/3973">#3973</a></li>
</ul>
<h2>4.36.3 - 01 Jul 2026</h2>
<p>No user facing changes.</p>
<h2>4.36.2 - 04 Jun 2026</h2>
<ul>
<li>Cache CodeQL CLI version information across Actions steps. <a
href="https://redirect.github.com/github/codeql-action/pull/3943">#3943</a></li>
<li>Reduce requests while waiting for analysis processing by using
exponential backoff when polling SARIF processing status. <a
href="https://redirect.github.com/github/codeql-action/pull/3937">#3937</a></li>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.25.6">2.25.6</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/3948">#3948</a></li>
</ul>
<h2>4.36.1 - 02 Jun 2026</h2>
<p>No user facing changes.</p>
<h2>4.36.0 - 22 May 2026</h2>
<ul>
<li><em>Breaking change</em>: Bump the minimum required CodeQL bundle
version to 2.19.4. <a
href="https://redirect.github.com/github/codeql-action/pull/3894">#3894</a></li>
<li>Add support for SHA-256 Git object IDs. <a
href="https://redirect.github.com/github/codeql-action/pull/3893">#3893</a></li>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.25.5">2.25.5</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/3926">#3926</a></li>
</ul>
<h2>4.35.5 - 15 May 2026</h2>
<ul>
<li>We have improved how the JavaScript bundles for the CodeQL Action
are generated to avoid duplication across bundles and reduce the size of
the repository by around 70%. This should have no effect on the runtime
behaviour of the CodeQL Action. <a
href="https://redirect.github.com/github/codeql-action/pull/3899">#3899</a></li>
<li>For performance and accuracy reasons, <a
href="https://redirect.github.com/github/roadmap/issues/1158">improved
incremental analysis</a> will now only be enabled on a pull request when
diff-informed analysis is also enabled for that run. If diff-informed
analysis is unavailable (for example, because the PR diff ranges could
not be computed), the action will fall back to a full analysis. <a
href="https://redirect.github.com/github/codeql-action/pull/3791">#3791</a></li>
<li>If multiple inputs are provided for the GitHub-internal
<code>analysis-kinds</code> input, only <code>code-scanning</code> will
be enabled. The <code>analysis-kinds</code> input is experimental, for
GitHub-internal use only, and may change without notice at any time. <a
href="https://redirect.github.com/github/codeql-action/pull/3892">#3892</a></li>
<li>Added an experimental change which, when running a Code Scanning
analysis for a PR with <a
href="https://redirect.github.com/github/roadmap/issues/1158">improved
incremental analysis</a> enabled, prefers CodeQL CLI versions that have
a cached overlay-base database for the configured languages. This speeds
up analysis for a repository when there is not yet a cached overlay-base
database for the latest CLI version. We expect to roll this change out
to everyone in May. <a
href="https://redirect.github.com/github/codeql-action/pull/3880">#3880</a></li>
</ul>
<h2>4.35.4 - 07 May 2026</h2>
<ul>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.25.4">2.25.4</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/3881">#3881</a></li>
</ul>
<h2>4.35.3 - 01 May 2026</h2>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/github/codeql-action/commit/99df26d4f13ea111d4ec1a7dddef6063f76b97e9"><code>99df26d</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/3996">#3996</a>
from github/update-v4.37.0-c7c896d71</li>
<li><a
href="https://github.com/github/codeql-action/commit/31c27074fda95256cda077009907f8a6022dd7c0"><code>31c2707</code></a>
Add changenote for <a
href="https://redirect.github.com/github/codeql-action/issues/3973">#3973</a></li>
<li><a
href="https://github.com/github/codeql-action/commit/72df2181aac054d1f4b44264399d2aac12cf11c6"><code>72df218</code></a>
Update changelog for v4.37.0</li>
<li><a
href="https://github.com/github/codeql-action/commit/c7c896d71b3055d36f2aff93b16bcc6c69923b91"><code>c7c896d</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/3995">#3995</a>
from github/update-bundle/codeql-bundle-v2.26.0</li>
<li><a
href="https://github.com/github/codeql-action/commit/3f34ff0ea3f5153c96071437b7cbf71ea3757146"><code>3f34ff0</code></a>
Add changelog note</li>
<li><a
href="https://github.com/github/codeql-action/commit/43bec09f1dc368b430cab4b5d69799bc904079d1"><code>43bec09</code></a>
Update default bundle to codeql-bundle-v2.26.0</li>
<li><a
href="https://github.com/github/codeql-action/commit/f58f0d11ebf5dedd870fab2f999275f7602cfa46"><code>f58f0d1</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/3973">#3973</a>
from github/mbg/repo-props/config-file-shorthands</li>
<li><a
href="https://github.com/github/codeql-action/commit/7dc37cbb5b3e37f0e1cd1f18b61e0ea849898fb8"><code>7dc37cb</code></a>
Merge remote-tracking branch 'origin/main' into
mbg/repo-props/config-file-sh...</li>
<li><a
href="https://github.com/github/codeql-action/commit/8e22350a7e28c34c82a5a499fc241923301c2c4f"><code>8e22350</code></a>
Thread <code>ActionState</code> to <code>initConfig</code></li>
<li><a
href="https://github.com/github/codeql-action/commit/69c9e8c7d918cf2fee13b8b72fdde15883ff155b"><code>69c9e8c</code></a>
Mark some <code>status-report</code> imports as <code>type</code>-only
to avoid circular dependencies</li>
<li>Additional commits viewable in <a
href="https://github.com/github/codeql-action/compare/8aad20d150bbac5944a9f9d289da16a4b0d87c1e...99df26d4f13ea111d4ec1a7dddef6063f76b97e9">compare
view</a></li>
</ul>
</details>
<br />

Updates `github/codeql-action/autobuild` from 4.36.2 to 4.37.0
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/github/codeql-action/releases">github/codeql-action/autobuild's
releases</a>.</em></p>
<blockquote>
<h2>v4.37.0</h2>
<ul>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.26.0">2.26.0</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/3995">#3995</a></li>
<li>In addition to the existing input format, the
<code>config-file</code> input for the <code>codeql-action/init</code>
step will soon support a new <code>[owner/]repo[@ref][:path]</code>
format. All components except the repository name are optional. If
omitted, <code>owner</code> defaults to the same owner as the repository
the analysis is running for, <code>ref</code> to <code>main</code>, and
<code>path</code> to <code>.github/codeql-action.yaml</code>. Support
for this format ships in this version of the CodeQL Action, but will
only be enabled over the coming weeks. <a
href="https://redirect.github.com/github/codeql-action/pull/3973">#3973</a></li>
</ul>
<h2>v4.36.3</h2>
<p>No user facing changes.</p>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/github/codeql-action/blob/main/CHANGELOG.md">github/codeql-action/autobuild's
changelog</a>.</em></p>
<blockquote>
<h1>CodeQL Action Changelog</h1>
<p>See the <a
href="https://github.com/github/codeql-action/releases">releases
page</a> for the relevant changes to the CodeQL CLI and language
packs.</p>
<h2>[UNRELEASED]</h2>
<p>No user facing changes.</p>
<h2>4.37.1 - 16 Jul 2026</h2>
<ul>
<li><em>Upcoming breaking change</em>: Add a deprecation warning for
customers using CodeQL version 2.20.6 and earlier. These versions of
CodeQL were discontinued on 1 July 2026 alongside GitHub Enterprise
Server 3.16, and will be unsupported by the next minor release of the
CodeQL Action. <a
href="https://redirect.github.com/github/codeql-action/pull/3956">#3956</a></li>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.26.1">2.26.1</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/4019">#4019</a></li>
</ul>
<h2>4.37.0 - 08 Jul 2026</h2>
<ul>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.26.0">2.26.0</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/3995">#3995</a></li>
<li>In addition to the existing input format, the
<code>config-file</code> input for the <code>codeql-action/init</code>
step will soon support a new <code>[owner/]repo[@ref][:path]</code>
format. All components except the repository name are optional. If
omitted, <code>owner</code> defaults to the same owner as the repository
the analysis is running for, <code>ref</code> to <code>main</code>, and
<code>path</code> to <code>.github/codeql-action.yaml</code>. Support
for this format ships in this version of the CodeQL Action, but will
only be enabled over the coming weeks. <a
href="https://redirect.github.com/github/codeql-action/pull/3973">#3973</a></li>
</ul>
<h2>4.36.3 - 01 Jul 2026</h2>
<p>No user facing changes.</p>
<h2>4.36.2 - 04 Jun 2026</h2>
<ul>
<li>Cache CodeQL CLI version information across Actions steps. <a
href="https://redirect.github.com/github/codeql-action/pull/3943">#3943</a></li>
<li>Reduce requests while waiting for analysis processing by using
exponential backoff when polling SARIF processing status. <a
href="https://redirect.github.com/github/codeql-action/pull/3937">#3937</a></li>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.25.6">2.25.6</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/3948">#3948</a></li>
</ul>
<h2>4.36.1 - 02 Jun 2026</h2>
<p>No user facing changes.</p>
<h2>4.36.0 - 22 May 2026</h2>
<ul>
<li><em>Breaking change</em>: Bump the minimum required CodeQL bundle
version to 2.19.4. <a
href="https://redirect.github.com/github/codeql-action/pull/3894">#3894</a></li>
<li>Add support for SHA-256 Git object IDs. <a
href="https://redirect.github.com/github/codeql-action/pull/3893">#3893</a></li>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.25.5">2.25.5</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/3926">#3926</a></li>
</ul>
<h2>4.35.5 - 15 May 2026</h2>
<ul>
<li>We have improved how the JavaScript bundles for the CodeQL Action
are generated to avoid duplication across bundles and reduce the size of
the repository by around 70%. This should have no effect on the runtime
behaviour of the CodeQL Action. <a
href="https://redirect.github.com/github/codeql-action/pull/3899">#3899</a></li>
<li>For performance and accuracy reasons, <a
href="https://redirect.github.com/github/roadmap/issues/1158">improved
incremental analysis</a> will now only be enabled on a pull request when
diff-informed analysis is also enabled for that run. If diff-informed
analysis is unavailable (for example, because the PR diff ranges could
not be computed), the action will fall back to a full analysis. <a
href="https://redirect.github.com/github/codeql-action/pull/3791">#3791</a></li>
<li>If multiple inputs are provided for the GitHub-internal
<code>analysis-kinds</code> input, only <code>code-scanning</code> will
be enabled. The <code>analysis-kinds</code> input is experimental, for
GitHub-internal use only, and may change without notice at any time. <a
href="https://redirect.github.com/github/codeql-action/pull/3892">#3892</a></li>
<li>Added an experimental change which, when running a Code Scanning
analysis for a PR with <a
href="https://redirect.github.com/github/roadmap/issues/1158">improved
incremental analysis</a> enabled, prefers CodeQL CLI versions that have
a cached overlay-base database for the configured languages. This speeds
up analysis for a repository when there is not yet a cached overlay-base
database for the latest CLI version. We expect to roll this change out
to everyone in May. <a
href="https://redirect.github.com/github/codeql-action/pull/3880">#3880</a></li>
</ul>
<h2>4.35.4 - 07 May 2026</h2>
<ul>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.25.4">2.25.4</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/3881">#3881</a></li>
</ul>
<h2>4.35.3 - 01 May 2026</h2>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/github/codeql-action/commit/99df26d4f13ea111d4ec1a7dddef6063f76b97e9"><code>99df26d</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/3996">#3996</a>
from github/update-v4.37.0-c7c896d71</li>
<li><a
href="https://github.com/github/codeql-action/commit/31c27074fda95256cda077009907f8a6022dd7c0"><code>31c2707</code></a>
Add changenote for <a
href="https://redirect.github.com/github/codeql-action/issues/3973">#3973</a></li>
<li><a
href="https://github.com/github/codeql-action/commit/72df2181aac054d1f4b44264399d2aac12cf11c6"><code>72df218</code></a>
Update changelog for v4.37.0</li>
<li><a
href="https://github.com/github/codeql-action/commit/c7c896d71b3055d36f2aff93b16bcc6c69923b91"><code>c7c896d</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/3995">#3995</a>
from github/update-bundle/codeql-bundle-v2.26.0</li>
<li><a
href="https://github.com/github/codeql-action/commit/3f34ff0ea3f5153c96071437b7cbf71ea3757146"><code>3f34ff0</code></a>
Add changelog note</li>
<li><a
href="https://github.com/github/codeql-action/commit/43bec09f1dc368b430cab4b5d69799bc904079d1"><code>43bec09</code></a>
Update default bundle to codeql-bundle-v2.26.0</li>
<li><a
href="https://github.com/github/codeql-action/commit/f58f0d11ebf5dedd870fab2f999275f7602cfa46"><code>f58f0d1</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/3973">#3973</a>
from github/mbg/repo-props/config-file-shorthands</li>
<li><a
href="https://github.com/github/codeql-action/commit/7dc37cbb5b3e37f0e1cd1f18b61e0ea849898fb8"><code>7dc37cb</code></a>
Merge remote-tracking branch 'origin/main' into
mbg/repo-props/config-file-sh...</li>
<li><a
href="https://github.com/github/codeql-action/commit/8e22350a7e28c34c82a5a499fc241923301c2c4f"><code>8e22350</code></a>
Thread <code>ActionState</code> to <code>initConfig</code></li>
<li><a
href="https://github.com/github/codeql-action/commit/69c9e8c7d918cf2fee13b8b72fdde15883ff155b"><code>69c9e8c</code></a>
Mark some <code>status-report</code> imports as <code>type</code>-only
to avoid circular dependencies</li>
<li>Additional commits viewable in <a
href="https://github.com/github/codeql-action/compare/8aad20d150bbac5944a9f9d289da16a4b0d87c1e...99df26d4f13ea111d4ec1a7dddef6063f76b97e9">compare
view</a></li>
</ul>
</details>
<br />

Updates `github/codeql-action/analyze` from 4.36.2 to 4.37.0
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/github/codeql-action/releases">github/codeql-action/analyze's
releases</a>.</em></p>
<blockquote>
<h2>v4.37.0</h2>
<ul>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.26.0">2.26.0</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/3995">#3995</a></li>
<li>In addition to the existing input format, the
<code>config-file</code> input for the <code>codeql-action/init</code>
step will soon support a new <code>[owner/]repo[@ref][:path]</code>
format. All components except the repository name are optional. If
omitted, <code>owner</code> defaults to the same owner as the repository
the analysis is running for, <code>ref</code> to <code>main</code>, and
<code>path</code> to <code>.github/codeql-action.yaml</code>. Support
for this format ships in this version of the CodeQL Action, but will
only be enabled over the coming weeks. <a
href="https://redirect.github.com/github/codeql-action/pull/3973">#3973</a></li>
</ul>
<h2>v4.36.3</h2>
<p>No user facing changes.</p>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/github/codeql-action/blob/main/CHANGELOG.md">github/codeql-action/analyze's
changelog</a>.</em></p>
<blockquote>
<h1>CodeQL Action Changelog</h1>
<p>See the <a
href="https://github.com/github/codeql-action/releases">releases
page</a> for the relevant changes to the CodeQL CLI and language
packs.</p>
<h2>[UNRELEASED]</h2>
<p>No user facing changes.</p>
<h2>4.37.1 - 16 Jul 2026</h2>
<ul>
<li><em>Upcoming breaking change</em>: Add a deprecation warning for
customers using CodeQL version 2.20.6 and earlier. These versions of
CodeQL were discontinued on 1 July 2026 alongside GitHub Enterprise
Server 3.16, and will be unsupported by the next minor release of the
CodeQL Action. <a
href="https://redirect.github.com/github/codeql-action/pull/3956">#3956</a></li>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.26.1">2.26.1</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/4019">#4019</a></li>
</ul>
<h2>4.37.0 - 08 Jul 2026</h2>
<ul>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.26.0">2.26.0</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/3995">#3995</a></li>
<li>In addition to the existing input format, the
<code>config-file</code> input for the <code>codeql-action/init</code>
step will soon support a new <code>[owner/]repo[@ref][:path]</code>
format. All components except the repository name are optional. If
omitted, <code>owner</code> defaults to the same owner as the repository
the analysis is running for, <code>ref</code> to <code>main</code>, and
<code>path</code> to <code>.github/codeql-action.yaml</code>. Support
for this format ships in this version of the CodeQL Action, but will
only be enabled over the coming weeks. <a
href="https://redirect.github.com/github/codeql-action/pull/3973">#3973</a></li>
</ul>
<h2>4.36.3 - 01 Jul 2026</h2>
<p>No user facing changes.</p>
<h2>4.36.2 - 04 Jun 2026</h2>
<ul>
<li>Cache CodeQL CLI version information across Actions steps. <a
href="https://redirect.github.com/github/codeql-action/pull/3943">#3943</a></li>
<li>Reduce requests while waiting for analysis processing by using
exponential backoff when polling SARIF processing status. <a
href="https://redirect.github.com/github/codeql-action/pull/3937">#3937</a></li>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.25.6">2.25.6</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/3948">#3948</a></li>
</ul>
<h2>4.36.1 - 02 Jun 2026</h2>
<p>No user facing changes.</p>
<h2>4.36.0 - 22 May 2026</h2>
<ul>
<li><em>Breaking change</em>: Bump the minimum required CodeQL bundle
version to 2.19.4. <a
href="https://redirect.github.com/github/codeql-action/pull/3894">#3894</a></li>
<li>Add support for SHA-256 Git object IDs. <a
href="https://redirect.github.com/github/codeql-action/pull/3893">#3893</a></li>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.25.5">2.25.5</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/3926">#3926</a></li>
</ul>
<h2>4.35.5 - 15 May 2026</h2>
<ul>
<li>We have improved how the JavaScript bundles for the CodeQL Action
are generated to avoid duplication across bundles and reduce the size of
the repository by around 70%. This should have no effect on the runtime
behaviour of the CodeQL Action. <a
href="https://redirect.github.com/github/codeql-action/pull/3899">#3899</a></li>
<li>For performance and accuracy reasons, <a
href="https://redirect.github.com/github/roadmap/issues/1158">improved
incremental analysis</a> will now only be enabled on a pull request when
diff-informed analysis is also enabled for that run. If diff-informed
analysis is unavailable (for example, because the PR diff ranges could
not be computed), the action will fall back to a full analysis. <a
href="https://redirect.github.com/github/codeql-action/pull/3791">#3791</a></li>
<li>If multiple inputs are provided for the GitHub-internal
<code>analysis-kinds</code> input, only <code>code-scanning</code> will
be enabled. The <code>analysis-kinds</code> input is experimental, for
GitHub-internal use only, and may change without notice at any time. <a
href="https://redirect.github.com/github/codeql-action/pull/3892">#3892</a></li>
<li>Added an experimental change which, when running a Code Scanning
analysis for a PR with <a
href="https://redirect.github.com/github/roadmap/issues/1158">improved
incremental analysis</a> enabled, prefers CodeQL CLI versions that have
a cached overlay-base database for the configured languages. This speeds
up analysis for a repository when there is not yet a cached overlay-base
database for the latest CLI version. We expect to roll this change out
to everyone in May. <a
href="https://redirect.github.com/github/codeql-action/pull/3880">#3880</a></li>
</ul>
<h2>4.35.4 - 07 May 2026</h2>
<ul>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.25.4">2.25.4</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/3881">#3881</a></li>
</ul>
<h2>4.35.3 - 01 May 2026</h2>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/github/codeql-action/commit/99df26d4f13ea111d4ec1a7dddef6063f76b97e9"><code>99df26d</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/3996">#3996</a>
from github/update-v4.37.0-c7c896d71</li>
<li><a
href="https://github.com/github/codeql-action/commit/31c27074fda95256cda077009907f8a6022dd7c0"><code>31c2707</code></a>
Add changenote for <a
href="https://redirect.github.com/github/codeql-action/issues/3973">#3973</a></li>
<li><a
href="https://github.com/github/codeql-action/commit/72df2181aac054d1f4b44264399d2aac12cf11c6"><code>72df218</code></a>
Update changelog for v4.37.0</li>
<li><a
href="https://github.com/github/codeql-action/commit/c7c896d71b3055d36f2aff93b16bcc6c69923b91"><code>c7c896d</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/3995">#3995</a>
from github/update-bundle/codeql-bundle-v2.26.0</li>
<li><a
href="https://github.com/github/codeql-action/commit/3f34ff0ea3f5153c96071437b7cbf71ea3757146"><code>3f34ff0</code></a>
Add changelog note</li>
<li><a
href="https://github.com/github/codeql-action/commit/43bec09f1dc368b430cab4b5d69799bc904079d1"><code>43bec09</code></a>
Update default bundle to codeql-bundle-v2.26.0</li>
<li><a
href="https://github.com/github/codeql-action/commit/f58f0d11ebf5dedd870fab2f999275f7602cfa46"><code>f58f0d1</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/3973">#3973</a>
from github/mbg/repo-props/config-file-shorthands</li>
<li><a
href="https://github.com/github/codeql-action/commit/7dc37cbb5b3e37f0e1cd1f18b61e0ea849898fb8"><code>7dc37cb</code></a>
Merge remote-tracking branch 'origin/main' into
mbg/repo-props/config-file-sh...</li>
<li><a
href="https://github.com/github/codeql-action/commit/8e22350a7e28c34c82a5a499fc241923301c2c4f"><code>8e22350</code></a>
Thread <code>ActionState</code> to <code>initConfig</code></li>
<li><a
href="https://github.com/github/codeql-action/commit/69c9e8c7d918cf2fee13b8b72fdde15883ff155b"><code>69c9e8c</code></a>
Mark some <code>status-report</code> imports as <code>type</code>-only
to avoid circular dependencies</li>
<li>Additional commits viewable in <a
href="https://github.com/github/codeql-action/compare/8aad20d150bbac5944a9f9d289da16a4b0d87c1e...99df26d4f13ea111d4ec1a7dddef6063f76b97e9">compare
view</a></li>
</ul>
</details>
<br />

Updates `dorny/paths-filter` from 4.0.1 to 4.0.2
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/dorny/paths-filter/releases">dorny/paths-filter's
releases</a>.</em></p>
<blockquote>
<h2>v4.0.2</h2>
<h2>What's Changed</h2>
<ul>
<li>fix warning message by <a
href="https://github.com/cgundy"><code>@​cgundy</code></a> in <a
href="https://redirect.github.com/dorny/paths-filter/pull/282">dorny/paths-filter#282</a></li>
<li>chore: fix GitHub spelling in logs by <a
href="https://github.com/squat"><code>@​squat</code></a> in <a
href="https://redirect.github.com/dorny/paths-filter/pull/278">dorny/paths-filter#278</a></li>
<li>fix: use rev-parse instead of branch --show-current for older git
compat by <a
href="https://github.com/saschabratton"><code>@​saschabratton</code></a>
in <a
href="https://redirect.github.com/dorny/paths-filter/pull/303">dorny/paths-filter#303</a></li>
<li>fix: work around git dubious ownership errors in container jobs by
<a
href="https://github.com/saschabratton"><code>@​saschabratton</code></a>
in <a
href="https://redirect.github.com/dorny/paths-filter/pull/317">dorny/paths-filter#317</a></li>
<li>docs: update changelog for v4.0.2 by <a
href="https://github.com/saschabratton"><code>@​saschabratton</code></a>
in <a
href="https://redirect.github.com/dorny/paths-filter/pull/318">dorny/paths-filter#318</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a href="https://github.com/cgundy"><code>@​cgundy</code></a> made
their first contribution in <a
href="https://redirect.github.com/dorny/paths-filter/pull/282">dorny/paths-filter#282</a></li>
<li><a href="https://github.com/squat"><code>@​squat</code></a> made
their first contribution in <a
href="https://redirect.github.com/dorny/paths-filter/pull/278">dorny/paths-filter#278</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/dorny/paths-filter/compare/v4.0.1...v4.0.2">https://github.com/dorny/paths-filter/compare/v4.0.1...v4.0.2</a></p>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/dorny/paths-filter/blob/master/CHANGELOG.md">dorny/paths-filter's
changelog</a>.</em></p>
<blockquote>
<h2>v4.0.2</h2>
<ul>
<li><a
href="https://redirect.github.com/dorny/paths-filter/pull/317">Work
around git dubious ownership errors in container jobs</a></li>
<li><a
href="https://redirect.github.com/dorny/paths-filter/pull/303">Use
rev-parse instead of branch --show-current for older git compat</a></li>
<li><a
href="https://redirect.github.com/dorny/paths-filter/pull/282">Fix
warning message</a></li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/dorny/paths-filter/commit/7b450fff21473bca461d4b92ce414b9d0420d706"><code>7b450ff</code></a>
docs: update changelog for v4.0.2 (<a
href="https://redirect.github.com/dorny/paths-filter/issues/318">#318</a>)</li>
<li><a
href="https://github.com/dorny/paths-filter/commit/928037783a71f24983ea250c6e55290c1b2de54f"><code>9280377</code></a>
fix: work around git dubious ownership errors in container jobs (<a
href="https://redirect.github.com/dorny/paths-filter/issues/317">#317</a>)</li>
<li><a
href="https://github.com/dorny/paths-filter/commit/f3ceefdc7ef57bc2d8560787d4b6c33e44044cec"><code>f3ceefd</code></a>
fix: use rev-parse instead of branch --show-current for older git compat
(<a
href="https://redirect.github.com/dorny/paths-filter/issues/303">#303</a>)</li>
<li><a
href="https://github.com/dorny/paths-filter/commit/61f87a10cd2c304679af17bb73ef192addf33c1c"><code>61f87a1</code></a>
chore: fix GitHub spelling in logs (<a
href="https://redirect.github.com/dorny/paths-filter/issues/278">#278</a>)</li>
<li><a
href="https://github.com/dorny/paths-filter/commit/b82ff81ffbe6fb4b636bb5b47e37fd8d12b32632"><code>b82ff81</code></a>
fix warning message (<a
href="https://redirect.github.com/dorny/paths-filter/issues/282">#282</a>)</li>
<li>See full diff in <a
href="https://github.com/dorny/paths-filter/compare/v4.0.1...v4.0.2">compare
view</a></li>
</ul>
</details>
<br />


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

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

---

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

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


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-19 02:51:53 -04:00
dependabot[bot] ceaccfd29f Bump prek from 0.4.8 to 0.4.9 in the python-deps group (#1116)
Bumps the python-deps group with 1 update:
[prek](https://github.com/j178/prek).

Updates `prek` from 0.4.8 to 0.4.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.4.9</h2>
<h2>Release Notes</h2>
<p>Released on 2026-07-11.</p>
<p>Note: This release changes the repository identity schema stored in
cached hook environments. Existing
hook environments will be invalidated, and prek will reinstall them
automatically when needed.</p>
<h3>Enhancements</h3>
<ul>
<li>Preserve additional dependency order (<a
href="https://redirect.github.com/j178/prek/pull/2311">#2311</a>)</li>
<li>Remove parallel Ruby gem installation (<a
href="https://redirect.github.com/j178/prek/pull/2307">#2307</a>)</li>
<li>Warn for missing update repositories (<a
href="https://redirect.github.com/j178/prek/pull/2316">#2316</a>)</li>
</ul>
<h3>Bug fixes</h3>
<ul>
<li>Fix mixed workspace selectors (<a
href="https://redirect.github.com/j178/prek/pull/2306">#2306</a>)</li>
<li>Fix try-repo local path resolution (<a
href="https://redirect.github.com/j178/prek/pull/2310">#2310</a>)</li>
<li>Use resolved gem executable (<a
href="https://redirect.github.com/j178/prek/pull/2308">#2308</a>)</li>
<li>Validate complete XML documents in check-xml (<a
href="https://redirect.github.com/j178/prek/pull/2312">#2312</a>)</li>
</ul>
<h3>Contributors</h3>
<ul>
<li><a href="https://github.com/j178"><code>@​j178</code></a></li>
</ul>
<h2>Install prek 0.4.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.4.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.4.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.4.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.4.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.4.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/master/CHANGELOG.md">prek's
changelog</a>.</em></p>
<blockquote>
<h2>0.4.9</h2>
<p>Released on 2026-07-11.</p>
<p>Note: This release changes the repository identity schema stored in
cached hook environments. Existing
hook environments will be invalidated, and prek will reinstall them
automatically when needed.</p>
<h3>Enhancements</h3>
<ul>
<li>Preserve additional dependency order (<a
href="https://redirect.github.com/j178/prek/pull/2311">#2311</a>)</li>
<li>Remove parallel Ruby gem installation (<a
href="https://redirect.github.com/j178/prek/pull/2307">#2307</a>)</li>
<li>Warn for missing update repositories (<a
href="https://redirect.github.com/j178/prek/pull/2316">#2316</a>)</li>
</ul>
<h3>Bug fixes</h3>
<ul>
<li>Fix mixed workspace selectors (<a
href="https://redirect.github.com/j178/prek/pull/2306">#2306</a>)</li>
<li>Fix try-repo local path resolution (<a
href="https://redirect.github.com/j178/prek/pull/2310">#2310</a>)</li>
<li>Use resolved gem executable (<a
href="https://redirect.github.com/j178/prek/pull/2308">#2308</a>)</li>
<li>Validate complete XML documents in check-xml (<a
href="https://redirect.github.com/j178/prek/pull/2312">#2312</a>)</li>
</ul>
<h3>Contributors</h3>
<ul>
<li><a href="https://github.com/j178"><code>@​j178</code></a></li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/j178/prek/commit/42b79a57f275ffe3e7fc3ad32fdf2754416779d5"><code>42b79a5</code></a>
Bump version to 0.4.9 (<a
href="https://redirect.github.com/j178/prek/issues/2318">#2318</a>)</li>
<li><a
href="https://github.com/j178/prek/commit/ecbd9dabab540d1d964a66276f70ff084c5aa14d"><code>ecbd9da</code></a>
Extract executable helpers (<a
href="https://redirect.github.com/j178/prek/issues/2317">#2317</a>)</li>
<li><a
href="https://github.com/j178/prek/commit/25ee3c2d16086291673fed03dcc9beff09b1e03a"><code>25ee3c2</code></a>
Validate complete XML documents in check-xml (<a
href="https://redirect.github.com/j178/prek/issues/2312">#2312</a>)</li>
<li><a
href="https://github.com/j178/prek/commit/876f7610dcc6f50dd6b85842cc5f01b36e7dc93f"><code>876f761</code></a>
Warn for missing update repositories (<a
href="https://redirect.github.com/j178/prek/issues/2316">#2316</a>)</li>
<li><a
href="https://github.com/j178/prek/commit/61cbcbf91b0c6478afcc16a2de8fb51269c94d52"><code>61cbcbf</code></a>
Preserve additional dependency order (<a
href="https://redirect.github.com/j178/prek/issues/2311">#2311</a>)</li>
<li><a
href="https://github.com/j178/prek/commit/4b006eba7adf4515f686647060fa852fd994359d"><code>4b006eb</code></a>
Update Rust to 1.97 (<a
href="https://redirect.github.com/j178/prek/issues/2313">#2313</a>)</li>
<li><a
href="https://github.com/j178/prek/commit/40f6609fd824151af0cb85ca994a1cd2f6ea5d87"><code>40f6609</code></a>
Fix try-repo local path resolution (<a
href="https://redirect.github.com/j178/prek/issues/2310">#2310</a>)</li>
<li><a
href="https://github.com/j178/prek/commit/3795e9964883a2e57ed9b783345ac1cf654160a3"><code>3795e99</code></a>
Use resolved gem executable (<a
href="https://redirect.github.com/j178/prek/issues/2308">#2308</a>)</li>
<li><a
href="https://github.com/j178/prek/commit/de7ac64173f48f91ad746e1ebe7e8b4cd1a99d11"><code>de7ac64</code></a>
Remove parallel Ruby gem installation (<a
href="https://redirect.github.com/j178/prek/issues/2307">#2307</a>)</li>
<li><a
href="https://github.com/j178/prek/commit/434e791453272d9badef7c5c6295342d541496ac"><code>434e791</code></a>
Fix mixed workspace selectors (<a
href="https://redirect.github.com/j178/prek/issues/2306">#2306</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/j178/prek/compare/v0.4.8...v0.4.9">compare
view</a></li>
</ul>
</details>
<br />


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

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

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

---

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

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore <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-07-15 10:28:00 -04:00
CaliBrain 6bab9989ab fix: fetch tracker .torrent links once per add and surface fetch failures (#1115)
## Summary

Fixes Prowlarr torrent downloads that fail with `Could not determine
torrent hash
from URL` when the result has no magnet link and no infohash (e.g.
MyAnonaMouse),
where fetching the .torrent from Prowlarr's proxy download link is the
only path.

Two problems compounded here:

1. **Every add attempt fetched the download link twice.**
`find_existing()`
prefetched the .torrent to compute a dedup hash, discarded the result,
and
`add_download()` fetched the same URL again seconds later. Private
tracker
links behind Prowlarr's proxy can be slow, rate-limited, or effectively
single-use, so the second hit could fail even when the link itself was
valid —
   which is why the reporter's manual fetch of the same URL succeeded.
2. **The real failure reason was invisible.** When the fetch failed
(e.g.
Prowlarr returning HTTP 500 because the tracker rejected the request —
see the
2026-07-07 MAM report on #476, which turned out to be a MAM IP-settings
problem), the reason was logged at DEBUG only and the user saw the
misleading
   generic hash error.

## What changed

- `extract_torrent_info()` now reuses a recent successful fetch of the
same URL
(short-TTL in-memory cache, successes only), so one add attempt hits the
tracker download link exactly once across `find_existing()` +
`add_download()`.
All four torrent clients (qBittorrent, Deluge, Transmission, rTorrent)
share
  this path and benefit. Failures are never cached, so retries refetch.
- `TorrentInfo` gains a `fetch_error` field. qBittorrent and rTorrent
append it
to the hash error (`... (torrent file fetch failed: 500 Server Error
...)`),
Deluge to its "Failed to fetch torrent file" error. The enriched message
still
contains the exact substring the #1109 expired-link refresh hook matches
on,
  so the refresh-and-retry path keeps working.
- Torrent fetch failures are logged at WARNING instead of DEBUG, so
non-debug
  logs show the cause.

## Validation

- `uv run pytest tests/prowlarr tests/download -q` — 498 passed
- `uv run pytest tests/newznab tests/audiobookbay -q` — 147 passed
- `uv run ruff check` / `ruff format --check` on all changed files
- New tests: fetch-cache reuse, failure-not-cached + reason capture,
expected-hash fallback on failed/hashless fetches, TTL expiry,
magnet-redirect
  reuse, and the enriched qBittorrent error message.

Fixes #1111
2026-07-14 16:21:31 -04:00
Aidan AbbottandAidan Abbott f8d3f990ca fix: Prowlarr expired download link refresh (#1109)
## Summary

Fixes Prowlarr downloads that fail after a queued torrent result’s
tracker download link expires.

Prowlarr torrent results can expose a `downloadUrl` that is only a
short-lived proxy to the upstream tracker. Some trackers, including MAM,
include expiring credentials in that URL. Shelfmark was persisting that
URL as retry data and later treating it as durable. If the in-memory
Prowlarr cache was gone, or if qBittorrent tried to add a stale URL,
Shelfmark could fail with a misleading torrent-hash error instead of
refreshing the release.

## What changed

- Stop persisting Prowlarr `downloadUrl` values as durable retry data.
- Persist only source context needed to refresh the release later.
- On a Prowlarr cache miss, re-query Prowlarr using the queued task
context.
- Accept refreshed results only when the stable identity matches the
original release:
  - `guid == task.task_id`
  - or `infoUrl == task.task_id`
- Cache the fresh raw Prowlarr result and build the download request
from its current `downloadUrl` / `magnetUrl`.
- If qBittorrent add fails with `Could not determine torrent hash from
URL`, remove the stale cached Prowlarr result, refresh once, and retry
with the fresh URL.
- Preserve existing magnet handling: torrent results continue to use
`magnetUrl` first, so the refresh path only targets Prowlarr proxy
`downloadUrl` values that can expire.
- Improve the user-facing failure when refresh cannot find the same
release:
`The indexer download link expired and the release could not be
refreshed. Search again for a fresh result.`

## Why this approach

The important constraint is avoiding accidental downloads of a different
edition or format after the original tracker link expires. Re-running a
search by title can return many plausible results, so the refresh path
deliberately requires an exact stable identity match before using any
new URL.

This treats Prowlarr `downloadUrl` as a short-lived hint, while still
allowing retries to recover when Prowlarr can find the same release
again. The one-shot retry after qBittorrent add failure handles the case
where Shelfmark still has a cached Prowlarr result, but that cached
result contains an expired proxy URL.

The refresh hook is source-specific and defaults to no-op for other
external download handlers, so Newznab and other sources keep their
existing retry behavior.

## Bug

Fixes #1012

Context:
https://github.com/calibrain/shelfmark/issues/1012#issuecomment-4917148398

## Validation

- `uv run pytest tests/prowlarr -q`
- `uv run pytest tests/download/test_orchestrator_user_output_mode.py
-q`
- `uv run pytest tests/newznab/test_handler.py
tests/audiobookbay/test_handler.py -q`
- `uv run ruff check shelfmark/core/models.py
shelfmark/download/orchestrator.py
shelfmark/download/clients/base_handler.py
shelfmark/release_sources/prowlarr/handler.py
tests/prowlarr/test_handler.py
tests/prowlarr/test_integration_handler.py
tests/prowlarr/test_failure_scenarios.py tests/prowlarr/test_source.py
tests/download/test_orchestrator_user_output_mode.py`
- after resolving conflicts with latest `main`: `uv run pytest
tests/prowlarr -q`
- after resolving conflicts with latest `main`: `uv run ruff check
shelfmark/release_sources/prowlarr/handler.py`
- after resolving conflicts with latest `main`: `uv run pytest
tests/prowlarr/test_handler.py tests/prowlarr/test_source.py -q`

Co-authored-by: Aidan Abbott <aidanabbott@Aidans-MacBook-Pro.local>
2026-07-14 14:22:29 -04:00
dependabot[bot] 30d7f228be Bump the python-deps group with 5 updates (#1114)
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-07-14 14:07:49 -04:00
adman234 b291df5cc9 fix(prowlarr): prevent silent loss of indexer seed limits on transient Prowlarr API failures (#1110)
# fix(prowlarr): prevent silent loss of indexer seed limits

Related to #795, though not a fix for that specific (closed) report —
see note below.

## Problem

With "Use Prowlarr seed preferences" enabled, ~5–10% of torrent grabs
are added to the download client without their configured share limits
and seed indefinitely (∞ ETA in qBittorrent).

Seed limits are resolved once at search time.
`get_indexer_seed_settings()` builds on `get_indexers()`, which swallows
all API errors and returns `[]`. A transient failure of the
`/api/v1/indexer` call therefore produces an empty settings dict that is
indistinguishable from "no limits configured", while the search itself
(separate HTTP calls) still succeeds. Every result from that search is
cached without `configuredSeedTimeMinutes`; grabbing one sends the
torrent to the client with no limits.

Compounding factors: `cache_release()` is last-write-wins by GUID, so
one degraded search can strip enrichment from a previously good cache
entry; and the retry fields persisted at queue time snapshot the same
missing values, so retries reproduce the failure.

## Changes

- **`api.py`** — `get_indexers()` / `get_enabled_indexers_detailed()`
gain a keyword-only `raise_on_error` (default `False`, existing behavior
unchanged). `get_indexer_seed_settings()` uses it, so fetch failures now
propagate and an empty dict strictly means "nothing configured".
- **`source.py`** — searches fetch settings via
`_fetch_indexer_seed_settings()`, which maintains a module-level
last-known-good copy (merged on each success) and falls back to it with
a warning when the fetch fails. After one successful fetch, results can
no longer be cached un-enriched.
- **`handler.py`** — grab-time safety net in `_resolve_download()`: if
seed preferences are enabled, the release is a torrent, and no
configured limits are present in the cached result, the handler
re-resolves the limits from Prowlarr for that indexer
(`restrict_to=[indexerId]`) before adding to the client. If limits still
can't be resolved, a warning is logged so the condition is visible
instead of silent.
- **Tests** — regression coverage: last-known-good fallback (success
updates cache, failure falls back, failure with no history returns
empty, fallback copy is mutation-safe) and grab-time fallback (used when
enrichment is missing, tolerates Prowlarr being down, skipped when
enrichment is present). Existing test stubs for
`get_enabled_indexers_detailed` updated to accept the new kwarg.

## Testing

- `uv run pytest tests/prowlarr/test_handler.py
tests/prowlarr/test_source.py
tests/prowlarr/test_integration_handler.py` — 93 passed, 2 skipped
(Python 3.14.4)
- Full `tests/prowlarr` run has 11 pre-existing failures on this
environment (Windows path-separator assertions in the
qBittorrent/NZBGet/SABnzbd/Transmission client tests, e.g.
`/downloads/x` vs `\downloads\x`); confirmed these also fail on
unpatched `main` and are unrelated to this change
- `uv run ruff check` / `ruff format --check` — clean on touched files
- `uv run basedpyright` — 0 errors on touched files

No behavior change when `PROWLARR_USE_SEED_PREFERENCES` is disabled; the
fallback path only activates when the preference is on and enrichment is
missing for a torrent.

---

**Note on #795:** this PR references #795 for background context on the
seed-limits feature, but it does not fix that report — #795 was about
seed limits not being converted/applied at all (a units mismatch), and
was already fixed by #946 / #959. This PR fixes a separate,
still-present bug: `get_indexers()` silently swallowing transient API
errors, which intermittently drops seed limits even when the feature is
otherwise working correctly.
2026-07-09 23:25:29 -04:00
81 changed files with 6744 additions and 1017 deletions
@@ -70,7 +70,7 @@ jobs:
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Log in to the Container registry
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
@@ -78,7 +78,7 @@ jobs:
- name: Extract metadata for ${{ matrix.target }} image
id: meta
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
env:
# Annotate both the per-platform manifests and the multi-arch image
# index. The index level is what manifest-list consumers (Renovate's
@@ -96,11 +96,11 @@ jobs:
type=ref,event=tag
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Build and push ${{ matrix.target }} Docker image
id: push
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
platforms: linux/amd64,linux/arm64
context: .
@@ -134,14 +134,14 @@ jobs:
LEGACY_NAME: calibre-web-automated-book-downloader
steps:
- name: Log in to registry
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Create legacy aliases
run: |
+8 -8
View File
@@ -16,7 +16,7 @@ jobs:
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Install uv and Python
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
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@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
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@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
with:
version: "0.11.3"
python-version: "3.14"
@@ -81,10 +81,10 @@ jobs:
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Build shelfmark-lite image
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
target: shelfmark-lite
@@ -102,7 +102,7 @@ jobs:
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Set up Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 24
cache: "npm"
@@ -125,7 +125,7 @@ jobs:
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Set up Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 24
cache: "npm"
@@ -145,7 +145,7 @@ jobs:
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Set up Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 24
cache: "npm"
+3 -3
View File
@@ -25,14 +25,14 @@ jobs:
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Initialize CodeQL
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v3
uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v3
with:
languages: ${{ matrix.language }}
- name: Autobuild
uses: github/codeql-action/autobuild@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v3
uses: github/codeql-action/autobuild@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v3
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v3
uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v3
with:
category: "/language:${{ matrix.language }}"
+3 -3
View File
@@ -30,7 +30,7 @@ jobs:
relevant: ${{ steps.filter.outputs.relevant }}
steps:
- uses: actions/checkout@v7
- uses: dorny/paths-filter@v4.0.1
- uses: dorny/paths-filter@v4.0.2
id: filter
with:
filters: |
@@ -69,7 +69,7 @@ jobs:
- name: Checkout
uses: actions/checkout@v7
- name: Install uv and Python
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
with:
python-version: "3.14"
enable-cache: true
@@ -92,7 +92,7 @@ jobs:
- name: Checkout
uses: actions/checkout@v7
- name: Install uv and Python
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
with:
python-version: "3.14"
enable-cache: true
+1 -1
View File
@@ -25,7 +25,7 @@ COPY src/frontend/ ./
RUN npm run build
# Use python-slim as the base image
FROM python:3.14.6-slim@sha256:b877e50bd90de10af8d82c57a022fc2e0dc731c5320d762a27986facfc3355c1 AS base
FROM python:3.14.6-slim@sha256:cea0e6040540fb2b965b6e7fb5ffa00871e632eef63719f0ea54bca189ce14a6 AS base
COPY --from=ghcr.io/astral-sh/uv:0.11.3@sha256:90bbb3c16635e9627f49eec6539f956d70746c409209041800a0280b93152823 /uv /uvx /bin/
+60 -53
View File
@@ -1,72 +1,79 @@
[
{ "language": "English", "code": "en" },
{ "language": "Chinese", "code": "zh" },
{ "language": "Russian", "code": "ru" },
{ "language": "Spanish", "code": "es" },
{ "language": "French", "code": "fr" },
{ "language": "German", "code": "de" },
{ "language": "Italian", "code": "it" },
{ "language": "Portuguese", "code": "pt" },
{ "language": "Polish", "code": "pl" },
{ "language": "Bulgarian", "code": "bg" },
{ "language": "Dutch", "code": "nl" },
{ "language": "Japanese", "code": "ja" },
{ "language": "Arabic", "code": "ar" },
{ "language": "Hebrew", "code": "he" },
{ "language": "Turkish", "code": "tr" },
{ "language": "Hungarian", "code": "hu" },
{ "language": "Latin", "code": "la" },
{ "language": "Czech", "code": "cs" },
{ "language": "Korean", "code": "ko" },
{ "language": "Ukrainian", "code": "uk" },
{ "language": "Indonesian", "code": "id" },
{ "language": "Romanian", "code": "ro" },
{ "language": "Swedish", "code": "sv" },
{ "language": "Greek", "code": "el" },
{ "language": "Lithuanian", "code": "lt" },
{ "language": "Bangla", "code": "bn" },
{ "language": "Traditional Chinese", "code": "zh‑Hant" },
{ "language": "Afrikaans", "code": "af" },
{ "language": "Catalan", "code": "ca" },
{ "language": "Danish", "code": "da" },
{ "language": "Thai", "code": "th" },
{ "language": "Hindi", "code": "hi" },
{ "language": "Irish", "code": "ga" },
{ "language": "Latvian", "code": "lv" },
{ "language": "English", "code": "en", "aliases": ["eng"] },
{ "language": "Chinese", "code": "zh", "aliases": ["chi", "zho"] },
{ "language": "Russian", "code": "ru", "aliases": ["rus"] },
{ "language": "Spanish", "code": "es", "aliases": ["spa"] },
{ "language": "French", "code": "fr", "aliases": ["fra", "fre"] },
{ "language": "German", "code": "de", "aliases": ["deu", "ger"] },
{ "language": "Italian", "code": "it", "aliases": ["ita"] },
{ "language": "Portuguese", "code": "pt", "aliases": ["por"] },
{ "language": "Polish", "code": "pl", "aliases": ["pol"] },
{ "language": "Bulgarian", "code": "bg", "aliases": ["bul"] },
{ "language": "Dutch", "code": "nl", "aliases": ["dut", "nld"] },
{ "language": "Japanese", "code": "ja", "aliases": ["jap", "jpn"] },
{ "language": "Arabic", "code": "ar", "aliases": ["ara"] },
{ "language": "Hebrew", "code": "he", "aliases": ["heb"] },
{ "language": "Turkish", "code": "tr", "aliases": ["tur"] },
{ "language": "Hungarian", "code": "hu", "aliases": ["hun"] },
{ "language": "Latin", "code": "la", "aliases": ["lat"] },
{ "language": "Czech", "code": "cs", "aliases": ["ces", "cze"] },
{ "language": "Korean", "code": "ko", "aliases": ["kor"] },
{ "language": "Ukrainian", "code": "uk", "aliases": ["ukr"] },
{ "language": "Indonesian", "code": "id", "aliases": ["ind"] },
{ "language": "Romanian", "code": "ro", "aliases": ["rom", "ron"] },
{ "language": "Swedish", "code": "sv", "aliases": ["swe"] },
{ "language": "Greek", "code": "el", "aliases": ["ell", "gre"] },
{ "language": "Lithuanian", "code": "lt", "aliases": ["lit"] },
{ "language": "Bangla", "code": "bn", "aliases": ["ben", "bengali"] },
{ "language": "Traditional Chinese", "code": "zh-Hant", "aliases": ["zh‑Hant"] },
{ "language": "Afrikaans", "code": "af", "aliases": ["afr"] },
{ "language": "Catalan", "code": "ca", "aliases": ["cat"] },
{ "language": "Danish", "code": "da", "aliases": ["dan"] },
{ "language": "Thai", "code": "th", "aliases": ["tha"] },
{ "language": "Hindi", "code": "hi", "aliases": ["hin"] },
{ "language": "Irish", "code": "ga", "aliases": ["gle"] },
{ "language": "Latvian", "code": "lv", "aliases": ["lav"] },
{ "language": "Tibetan", "code": "bo" },
{ "language": "Kannada", "code": "kn" },
{ "language": "Serbian", "code": "sr" },
{ "language": "Persian", "code": "fa" },
{ "language": "Croatian", "code": "hr" },
{ "language": "Kannada", "code": "kn", "aliases": ["kan"] },
{ "language": "Serbian", "code": "sr", "aliases": ["srp"] },
{ "language": "Persian", "code": "fa", "aliases": ["farsi", "fas", "per"] },
{ "language": "Croatian", "code": "hr", "aliases": ["hrv"] },
{ "language": "Slovak", "code": "sk" },
{ "language": "Javanese", "code": "jv" },
{ "language": "Vietnamese", "code": "vi" },
{ "language": "Urdu", "code": "ur" },
{ "language": "Finnish", "code": "fi" },
{ "language": "Norwegian", "code": "no" },
{ "language": "Javanese", "code": "jv", "aliases": ["jav"] },
{ "language": "Vietnamese", "code": "vi", "aliases": ["vie"] },
{ "language": "Urdu", "code": "ur", "aliases": ["urd"] },
{ "language": "Finnish", "code": "fi", "aliases": ["fin"] },
{ "language": "Norwegian", "code": "no", "aliases": ["nor"] },
{ "language": "Kinyarwanda", "code": "rw" },
{ "language": "Tamil", "code": "ta" },
{ "language": "Tamil", "code": "ta", "aliases": ["tam"] },
{ "language": "Belarusian", "code": "be" },
{ "language": "Kazakh", "code": "kk" },
{ "language": "Mongolian", "code": "mn" },
{ "language": "Georgian", "code": "ka" },
{ "language": "Slovenian", "code": "sl" },
{ "language": "Slovenian", "code": "sl", "aliases": ["slv"] },
{ "language": "Esperanto", "code": "eo" },
{ "language": "Galician", "code": "gl" },
{ "language": "Marathi", "code": "mr" },
{ "language": "Filipino", "code": "fil" },
{ "language": "Gujarati", "code": "gu" },
{ "language": "Malayalam", "code": "ml" },
{ "language": "Marathi", "code": "mr", "aliases": ["mar"] },
{ "language": "Filipino", "code": "fil", "aliases": ["tagalog", "tgl"] },
{ "language": "Gujarati", "code": "gu", "aliases": ["guj"] },
{ "language": "Malayalam", "code": "ml", "aliases": ["mal"] },
{ "language": "Kyrgyz", "code": "ky" },
{ "language": "Azerbaijani", "code": "az" },
{ "language": "Quechua", "code": "qu" },
{ "language": "Swahili", "code": "sw" },
{ "language": "Bashkir", "code": "ba" },
{ "language": "Punjabi", "code": "pa" },
{ "language": "Malay", "code": "ms" },
{ "language": "Telugu", "code": "te" },
{ "language": "Punjabi", "code": "pa", "aliases": ["pan"] },
{ "language": "Malay", "code": "ms", "aliases": ["may", "msa"] },
{ "language": "Telugu", "code": "te", "aliases": ["tel"] },
{ "language": "Albanian", "code": "sq" },
{ "language": "Uyghur", "code": "ug" },
{ "language": "Armenian", "code": "hy" },
{ "language": "Shan", "code": "shn" }
{ "language": "Shan", "code": "shn" },
{ "language": "Bosnian", "code": "bs", "aliases": ["bos"] },
{ "language": "Burmese", "code": "my", "aliases": ["bur", "mya"] },
{ "language": "Estonian", "code": "et", "aliases": ["est"] },
{ "language": "Icelandic", "code": "is", "aliases": ["ice", "isl"] },
{ "language": "Manx", "code": "gv", "aliases": ["glv"] },
{ "language": "Scottish Gaelic", "code": "gd", "aliases": ["gla"] },
{ "language": "Sanskrit", "code": "sa", "aliases": ["san"] }
]
+87 -17
View File
@@ -432,8 +432,8 @@ The release source tab to open by default in the release modal for audiobooks. U
| `BOOKS_OUTPUT_MODE` | Choose where completed book files are sent. | string (choice) | `folder` |
| `INGEST_DIR` | Directory where downloaded files are saved. Use {User} for per-user folders (e.g. /books/{User}). | string | `/books` |
| `FILE_ORGANIZATION` | Choose how downloaded book files are named and organized. | string (choice) | `rename` |
| `TEMPLATE_RENAME` | Variables: {Author}, {Title}, {Year}, {User}, {OriginalName} (source filename without extension). Universal adds: {Series}, {SeriesPosition}, {Subtitle}, {PrimaryTitle}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. Rename templates are filename-only (no '/' or '\'); use Organize for folders. Applies to single-file downloads. | string | `{Author} - {Title} ({Year})` |
| `TEMPLATE_ORGANIZE` | Use / to create folders. Variables: {Author}, {Title}, {Year}, {User}, {OriginalName} (source filename without extension). Universal adds: {Series}, {SeriesPosition}, {Subtitle}, {PrimaryTitle}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. | string | `{Author}/{Title} ({Year})` |
| `TEMPLATE_RENAME` | Variables: {Author}, {Title}, {Year}, {Language}, {User}, {OriginalName} (source filename without extension). Universal adds: {Series}, {SeriesPosition}, {Subtitle}, {PrimaryTitle}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. Rename templates are filename-only (no '/' or '\'); use Organize for folders. Applies to single-file downloads. | string | `{Author} - {Title} ({Year})` |
| `TEMPLATE_ORGANIZE` | Use / to create folders. Variables: {Author}, {Title}, {Year}, {Language}, {User}, {OriginalName} (source filename without extension). Universal adds: {Series}, {SeriesPosition}, {Subtitle}, {PrimaryTitle}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. | string | `{Author}/{Title} ({Year})` |
| `HARDLINK_TORRENTS` | Create hardlinks instead of copying. Preserves seeding but archives won't be extracted. Don't use if destination is a library ingest folder. | boolean | `false` |
| `BOOKLORE_HOST` | Base URL of your Grimmory instance | string | _none_ |
| `BOOKLORE_USERNAME` | Grimmory account username | string | _none_ |
@@ -454,8 +454,8 @@ The release source tab to open by default in the release modal for audiobooks. U
| `EMAIL_ALLOW_UNVERIFIED_TLS` | Disable TLS certificate verification (not recommended). | boolean | `false` |
| `DESTINATION_AUDIOBOOK` | Directory where downloaded audiobook files are saved. Leave empty to use the Books destination. | string | _none_ |
| `FILE_ORGANIZATION_AUDIOBOOK` | Choose how downloaded audiobook files are named and organized. | string (choice) | `rename` |
| `TEMPLATE_AUDIOBOOK_RENAME` | Variables: {Author}, {Title}, {Year}, {User}, {OriginalName} (source filename without extension), {Series}, {SeriesPosition}, {Subtitle}, {PrimaryTitle}, {PartNumber}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. Rename templates are filename-only (no '/' or '\'); use Organize for folders. Applies to single-file downloads. | string | `{Author} - {Title}` |
| `TEMPLATE_AUDIOBOOK_ORGANIZE` | Use / to create folders. Variables: {Author}, {Title}, {Year}, {User}, {OriginalName} (source filename without extension), {Series}, {SeriesPosition}, {Subtitle}, {PrimaryTitle}, {PartNumber}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. | string | `{Author}/{Title}/{Title}` |
| `TEMPLATE_AUDIOBOOK_RENAME` | Variables: {Author}, {Title}, {Year}, {Language}, {User}, {OriginalName} (source filename without extension), {Series}, {SeriesPosition}, {Subtitle}, {PrimaryTitle}, {PartNumber}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. Rename templates are filename-only (no '/' or '\'); use Organize for folders. Applies to single-file downloads. | string | `{Author} - {Title}` |
| `TEMPLATE_AUDIOBOOK_ORGANIZE` | Use / to create folders. Variables: {Author}, {Title}, {Year}, {Language}, {User}, {OriginalName} (source filename without extension), {Series}, {SeriesPosition}, {Subtitle}, {PrimaryTitle}, {PartNumber}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. | string | `{Author}/{Title}/{Title}` |
| `HARDLINK_TORRENTS_AUDIOBOOK` | Create hardlinks instead of copying. Preserves seeding but archives won't be extracted. Don't use if destination is a library ingest folder. | boolean | `true` |
| `AUTO_OPEN_DOWNLOADS_SIDEBAR` | Automatically open the downloads sidebar when a new download is queued. | boolean | `false` |
| `DOWNLOAD_TO_BROWSER_CONTENT_TYPES` | Automatically download completed files to your browser for the selected content types. | string (comma-separated) | _empty list_ |
@@ -499,7 +499,7 @@ Choose how downloaded book files are named and organized.
**Naming Template**
Variables: {Author}, {Title}, {Year}, {User}, {OriginalName} (source filename without extension). Universal adds: {Series}, {SeriesPosition}, {Subtitle}, {PrimaryTitle}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. Rename templates are filename-only (no '/' or '\'); use Organize for folders. Applies to single-file downloads.
Variables: {Author}, {Title}, {Year}, {Language}, {User}, {OriginalName} (source filename without extension). Universal adds: {Series}, {SeriesPosition}, {Subtitle}, {PrimaryTitle}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. Rename templates are filename-only (no '/' or '\'); use Organize for folders. Applies to single-file downloads.
- **Type:** string
- **Default:** `{Author} - {Title} ({Year})`
@@ -508,7 +508,7 @@ Variables: {Author}, {Title}, {Year}, {User}, {OriginalName} (source filename wi
**Path Template**
Use / to create folders. Variables: {Author}, {Title}, {Year}, {User}, {OriginalName} (source filename without extension). Universal adds: {Series}, {SeriesPosition}, {Subtitle}, {PrimaryTitle}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty.
Use / to create folders. Variables: {Author}, {Title}, {Year}, {Language}, {User}, {OriginalName} (source filename without extension). Universal adds: {Series}, {SeriesPosition}, {Subtitle}, {PrimaryTitle}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty.
- **Type:** string
- **Default:** `{Author}/{Title} ({Year})`
@@ -709,7 +709,7 @@ Choose how downloaded audiobook files are named and organized.
**Naming Template**
Variables: {Author}, {Title}, {Year}, {User}, {OriginalName} (source filename without extension), {Series}, {SeriesPosition}, {Subtitle}, {PrimaryTitle}, {PartNumber}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. Rename templates are filename-only (no '/' or '\'); use Organize for folders. Applies to single-file downloads.
Variables: {Author}, {Title}, {Year}, {Language}, {User}, {OriginalName} (source filename without extension), {Series}, {SeriesPosition}, {Subtitle}, {PrimaryTitle}, {PartNumber}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. Rename templates are filename-only (no '/' or '\'); use Organize for folders. Applies to single-file downloads.
- **Type:** string
- **Default:** `{Author} - {Title}`
@@ -718,7 +718,7 @@ Variables: {Author}, {Title}, {Year}, {User}, {OriginalName} (source filename wi
**Path Template**
Use / to create folders. Variables: {Author}, {Title}, {Year}, {User}, {OriginalName} (source filename without extension), {Series}, {SeriesPosition}, {Subtitle}, {PrimaryTitle}, {PartNumber}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty.
Use / to create folders. Variables: {Author}, {Title}, {Year}, {Language}, {User}, {OriginalName} (source filename without extension), {Series}, {SeriesPosition}, {Subtitle}, {PrimaryTitle}, {PartNumber}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty.
- **Type:** string
- **Default:** `{Author}/{Title}/{Title}`
@@ -1211,6 +1211,7 @@ How long to cache individual book details. Default: 600 (10 minutes). Max: 60480
| `PROWLARR_API_KEY` | Found in Prowlarr: Settings > General > API Key | string (secret) | _none_ |
| `PROWLARR_INDEXERS` | Select which indexers to search. 📚 = has book categories. Leave empty to search all. | string (comma-separated) | _empty list_ |
| `PROWLARR_AUTO_EXPAND` | Automatically retry search without category filtering if no results are found | boolean | `false` |
| `PROWLARR_COLLAPSE_DUPLICATES` | Collapse a release that several indexer entries returned down to a single row, keeping the entry with the best Prowlarr priority. Turn this off to see every entry that carried it, which is what makes results from filter-specific entries (freeleech and the like) visible. | boolean | `true` |
| `PROWLARR_USE_SEED_PREFERENCES` | Apply per-indexer seed time and ratio preferences from Prowlarr when sending torrents to the download client | boolean | `false` |
<details>
@@ -1263,6 +1264,15 @@ Automatically retry search without category filtering if no results are found
- **Type:** boolean
- **Default:** `false`
#### `PROWLARR_COLLAPSE_DUPLICATES`
**Show one row per release**
Collapse a release that several indexer entries returned down to a single row, keeping the entry with the best Prowlarr priority. Turn this off to see every entry that carried it, which is what makes results from filter-specific entries (freeleech and the like) visible.
- **Type:** boolean
- **Default:** `true`
#### `PROWLARR_USE_SEED_PREFERENCES`
**Use Prowlarr seed preferences**
@@ -1395,9 +1405,11 @@ Delay between requests in seconds to avoid rate limiting (0-10).
| `IRC_SERVER` | IRC server hostname | string | _none_ |
| `IRC_PORT` | IRC server port (usually 6697 for TLS, 6667 for plain) | number | `6697` |
| `IRC_USE_TLS` | Enable TLS/SSL encryption for the IRC connection. Disable for servers that don't support TLS. | boolean | `true` |
| `IRC_CHANNEL` | Channel name without the # prefix | string | _none_ |
| `IRC_CHANNEL` | Channel name without the # prefix. Used for all searches unless a separate audiobook channel is configured below. | string | _none_ |
| `IRC_NICK` | Your IRC nickname (required). Must be unique on the IRC network. | string | _none_ |
| `IRC_SEARCH_BOT` | The search bot to address queries to (required). | string | _none_ |
| `IRC_SEARCH_BOT` | The search bot to address queries to (required). Searches are sent as "@<bot> <query>". | string | _none_ |
| `IRC_AUDIOBOOK_CHANNEL` | Optional. Channel name (without the # prefix) to use for audiobook searches. Leave blank to use the main channel above for audiobooks too. | string | _none_ |
| `IRC_AUDIOBOOK_SEARCH_BOT` | Optional. Search bot for the audiobook channel. Leave blank to reuse the main search bot above. Only used when an audiobook channel is set. | string | _none_ |
| `IRC_CACHE_TTL` | How long to keep cached search results before they expire. | string (choice) | `2592000` |
<details>
@@ -1435,7 +1447,7 @@ Enable TLS/SSL encryption for the IRC connection. Disable for servers that don't
**Channel**
Channel name without the # prefix
Channel name without the # prefix. Used for all searches unless a separate audiobook channel is configured below.
- **Type:** string
- **Default:** _none_
@@ -1455,12 +1467,30 @@ Your IRC nickname (required). Must be unique on the IRC network.
**Search bot**
The search bot to address queries to (required). Searches are sent as "@<bot> <query>". Without it, queries would be posted unaddressed to the channel.
The search bot to address queries to (required). Searches are sent as "@<bot> <query>".
- **Type:** string
- **Default:** _none_
- **Required:** Yes
#### `IRC_AUDIOBOOK_CHANNEL`
**Audiobook channel**
Optional. Channel name (without the # prefix) to use for audiobook searches. Leave blank to use the main channel above for audiobooks too.
- **Type:** string
- **Default:** _none_
#### `IRC_AUDIOBOOK_SEARCH_BOT`
**Audiobook search bot**
Optional. Search bot for the audiobook channel. Leave blank to reuse the main search bot above. Only used when an audiobook channel is set.
- **Type:** string
- **Default:** _none_
#### `IRC_CACHE_TTL`
**Cache Duration**
@@ -1481,6 +1511,7 @@ How long to keep cached search results before they expire.
| `QBITTORRENT_URL` | Web UI URL of your qBittorrent instance | string | _none_ |
| `QBITTORRENT_USERNAME` | qBittorrent Web UI username | string | _none_ |
| `QBITTORRENT_PASSWORD` | qBittorrent Web UI password | string (secret) | _none_ |
| `QBITTORRENT_API_KEY` | Found in qBittorrent: Options > Web UI > API Key (qBittorrent 5.2.0+). Used instead of the username and password when set. | string (secret) | _none_ |
| `QBITTORRENT_CATEGORY` | Category to assign to book downloads in qBittorrent | string | `books` |
| `QBITTORRENT_CATEGORY_AUDIOBOOK` | Category for audiobook downloads. Leave empty to use the book category. | string | _empty string_ |
| `QBITTORRENT_DOWNLOAD_DIR` | Server-side directory where torrents are downloaded (optional, uses qBittorrent default if not specified) | string | _none_ |
@@ -1500,9 +1531,11 @@ How long to keep cached search results before they expire.
| `RTORRENT_URL` | XML-RPC URL of your rTorrent instance | string | _none_ |
| `RTORRENT_USERNAME` | HTTP Basic auth username (if authentication enabled) | string | _none_ |
| `RTORRENT_PASSWORD` | HTTP Basic auth password | string (secret) | _none_ |
| `RTORRENT_LABEL` | Label to assign to book downloads in rTorrent | string | `cwabd` |
| `RTORRENT_LABEL` | Label to assign to ebook downloads in rTorrent | string | `cwabd` |
| `RTORRENT_AUDIOBOOK_LABEL` | Label to assign to audiobook downloads in rTorrent (falls back to Book Label if not set) | string | _none_ |
| `RTORRENT_DOWNLOAD_DIR` | Server-side directory where torrents are downloaded (optional, uses rTorrent default if not specified) | string | _none_ |
| `PROWLARR_TORRENT_ACTION` | Remove deletes the torrent from your client immediately after import (stops seeding, files are kept); Keep leaves it in the client to continue seeding | string (choice) | `keep` |
| `PROWLARR_TORRENT_ACTION` | Choose whether to keep, remove, or move the torrent to another category or label after import | string (choice) | `keep` |
| `PROWLARR_TORRENT_POST_IMPORT_CATEGORY` | Category or label to assign after a successful import | string | _empty string_ |
| `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` |
@@ -1555,6 +1588,15 @@ qBittorrent Web UI password
- **Type:** string (secret)
- **Default:** _none_
#### `QBITTORRENT_API_KEY`
**API Key**
Found in qBittorrent: Options > Web UI > API Key (qBittorrent 5.2.0+). Used instead of the username and password when set.
- **Type:** string (secret)
- **Default:** _none_
#### `QBITTORRENT_CATEGORY`
**Book Category**
@@ -1730,11 +1772,20 @@ HTTP Basic auth password
**Book Label**
Label to assign to book downloads in rTorrent
Label to assign to ebook downloads in rTorrent
- **Type:** string
- **Default:** `cwabd`
#### `RTORRENT_AUDIOBOOK_LABEL`
**Audiobook Label**
Label to assign to audiobook downloads in rTorrent (falls back to Book Label if not set)
- **Type:** string
- **Default:** _none_
#### `RTORRENT_DOWNLOAD_DIR`
**Download Directory**
@@ -1748,11 +1799,20 @@ Server-side directory where torrents are downloaded (optional, uses rTorrent def
**Torrent Completion Action**
Remove deletes the torrent from your client immediately after import (stops seeding, files are kept); Keep leaves it in the client to continue seeding
Choose whether to keep, remove, or move the torrent to another category or label after import
- **Type:** string (choice)
- **Default:** `keep`
- **Options:** `keep` (Keep), `remove` (Remove)
- **Options:** `keep` (Keep), `remove` (Remove), `change_category` (Change Category)
#### `PROWLARR_TORRENT_POST_IMPORT_CATEGORY`
**Post-Import Category**
Category or label to assign after a successful import
- **Type:** string
- **Default:** _empty string_
#### `PROWLARR_USENET_CLIENT`
@@ -2011,6 +2071,7 @@ Default sort order for Google Books search results.
| Variable | Description | Type | Default |
|----------|-------------|------|---------|
| `DIRECT_DOWNLOAD_ENABLED` | Show Direct Download in release-source lists and allow Direct mode searches. Add your own mirror URLs in the Mirrors tab before using it. | boolean | `false` |
| `DIRECT_DOWNLOAD_LANGUAGE_FROM_PATH` | When language metadata is missing or unknown, parse the distant path (file path shown in search results) for language tags like [BD FR] or [En]. Also enables local language filtering so lgli files without AA language metadata are not excluded before the distant path can be checked. | boolean | `false` |
| `AA_DONATOR_KEY` | Enables fast download access on AA. Get this from your donator account page. | string (secret) | _none_ |
| `FAST_SOURCES_DISPLAY` | Always tried first, no waiting or bypass required. | JSON array | _see UI for defaults_ |
| `SOURCE_PRIORITY` | Fallback sources, may have waiting. Requires bypasser. Drag to reorder. | JSON array | _see UI for defaults_ |
@@ -2038,6 +2099,15 @@ Show Direct Download in release-source lists and allow Direct mode searches. Add
- **Type:** boolean
- **Default:** `false`
#### `DIRECT_DOWNLOAD_LANGUAGE_FROM_PATH`
**Detect Language From Distant Path**
When language metadata is missing or unknown, parse the distant path (file path shown in search results) for language tags like [BD FR] or [En]. Also enables local language filtering so lgli files without AA language metadata are not excluded before the distant path can be checked.
- **Type:** boolean
- **Default:** `false`
#### `AA_DONATOR_KEY`
**Account Donator Key**
+16 -2
View File
@@ -30,7 +30,19 @@ Configure in **Settings → Security → Authentication Method → OIDC**.
| Auto-Provision Users | Create accounts on first login | `true` |
| Login Button Label | Custom text for the sign-in button | — |
Use **Test Connection** to verify discovery and client configuration before attempting login.
Use **Test Connection** to verify discovery, client configuration, and the provider's token signing keys (JWKS) before attempting login.
> **Authentik users:** make sure your provider has a **Signing Key** selected (e.g. the default self-signed certificate). Without one, Authentik serves an empty JWKS document and every login fails with an OIDC callback error, even though the discovery document looks healthy.
## Account Linking
On login, Shelfmark matches the OIDC identity to a user account in this order:
1. **OIDC subject** — a user who has logged in through this provider before.
2. **Email** — a local account with the same (unique) email address. This only happens when the provider also asserts `email_verified: true` for the address; an unverified email would let anyone claim a local account by registering its address at the IdP.
3. Otherwise, a new account is created when **Auto-Provision Users** is enabled (username conflicts get a numeric suffix), or the login is rejected with "Account not found" when it is disabled.
If the `email_verified` claim is missing or `false`, email linking is silently skipped — a common surprise when the address was never verified at the identity provider (e.g. Keycloak's **Email verified** toggle on the user, or Authentik accounts created without email verification). Make sure the `email` scope is requested and the address is marked verified in your IdP.
## Environment Variables
@@ -46,6 +58,8 @@ If `DISABLE_LOCAL_AUTH` and `OIDC_AUTO_REDIRECT` are both enabled, users are red
## Troubleshooting
- **No token signing keys (empty JWKS)** — The provider's JWKS endpoint returned no keys, so ID tokens can't be verified. In Authentik this happens when the provider has no **Signing Key** selected; pick one (e.g. the default self-signed certificate) and try again.
- **Issuer validation failed** — The issuer in the token doesn't match the discovery document. Check your provider's external URL / issuer configuration.
- **Callback URL mismatch** — Reverse proxy isn't forwarding `X-Forwarded-Proto` or `X-Forwarded-Host`, so the constructed callback URL doesn't match what's registered in the provider.
- **Account not found** — Auto-provision is disabled and the user hasn't been pre-created by an admin.
- **Account not found** — Auto-provision is disabled and the user hasn't been pre-created by an admin. If you pre-created the account with a matching email, see [Account Linking](#account-linking): the provider must send `email_verified: true` for linking to happen.
- **Login created a duplicate account instead of linking to my local one** — Email linking requires a verified email; see [Account Linking](#account-linking). With `DEBUG=true`, the log notes when linking is skipped because the address isn't verified.
+3 -3
View File
@@ -19,7 +19,7 @@ dependencies = [
"psutil",
"emoji",
"rarfile",
"qbittorrent-api",
"qbittorrent-api>=2026.5.3",
"transmission-rpc",
"authlib>=1.7.2,<1.8",
"apprise>=1.12.0",
@@ -29,7 +29,7 @@ dependencies = [
browser = [
"pyvirtualdisplay",
"pyautogui",
"seleniumbase==4.50.5",
"seleniumbase==4.51.8",
"python-xlib",
]
@@ -40,7 +40,7 @@ dev = [
"pytest",
"pytest-cov",
"pytest-xdist>=3.8.0",
"ruff==0.15.20",
"ruff==0.16.0",
"vulture>=2.14",
]
+19 -1
View File
@@ -115,7 +115,7 @@ def check_oidc_connection(
response.raise_for_status()
document = response.json()
required_fields = ["issuer", "authorization_endpoint", "token_endpoint"]
required_fields = ["issuer", "authorization_endpoint", "token_endpoint", "jwks_uri"]
missing_fields = [field for field in required_fields if field not in document]
if missing_fields:
return {
@@ -123,6 +123,24 @@ def check_oidc_connection(
"message": f"Discovery document missing fields: {', '.join(missing_fields)}",
}
# Logins verify the ID token against the provider's JWKS, so an empty key
# set (e.g. an Authentik provider with no Signing Key selected) means every
# login will fail even though discovery looks healthy.
jwks_uri = str(document["jwks_uri"])
jwks_response = requests.get(jwks_uri, timeout=10, verify=get_ssl_verify(jwks_uri))
jwks_response.raise_for_status()
jwks_document = jwks_response.json()
jwks_keys = jwks_document.get("keys") if isinstance(jwks_document, dict) else None
if not jwks_keys:
return {
"success": False,
"message": (
"Discovery document is valid, but the provider returned no token "
"signing keys (empty JWKS), so logins will fail. If you use "
"Authentik, select a Signing Key in the provider settings."
),
}
return {"success": True, "message": f"Connected to {document['issuer']}"}
except Exception as exc:
logger.exception("OIDC connection test failed")
+7 -10
View File
@@ -1,6 +1,5 @@
"""Core settings registration and derived configuration values."""
import json
from pathlib import Path
from typing import Any
@@ -15,6 +14,7 @@ from shelfmark.config.download_settings_handlers import (
check_books_destination,
)
from shelfmark.config.email_settings import check_email_connection
from shelfmark.core.languages import supported_book_languages
from shelfmark.core.logger import setup_logger
from shelfmark.core.settings_registry import (
ActionButton,
@@ -143,11 +143,8 @@ for key in ["CONFIG_DIR", "LOG_DIR", "TMP_DIR", "INGEST_DIR", "DEBUG", "DOCKERMO
if hasattr(env, key):
logger.debug(" %s: %s", key, getattr(env, key))
# Load supported book languages from data file
# Path is relative to the package root, not this file
_DATA_DIR = Path(__file__).resolve().parent.parent.parent / "data"
with (_DATA_DIR / "book-languages.json").open() as file:
_SUPPORTED_BOOK_LANGUAGE = json.load(file)
# Selectable book languages, without the resolution aliases clients do not need.
_SUPPORTED_BOOK_LANGUAGE = supported_book_languages()
# Directory settings
BASE_DIR = Path(__file__).resolve().parent.parent.parent
@@ -1012,7 +1009,7 @@ def download_settings() -> list[SettingsField]:
key="TEMPLATE_RENAME",
label="Naming Template",
description=(
"Variables: {Author}, {Title}, {Year}, {User}, {OriginalName} "
"Variables: {Author}, {Title}, {Year}, {Language}, {User}, {OriginalName} "
"(source filename without extension). Universal adds: {Series}, "
"{SeriesPosition}, {Subtitle}, {PrimaryTitle}. Use arbitrary prefix/suffix: "
"{Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. "
@@ -1031,7 +1028,7 @@ def download_settings() -> list[SettingsField]:
key="TEMPLATE_ORGANIZE",
label="Path Template",
description=(
"Use / to create folders. Variables: {Author}, {Title}, {Year}, {User}, "
"Use / to create folders. Variables: {Author}, {Title}, {Year}, {Language}, {User}, "
"{OriginalName} (source filename without extension). Universal adds: {Series}, "
"{SeriesPosition}, {Subtitle}, {PrimaryTitle}. Use arbitrary prefix/suffix: "
"{Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty."
@@ -1294,7 +1291,7 @@ def download_settings() -> list[SettingsField]:
key="TEMPLATE_AUDIOBOOK_RENAME",
label="Naming Template",
description=(
"Variables: {Author}, {Title}, {Year}, {User}, {OriginalName} "
"Variables: {Author}, {Title}, {Year}, {Language}, {User}, {OriginalName} "
"(source filename without extension), {Series}, {SeriesPosition}, {Subtitle}, "
"{PrimaryTitle}, {PartNumber}. Use arbitrary prefix/suffix: "
"{Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. "
@@ -1311,7 +1308,7 @@ def download_settings() -> list[SettingsField]:
key="TEMPLATE_AUDIOBOOK_ORGANIZE",
label="Path Template",
description=(
"Use / to create folders. Variables: {Author}, {Title}, {Year}, {User}, "
"Use / to create folders. Variables: {Author}, {Title}, {Year}, {Language}, {User}, "
"{OriginalName} (source filename without extension), {Series}, {SeriesPosition}, "
"{Subtitle}, {PrimaryTitle}, {PartNumber}. Use arbitrary prefix/suffix: "
"{Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty."
+1 -1
View File
@@ -149,7 +149,7 @@ def _find_existing_alias_user(
]
if not candidates:
return None
return sorted(candidates, key=lambda user: int(user.get("id") or 0))[0]
return min(candidates, key=lambda user: int(user.get("id") or 0), default=None)
def _resolve_create_username(
+138
View File
@@ -0,0 +1,138 @@
"""Canonical language resolution shared by every release source.
Release sources report a language in whatever shape their upstream uses: a
two-letter code, an ISO 639-2 three-letter code in either the bibliographic or
terminological form, or an English name. They all need the same ISO 639-1 code
out the other side, so the aliases live in one place (``data/book-languages.json``)
and adding a language means editing one file.
"""
import json
import threading
import unicodedata
from pathlib import Path
from shelfmark.core.logger import setup_logger
logger = setup_logger(__name__)
LANGUAGE_DATA_PATH = Path(__file__).resolve().parents[1].parent / "data" / "book-languages.json"
# Values a source uses to mean "we could not tell".
LANGUAGE_PLACEHOLDERS = frozenset({"", "-", "--", "unknown", "unk", "n/a", "na", "none", "null"})
_ALIAS_TO_CODE: dict[str, str] | None = None
_CODE_TO_NAME: dict[str, str] | None = None
_LOCK = threading.Lock()
# Separators that stand in for the hyphen in a subtag. The dashes turn up in
# codes copied from web pages -- "zh‑Hant" used U+2011, which renders close
# enough to both a hyphen and an underscore to go unnoticed -- and the
# underscore is the spelling Direct Download accepted before this module existed.
_SUBTAG_SEPARATORS = dict.fromkeys(map(ord, "‐‑‒–—―−﹘﹣-_"), "-")
def _fold(value: str) -> str:
"""Casefold, strip accents, and normalize subtag separators, so 'Español'
and 'espanol', or 'zh-Hant', 'zh‑Hant' and 'zh_Hant', all match."""
decomposed = unicodedata.normalize("NFKD", value).translate(_SUBTAG_SEPARATORS)
stripped = "".join(ch for ch in decomposed if not unicodedata.combining(ch))
return " ".join(stripped.split()).casefold()
def _load() -> tuple[dict[str, str], dict[str, str]]:
global _ALIAS_TO_CODE, _CODE_TO_NAME
if _ALIAS_TO_CODE is not None and _CODE_TO_NAME is not None:
return _ALIAS_TO_CODE, _CODE_TO_NAME
with _LOCK:
if _ALIAS_TO_CODE is not None and _CODE_TO_NAME is not None:
return _ALIAS_TO_CODE, _CODE_TO_NAME
alias_to_code: dict[str, str] = {}
code_to_name: dict[str, str] = {}
try:
raw = json.loads(LANGUAGE_DATA_PATH.read_text(encoding="utf-8"))
except OSError, ValueError:
logger.exception("Failed to load language data from %s", LANGUAGE_DATA_PATH)
raw = []
if not isinstance(raw, list):
logger.warning("Language data at %s is not a list", LANGUAGE_DATA_PATH)
raw = []
for item in raw:
if not isinstance(item, dict):
continue
code = str(item.get("code") or "").strip()
name = str(item.get("language") or "").strip()
if not code:
continue
code_to_name.setdefault(code, name or code)
for candidate in (code, name, *(item.get("aliases") or [])):
folded = _fold(str(candidate))
if folded and folded not in LANGUAGE_PLACEHOLDERS:
alias_to_code.setdefault(folded, code)
_ALIAS_TO_CODE = alias_to_code
_CODE_TO_NAME = code_to_name
return alias_to_code, code_to_name
def normalize_language(value: object) -> str | None:
"""Resolve any known spelling of a language to its ISO 639-1 code.
Accepts a two-letter code, an ISO 639-2 three-letter code in either the
bibliographic or terminological form, or an English name. Returns None for
anything unrecognised or for the placeholders a source uses to say it does
not know, so callers can treat "no language" uniformly.
"""
if value is None:
return None
folded = _fold(str(value))
if not folded or folded in LANGUAGE_PLACEHOLDERS:
return None
alias_to_code, _ = _load()
return alias_to_code.get(folded)
def language_name(code: str | None) -> str | None:
"""Return the English name for a language code, or None if unknown."""
if not code:
return None
_, code_to_name = _load()
return code_to_name.get(str(code).strip())
def language_alias_map() -> dict[str, str]:
"""Every known alias mapped to its code, for callers doing their own matching.
Direct Download scans free-text paths and needs the whole alias set up front
to look for, rather than resolving one candidate at a time.
"""
alias_to_code, _ = _load()
return dict(alias_to_code)
def supported_book_languages() -> list[dict[str, str]]:
"""The selectable languages, as ``{"language": ..., "code": ...}``.
Aliases are an implementation detail of resolution, so they are left out of
what the settings dropdown and the API hand to clients.
"""
_, code_to_name = _load()
return [{"language": name, "code": code} for code, name in code_to_name.items()]
def known_language_codes() -> frozenset[str]:
"""Every ISO 639-1 code the bundled language data defines."""
_, code_to_name = _load()
return frozenset(code_to_name)
+4
View File
@@ -108,6 +108,9 @@ class DownloadTask:
retry_expected_hash: str | None = None # Optional torrent hash used to match client downloads
retry_ratio_limit: float | None = None # Optional post-download seeding ratio
retry_seeding_time_limit_minutes: int | None = None # Optional post-download seeding time limit
retry_source_context: dict[str, Any] = field(
default_factory=dict
) # Source-private context for retry/re-resolution
can_retry_without_staged_source: bool = (
True # Whether the source can restart without a preserved staged file
)
@@ -116,6 +119,7 @@ class DownloadTask:
series_name: str | None = None
series_position: float | None = None # Float for novellas (e.g., 1.5)
subtitle: str | None = None # Book subtitle for naming templates
language: str | None = None # Release language code for the {Language} template variable
# Hardlinking support
original_download_path: str | None = None # Path in download client (for hardlinking)
+30 -2
View File
@@ -4,6 +4,7 @@ import re
from pathlib import Path
from typing import TYPE_CHECKING
from shelfmark.core.languages import LANGUAGE_PLACEHOLDERS, normalize_language
from shelfmark.core.logger import setup_logger
if TYPE_CHECKING:
@@ -19,6 +20,7 @@ KNOWN_TOKENS = [
"primarytitle",
"originalname",
"partnumber",
"language",
"subtitle",
"author",
"series",
@@ -66,6 +68,33 @@ def format_series_position(position: str | float | None) -> str:
return str(position)
def normalize_language_code(language: str | None) -> str:
"""Resolve a release language to the single spelling used in a path.
Sources report the same language in different shapes: "en", "eng", "English".
All of them have to collapse to one code, or the editions they identify end
up in separate folders, which is the collision this token exists to prevent.
Placeholder values render empty so `{ (Language)}` disappears entirely
rather than labelling a folder "(unknown)".
A language the bundled data does not know is kept, casefolded, rather than
dropped: it still separates editions, and it cannot collide with a resolved
code precisely because nothing resolves it.
"""
if not language:
return ""
resolved = normalize_language(language)
if resolved is not None:
return resolved
normalized = " ".join(str(language).split()).strip().casefold()
if normalized in LANGUAGE_PLACEHOLDERS:
return ""
return normalized
def derive_primary_title(title: str | None, subtitle: str | None) -> str:
"""Return the title without an explicit subtitle suffix when possible."""
title_value = " ".join(str(title or "").split()).strip()
@@ -91,8 +120,7 @@ PAD_NUMBERS_PATTERN = re.compile(r"\d+")
def natural_sort_key(path: str | Path) -> str:
"""Generate a sort key with padded numbers for natural sorting."""
filename = Path(path).name.lower()
return PAD_NUMBERS_PATTERN.sub(lambda m: m.group().zfill(9), filename)
return PAD_NUMBERS_PATTERN.sub(lambda m: m.group().zfill(9), str(path).lower())
def assign_part_numbers(
+33
View File
@@ -33,6 +33,11 @@ logger = setup_logger(__name__)
oauth = OAuth()
_RETURN_TO_SESSION_KEY = "oidc_return_to"
_OIDC_CLIENT_ERRORS = (OAuthError, OSError, RuntimeError, TypeError, ValueError)
_EMPTY_JWKS_MESSAGE = (
"Authentication failed: the identity provider returned no token signing keys "
"(empty JWKS). If you use Authentik, select a Signing Key in the provider "
"settings and try again."
)
class _ClaimsMappingLike(Protocol):
@@ -121,6 +126,17 @@ def _normalize_return_to(raw_return_to: object) -> str | None:
return urlunsplit(("", "", path, parsed.query, parsed.fragment))
def _idp_jwks_has_no_keys(client: Any) -> bool:
"""Return True when the IdP's JWKS document verifiably contains no signing keys."""
try:
jwk_set = client.fetch_jwk_set(force=True)
except (*_OIDC_CLIENT_ERRORS, KeyError):
return False
if not isinstance(jwk_set, Mapping):
return False
return not jwk_set.get("keys")
def _get_pending_return_to(*, clear: bool = False) -> str | None:
"""Read the pending post-login target from the session."""
raw_return_to = (
@@ -274,6 +290,17 @@ def register_oidc_routes(app: Flask, user_db: UserDB) -> None:
return redirect(
_login_error_url(f"OIDC token claim validation failed: {claim_name}")
)
except KeyError, ValueError:
# An IdP serving an empty JWKS document (e.g. an Authentik provider
# with no Signing Key selected) surfaces as KeyError('keys') while
# importing the key set. Test Connection only validates discovery,
# so this is the first place the misconfiguration becomes visible.
if _idp_jwks_has_no_keys(client):
logger.exception(
"OIDC callback failed: the IdP JWKS document contains no signing keys"
)
return redirect(_login_error_url(_EMPTY_JWKS_MESSAGE))
raise
claims = _normalize_claims(token.get("userinfo"))
# If userinfo is missing or claims are too sparse, request it explicitly.
@@ -306,6 +333,12 @@ def register_oidc_routes(app: Flask, user_db: UserDB) -> None:
is_admin = admin_group in groups
allow_email_link = bool(user_info.get("email")) and _is_email_verified(claims)
if user_info.get("email") and not allow_email_link:
logger.debug(
"OIDC email %s is not marked verified by the IdP; skipping "
"email-based account linking",
user_info["email"],
)
user = provision_oidc_user(
user_db,
user_info,
+18 -2
View File
@@ -325,6 +325,19 @@ class DownloadClient(ABC):
"""
def set_category(self, download_id: str, category: str) -> bool:
"""Update a download's category or label when supported by the client.
Args:
download_id: The client-specific download ID.
category: Category or label to assign.
Returns:
True if the category was updated, otherwise False.
"""
return False
@abstractmethod
def get_download_path(self, download_id: str) -> str | None:
"""Get the path where files were downloaded.
@@ -359,10 +372,13 @@ class DownloadClient(ABC):
# Client registry: protocol -> list of client classes
_CLIENTS: dict[str, list[type[DownloadClient]]] = {}
ClientType = TypeVar("ClientType", bound=DownloadClient)
_BUILTIN_CLIENT_MODULES = (
"shelfmark.download.clients.alldebrid",
"shelfmark.download.clients.deluge",
"shelfmark.download.clients.nzbget",
"shelfmark.download.clients.qbittorrent",
"shelfmark.download.clients.realdebrid",
"shelfmark.download.clients.rtorrent",
"shelfmark.download.clients.sabnzbd",
"shelfmark.download.clients.transmission",
@@ -383,7 +399,7 @@ def _ensure_builtin_clients_registered() -> None:
def register_client(
protocol: str,
) -> Callable[[type[DownloadClient]], type[DownloadClient]]:
) -> Callable[[type[ClientType]], type[ClientType]]:
"""Register a download client for a protocol.
Multiple clients can be registered for the same protocol.
@@ -399,7 +415,7 @@ def register_client(
"""
def decorator(cls: type[DownloadClient]) -> type[DownloadClient]:
def decorator(cls: type[ClientType]) -> type[ClientType]:
if protocol not in _CLIENTS:
_CLIENTS[protocol] = []
_CLIENTS[protocol].append(cls)
+681
View File
@@ -0,0 +1,681 @@
"""AllDebrid debrid service client for Shelfmark.
Routes magnet links through the AllDebrid API (v4/v4.1) to download
torrent content via AllDebrid's CDN infrastructure.
"""
from __future__ import annotations
import shutil
import threading
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, ClassVar, NoReturn
from urllib.parse import quote
import requests
from shelfmark.config.env import TMP_DIR
from shelfmark.core.config import config
from shelfmark.core.logger import setup_logger
from shelfmark.download.clients import (
DownloadClient,
DownloadState,
DownloadStatus,
register_client,
)
from shelfmark.download.clients._coercion import config_text
from shelfmark.download.http import download_url
from shelfmark.download.network import get_ssl_verify
logger = setup_logger(__name__)
_API_BASE = "https://api.alldebrid.com/v4"
_AGENT = "shelfmark"
_ALLDEBRID_CLIENT_ERRORS = (
AttributeError,
OSError,
requests.exceptions.RequestException,
RuntimeError,
TypeError,
ValueError,
)
# AllDebrid magnet status codes (from API v4.1 documentation).
_STATUS_DOWNLOADING = frozenset({0, 1, 2, 3})
_STATUS_READY = 4
# Timeouts and retry limits for API calls.
_API_TIMEOUT = 30
_STATUS_TIMEOUT = 15
_DELAYED_POLL_INTERVAL = 5
_DELAYED_POLL_MAX_ATTEMPTS = 12
# File extensions recognised as book or audiobook content.
_BOOK_EXTENSIONS = (
".aac",
".azw",
".azw3",
".cbr",
".cbz",
".djvu",
".doc",
".docx",
".epub",
".fb2",
".flac",
".lit",
".m4a",
".m4b",
".mobi",
".mp3",
".ogg",
".opus",
".pdf",
".rtf",
".txt",
".wma",
)
def _flatten_magnet_files(
entries: list[dict[str, Any]],
prefix: str = "",
) -> list[dict[str, Any]]:
"""Flatten AllDebrid's nested file tree into a list of file dicts.
AllDebrid returns files with ``"n"`` (name), ``"s"`` (size),
``"l"`` (link), and ``"e"`` (children) keys. Directories use
``"e"`` to nest their contents.
Returns:
List of ``{"filename": ..., "size": ..., "link": ...}`` dicts.
"""
flat: list[dict[str, Any]] = []
for entry in entries:
name = entry.get("n", "")
if "e" in entry:
flat.extend(
_flatten_magnet_files(entry["e"], prefix=f"{prefix}{name}/"),
)
elif entry.get("l"):
flat.append(
{
"filename": f"{prefix}{name}",
"size": entry.get("s", 0),
"link": entry["l"],
}
)
return flat
def _raise_runtime_error(message: str) -> NoReturn:
raise RuntimeError(message)
@dataclass
class _DownloadState:
"""Internal mutable state for an in-progress AllDebrid download."""
magnet_id: str
name: str
target_dir: Path
phase: str = "uploading"
error_message: str | None = None
progress: float = 0.0
download_thread: threading.Thread | None = None
lock: threading.Lock = field(default_factory=threading.Lock)
@register_client("torrent")
class AllDebridClient(DownloadClient):
"""AllDebrid debrid service client.
Downloads torrent content by uploading magnet links to AllDebrid,
waiting for the torrent to complete on their servers, then fetching
the resulting files via direct HTTP download from AllDebrid's CDN.
API documentation: https://docs.alldebrid.com/
"""
protocol = "torrent"
name = "alldebrid"
_downloads: ClassVar[dict[str, _DownloadState]] = {}
_downloads_lock = threading.Lock()
def __init__(self) -> None:
self._api_key = config_text(config.get("ALLDEBRID_API_KEY", ""))
def _auth_headers(self) -> dict[str, str]:
"""Return Authorization header dict for API requests."""
return {"Authorization": f"Bearer {self._api_key}"}
# ------------------------------------------------------------------
# DownloadClient interface
# ------------------------------------------------------------------
@staticmethod
def is_configured() -> bool:
"""Return True when AllDebrid is selected and an API key exists."""
client = config_text(config.get("PROWLARR_TORRENT_CLIENT", ""))
api_key = config_text(config.get("ALLDEBRID_API_KEY", ""))
return client == "alldebrid" and bool(api_key)
def test_connection(self) -> tuple[bool, str]:
"""Validate the API key and check Premium subscription status."""
if not self._api_key:
return False, "AllDebrid API Key is required"
try:
url = f"{_API_BASE}/user"
resp = requests.get(
url,
headers=self._auth_headers(),
timeout=_STATUS_TIMEOUT,
verify=get_ssl_verify(url),
)
resp.raise_for_status()
data = resp.json()
if data.get("status") != "success":
err = data.get("error", {}).get("message", "API error")
return False, f"AllDebrid error: {err}"
user = data.get("data", {}).get("user", {})
username = user.get("username", "Unknown")
if not user.get("isPremium", False):
return (
False,
f"AllDebrid user '{username}' does not have a Premium subscription",
)
except _ALLDEBRID_CLIENT_ERRORS as e:
return False, f"Connection failed: {e}"
else:
return True, f"Connected to AllDebrid as '{username}' (Premium)"
def add_download(
self,
url: str,
name: str,
category: str | None = None,
expected_hash: str | None = None,
**kwargs: object,
) -> str:
"""Upload a magnet link to AllDebrid and return the magnet ID."""
if not self._api_key:
msg = "AllDebrid API key is not configured"
raise RuntimeError(msg)
magnet_link = url
if not magnet_link.startswith("magnet:") and expected_hash:
magnet_link = f"magnet:?xt=urn:btih:{expected_hash}"
api_url = f"{_API_BASE}/magnet/upload"
try:
resp = requests.post(
api_url,
headers=self._auth_headers(),
data={"magnets[]": magnet_link},
timeout=_API_TIMEOUT,
verify=get_ssl_verify(api_url),
)
resp.raise_for_status()
data = resp.json()
if data.get("status") != "success":
code = data.get("error", {}).get("code", "UNKNOWN")
msg = f"AllDebrid upload failed: {code}"
_raise_runtime_error(msg)
magnets = data.get("data", {}).get("magnets", [])
if not magnets:
msg = "No magnet returned from AllDebrid"
_raise_runtime_error(msg)
info = magnets[0]
if info.get("error"):
code = info["error"].get("code", "UNKNOWN")
msg = f"AllDebrid magnet error: {code}"
_raise_runtime_error(msg)
magnet_id = str(info.get("id", ""))
if not magnet_id:
msg = "No magnet ID returned from AllDebrid"
_raise_runtime_error(msg)
target_dir = TMP_DIR / f"alldebrid_{magnet_id}"
target_dir.mkdir(parents=True, exist_ok=True)
state = _DownloadState(
magnet_id=magnet_id,
name=name,
target_dir=target_dir,
phase="waiting_ad",
)
with self._downloads_lock:
self._downloads[magnet_id] = state
logger.info(
"Added torrent to AllDebrid: ID %s (%s)",
magnet_id,
name,
)
except Exception:
logger.exception("Failed to upload magnet to AllDebrid")
raise
else:
return magnet_id
def get_status(self, download_id: str) -> DownloadStatus:
"""Poll AllDebrid for magnet status and drive the download."""
state = self._ensure_state(download_id)
# Return cached terminal / in-flight states immediately.
with state.lock:
if state.phase == "error":
return DownloadStatus.error(
state.error_message or "AllDebrid error",
)
if state.phase == "complete":
return DownloadStatus(
progress=100.0,
state=DownloadState.COMPLETE,
message="Complete",
complete=True,
file_path=str(state.target_dir),
)
if state.phase == "downloading_http":
return DownloadStatus(
progress=state.progress,
state=DownloadState.DOWNLOADING,
message="Downloading files via HTTP...",
complete=False,
file_path=None,
)
# Ask AllDebrid for the current magnet status.
try:
status_url = f"{_API_BASE.replace('/v4', '/v4.1')}/magnet/status"
resp = requests.post(
status_url,
headers=self._auth_headers(),
data={"id": download_id},
timeout=_STATUS_TIMEOUT,
verify=get_ssl_verify(status_url),
)
resp.raise_for_status()
data = resp.json()
if data.get("status") != "success":
err = data.get("error", {}).get("message", "Status failed")
return DownloadStatus.error(
f"AllDebrid status error: {err}",
)
mag = self._extract_magnet_info(data)
return self._handle_magnet_status(mag, state)
except Exception as e:
logger.exception(
"Error checking AllDebrid status for %s",
download_id,
)
return DownloadStatus.error(str(e))
def remove(
self,
download_id: str,
*,
delete_files: bool = False,
) -> bool:
"""Delete the magnet from AllDebrid and clean up local files."""
try:
url = f"{_API_BASE}/magnet/delete"
requests.post(
url,
headers=self._auth_headers(),
data={"id": download_id},
timeout=_STATUS_TIMEOUT,
verify=get_ssl_verify(url),
)
except _ALLDEBRID_CLIENT_ERRORS as e:
logger.warning("Failed to delete magnet from AllDebrid: %s", e)
with self._downloads_lock:
state = self._downloads.pop(download_id, None)
if state and state.target_dir.exists():
shutil.rmtree(state.target_dir, ignore_errors=True)
return True
def get_download_path(self, download_id: str) -> str | None:
"""Return the local directory containing downloaded files."""
with self._downloads_lock:
state = self._downloads.get(download_id)
if state and state.phase == "complete":
return str(state.target_dir)
target_dir = TMP_DIR / f"alldebrid_{download_id}"
if target_dir.exists():
return str(target_dir)
return None
# ------------------------------------------------------------------
# Internal helpers
# ------------------------------------------------------------------
def _ensure_state(self, download_id: str) -> _DownloadState:
"""Get or create download state for the given magnet ID."""
with self._downloads_lock:
state = self._downloads.get(download_id)
if state:
return state
target_dir = TMP_DIR / f"alldebrid_{download_id}"
state = _DownloadState(
magnet_id=download_id,
name=f"Download {download_id}",
target_dir=target_dir,
phase="waiting_ad",
)
with self._downloads_lock:
self._downloads[download_id] = state
return state
@staticmethod
def _extract_magnet_info(data: dict[str, Any]) -> dict[str, Any]:
"""Extract magnet info dict from a status API response."""
mag_data = data.get("data", {}).get("magnets", {})
if isinstance(mag_data, list) and mag_data:
return mag_data[0]
if isinstance(mag_data, dict):
return mag_data
return {}
def _handle_magnet_status(
self,
mag: dict[str, Any],
state: _DownloadState,
) -> DownloadStatus:
"""Map AllDebrid magnet status to a DownloadStatus."""
status_code = mag.get("statusCode")
if status_code in _STATUS_DOWNLOADING:
size = mag.get("size", 0)
downloaded = mag.get("downloaded", 0)
pct = (downloaded / size * 100.0) if size > 0 else 0.0
return DownloadStatus(
progress=pct * 0.5,
state=DownloadState.DOWNLOADING,
message=(f"AllDebrid downloading torrent ({mag.get('filename', state.name)})"),
complete=False,
file_path=None,
download_speed=mag.get("downloadSpeed", 0),
)
if status_code == _STATUS_READY or mag.get("ready", False):
self._maybe_start_download_thread(state)
return DownloadStatus(
progress=50.0,
state=DownloadState.DOWNLOADING,
message="AllDebrid ready, retrieving files...",
complete=False,
file_path=None,
)
# Terminal error from AllDebrid.
error_txt = mag.get("error", {}).get("message") or f"AllDebrid status code {status_code}"
with state.lock:
state.phase = "error"
state.error_message = error_txt
return DownloadStatus.error(error_txt)
def _maybe_start_download_thread(self, state: _DownloadState) -> None:
"""Spawn a background thread to unlock and download files."""
with state.lock:
already_running = state.phase in (
"unlocking",
"downloading_http",
"complete",
)
thread_alive = state.download_thread is not None and state.download_thread.is_alive()
if already_running or thread_alive:
return
state.phase = "unlocking"
t = threading.Thread(
target=self._process_and_download,
args=(state,),
daemon=True,
)
state.download_thread = t
t.start()
# ------------------------------------------------------------------
# Link unlocking
# ------------------------------------------------------------------
def _unlock_file_link(self, link: str) -> str:
"""Resolve an AllDebrid file link to a direct CDN download URL.
AllDebrid's ``/v4/magnet/files`` endpoint returns virtual links
(``alldebrid.com/f/...``) that must be converted to direct CDN
URLs via ``/v4/link/unlock``.
Strategy:
1. If the link is already a CDN URL (``/dl/``), return it.
2. ``POST /v4/link/unlock`` with Bearer auth (primary).
3. ``GET /v4/link/unlock`` with query parameters (fallback).
4. Append ``apikey=`` to ``alldebrid.com/f/`` links
(last-resort fallback for ghost-cached torrents).
"""
# 1. Already a direct CDN link.
if "/dl/" in link:
return link
headers = self._auth_headers()
unlock_url = f"{_API_BASE}/link/unlock"
err_msg = "Unknown unlock error"
# 2. POST unlock (primary method).
try:
resp = requests.post(
unlock_url,
headers=headers,
data={"link": link},
timeout=_API_TIMEOUT,
verify=get_ssl_verify(unlock_url),
)
if resp.status_code == 200:
body = resp.json()
if body.get("status") == "success":
direct = self._resolve_unlock_data(
body.get("data", {}),
headers,
)
if direct:
return direct
err_msg = body.get("error", {}).get(
"message",
"Unlock failed",
)
except _ALLDEBRID_CLIENT_ERRORS as e:
logger.debug("POST unlock exception: %s", e)
# 3. GET unlock fallback with URL-encoded link.
try:
encoded = quote(link, safe="")
get_url = (
f"{_API_BASE}/link/unlock?agent={_AGENT}&apikey={self._api_key}&link={encoded}"
)
resp = requests.get(
get_url,
headers=headers,
timeout=_API_TIMEOUT,
verify=get_ssl_verify(get_url),
)
if resp.status_code == 200:
body = resp.json()
if body.get("status") == "success":
direct = body.get("data", {}).get("link")
if direct:
return direct
err_msg = body.get("error", {}).get("message", err_msg)
except _ALLDEBRID_CLIENT_ERRORS as e:
logger.debug("GET unlock exception: %s", e)
# 4. Last-resort: append apikey to alldebrid.com/f/ links.
if "alldebrid.com/f/" in link:
logger.info(
"Using apikey fallback for AllDebrid file link: %s",
link,
)
if "apikey=" not in link:
sep = "&" if "?" in link else "?"
return f"{link}{sep}apikey={self._api_key}"
return link
logger.error(
"AllDebrid unlock failed for '%s': %s",
link,
err_msg,
)
msg = f"AllDebrid unlock failed: {err_msg}"
raise RuntimeError(msg)
def _resolve_unlock_data(
self,
data: dict[str, Any],
headers: dict[str, str],
) -> str | None:
"""Extract the direct link from unlock response data.
Handles the *delayed link* flow where AllDebrid returns a
``delayed`` ID instead of an immediate download link.
"""
# Delayed link: poll until the CDN file is ready.
if "delayed" in data:
delayed_id = data["delayed"]
logger.info(
"AllDebrid link delayed (ID %s), polling...",
delayed_id,
)
delayed_url = f"{_API_BASE}/link/delayed"
for _ in range(_DELAYED_POLL_MAX_ATTEMPTS):
time.sleep(_DELAYED_POLL_INTERVAL)
try:
resp = requests.post(
delayed_url,
headers=headers,
data={"id": delayed_id},
timeout=_STATUS_TIMEOUT,
verify=get_ssl_verify(delayed_url),
)
if resp.status_code != 200:
continue
body = resp.json()
d = body.get("data", {})
if body.get("status") == "success" and d.get("status") == 2 and d.get("link"):
return d["link"]
except _ALLDEBRID_CLIENT_ERRORS as e:
logger.debug("Delayed poll exception: %s", e)
return data.get("link")
# ------------------------------------------------------------------
# File download pipeline
# ------------------------------------------------------------------
def _process_and_download(self, state: _DownloadState) -> None:
"""Fetch the file list, unlock links, and download via HTTP.
Runs in a background thread spawned by ``_maybe_start_download_thread``.
"""
try:
files = self._fetch_file_list(state.magnet_id)
relevant = [f for f in files if f["filename"].lower().endswith(_BOOK_EXTENSIONS)]
if not relevant:
relevant = files
with state.lock:
state.phase = "downloading_http"
total = len(relevant)
for idx, file_info in enumerate(relevant):
direct_link = self._unlock_file_link(file_info["link"])
rel_path = Path(file_info["filename"])
dest = state.target_dir / rel_path
dest.parent.mkdir(parents=True, exist_ok=True)
logger.info(
"Downloading AllDebrid file %d/%d: %s",
idx + 1,
total,
rel_path,
)
buf = download_url(
direct_link,
referer="https://alldebrid.com/",
)
if not buf:
msg = f"Failed to download from {direct_link}"
_raise_runtime_error(msg)
with dest.open("wb") as fh:
fh.write(buf.getvalue())
with state.lock:
state.progress = 50.0 + (idx + 1) / total * 50.0
with state.lock:
state.phase = "complete"
state.progress = 100.0
logger.info(
"AllDebrid download complete for ID %s at %s",
state.magnet_id,
state.target_dir,
)
except Exception:
logger.exception(
"Error in AllDebrid download for ID %s",
state.magnet_id,
)
with state.lock:
state.phase = "error"
state.error_message = str(
state.error_message or "Download failed",
)
def _fetch_file_list(
self,
magnet_id: str,
) -> list[dict[str, Any]]:
"""Retrieve and flatten the file tree for a magnet."""
url = f"{_API_BASE}/magnet/files"
resp = requests.post(
url,
headers=self._auth_headers(),
data={"id[]": magnet_id},
timeout=_API_TIMEOUT,
verify=get_ssl_verify(url),
)
resp.raise_for_status()
data = resp.json()
if data.get("status") != "success":
msg = f"Failed to list magnet files: {data.get('error')}"
raise RuntimeError(msg)
magnets = data.get("data", {}).get("magnets", [])
if not magnets:
msg = "No magnet files returned"
raise RuntimeError(msg)
files = _flatten_magnet_files(magnets[0].get("files", []))
if not files:
msg = "No files found in torrent"
raise RuntimeError(msg)
return files
+76 -17
View File
@@ -220,6 +220,17 @@ class ExternalClientHandler(DownloadHandler, ABC):
configured = config.get(COMPLETED_PATH_TIMEOUT_SETTING, fallback)
return _coerce_completed_path_timeout_seconds(configured, fallback)
def _refresh_download_request_after_add_failure(
self,
*,
task: DownloadTask,
request: DownloadRequest,
error: Exception,
status_callback: Callable[[str, str | None], None],
) -> DownloadRequest | None:
"""Give source handlers one chance to refresh stale resolved download data."""
return None
def _get_category_for_task(self, client: DownloadClient, task: DownloadTask) -> str | None:
"""Get audiobook category if configured and applicable, else None for default."""
if not is_audiobook(task.content_type):
@@ -273,17 +284,47 @@ class ExternalClientHandler(DownloadHandler, ABC):
)
elif protocol == "torrent":
if config.get("PROWLARR_TORRENT_ACTION", "keep") != "remove":
torrent_action = config.get("PROWLARR_TORRENT_ACTION", "keep")
if torrent_action == "remove":
try:
client.remove(download_id, delete_files=False)
except _CLIENT_CLEANUP_ERRORS as e:
logger.warning(
"Failed to remove torrent %s from %s: %s",
download_id,
getattr(client, "name", "client"),
e,
)
return
if torrent_action != "change_category":
return
post_import_category = normalize_optional_text(
config.get("PROWLARR_TORRENT_POST_IMPORT_CATEGORY", "")
)
if post_import_category is None:
return
try:
client.remove(download_id, delete_files=False)
category_updated = client.set_category(download_id, post_import_category)
except _CLIENT_CLEANUP_ERRORS as e:
logger.warning(
"Failed to remove torrent %s from %s: %s",
"Failed to set post-import category for torrent %s in %s: %s",
download_id,
getattr(client, "name", "client"),
e,
)
return
if not category_updated:
# Clients that cannot label torrents (debrid services) return False here,
# and the ones that can already log the specific failure themselves.
logger.debug(
"Post-import category not applied to torrent %s in %s",
download_id,
getattr(client, "name", "client"),
)
def _remove_usenet_download(
self,
@@ -791,20 +832,38 @@ class ExternalClientHandler(DownloadHandler, ABC):
status_callback("downloading", "Resuming existing download")
else:
# No existing download - add new
status_callback("resolving", f"Sending to {client.name}")
try:
download_id = client.add_download(
url=request.url,
name=request.release_name,
category=category,
expected_hash=request.expected_hash,
seeding_time_limit=request.seeding_time_limit,
ratio_limit=request.ratio_limit,
)
except Exception as e:
logger.exception("Failed to add to %s", client.name)
status_callback("error", f"Failed to add to {client.name}: {e}")
return None
refresh_attempted = False
while True:
status_callback("resolving", f"Sending to {client.name}")
try:
download_id = client.add_download(
url=request.url,
name=request.release_name,
category=category,
expected_hash=request.expected_hash,
seeding_time_limit=request.seeding_time_limit,
ratio_limit=request.ratio_limit,
)
except Exception as e:
if not refresh_attempted:
refresh_attempted = True
refreshed_request = self._refresh_download_request_after_add_failure(
task=task,
request=request,
error=e,
status_callback=status_callback,
)
if (
refreshed_request is not None
and refreshed_request.protocol == request.protocol
):
request = refreshed_request
continue
logger.exception("Failed to add to %s", client.name)
status_callback("error", f"Failed to add to {client.name}: {e}")
return None
break
logger.info(
"Added to %s: %s for '%s'", client.name, download_id, request.release_name
+18 -3
View File
@@ -227,10 +227,10 @@ class DelugeClient(DownloadClient):
return self._rpc_call("daemon.info")
def _try_set_label(self, torrent_id: str, label: str) -> None:
def _try_set_label(self, torrent_id: str, label: str) -> bool:
"""Best-effort label assignment (requires Deluge Label plugin)."""
if not label:
return
return False
try:
# label.add will error if the plugin is unavailable or the label exists.
@@ -240,6 +240,9 @@ class DelugeClient(DownloadClient):
self._rpc_call("label.set_torrent", torrent_id, label)
except _DELUGE_CLIENT_ERRORS as e:
logger.debug("Could not set Deluge label '%s' for %s: %s", label, torrent_id, e)
return False
else:
return True
@staticmethod
def is_configured() -> bool:
@@ -277,7 +280,10 @@ class DelugeClient(DownloadClient):
torrent_info = extract_torrent_info(url, expected_hash=expected_hash)
if not torrent_info.is_magnet and not torrent_info.torrent_data:
_raise_runtime_error("Failed to fetch torrent file")
message = "Failed to fetch torrent file"
if torrent_info.fetch_error:
message = f"{message}: {torrent_info.fetch_error}"
_raise_runtime_error(message)
options: dict[str, Any] = {}
if self._download_dir:
@@ -419,6 +425,15 @@ class DelugeClient(DownloadClient):
else:
return False
def set_category(self, download_id: str, category: str) -> bool:
"""Assign a label to a torrent using Deluge's Label plugin."""
try:
self._ensure_connected()
return self._try_set_label(download_id, category)
except _DELUGE_CLIENT_ERRORS as e:
self._log_error("set_category", e)
return False
def get_download_path(self, download_id: str) -> str | None:
"""Return the resolved download path for a Deluge torrent."""
try:
+51 -12
View File
@@ -182,15 +182,15 @@ class QBittorrentClient(DownloadClient):
params = {"hash": torrent_hash}
try:
self._client.auth_log_in()
self._ensure_authenticated()
response = self._client._session.get(url, params=params, timeout=10)
# Re-authenticate and retry once on 403
if response.status_code == _HTTP_STATUS_FORBIDDEN:
if response.status_code == _HTTP_STATUS_FORBIDDEN and self._can_reauthenticate:
logger.debug(
"qBittorrent returned 403 for properties; re-authenticating and retrying"
)
self._client.auth_log_in()
self._ensure_authenticated()
response = self._client._session.get(url, params=params, timeout=10)
if response.status_code == _HTTP_STATUS_FORBIDDEN:
@@ -244,6 +244,7 @@ class QBittorrentClient(DownloadClient):
username = config_text(config.get("QBITTORRENT_USERNAME", ""))
password = config_text(config.get("QBITTORRENT_PASSWORD", ""))
self._api_key = config_text(config.get("QBITTORRENT_API_KEY", ""))
# qbittorrent-api accepts either a full URL or host:port; prefer the normalized URL
# for consistency.
@@ -251,12 +252,28 @@ class QBittorrentClient(DownloadClient):
host=self._base_url,
username=username,
password=password,
api_key=self._api_key or None,
VERIFY_WEBUI_CERTIFICATE=get_ssl_verify(self._base_url),
)
self._category = config_text(config.get("QBITTORRENT_CATEGORY", "books"))
self._download_dir = config_text(config.get("QBITTORRENT_DOWNLOAD_DIR", ""))
self._tags = _normalize_tags(config.get("QBITTORRENT_TAG", []))
@property
def _can_reauthenticate(self) -> bool:
"""Whether a 403 is worth retrying; a bearer token cannot be refreshed like a session."""
return not self._api_key
def _ensure_authenticated(self) -> None:
"""Authenticate the underlying HTTP session before it is used directly.
API keys (qBittorrent 5.2.0+) are sent as a bearer header on every request and
have no login endpoint, so there is no session to establish up front.
"""
if self._api_key:
return
self._client.auth_log_in()
def _get_torrents_info(
self, torrent_hash: str | None = None, category: str | None = None
) -> tuple[list[SimpleNamespace], str | None]:
@@ -276,8 +293,7 @@ class QBittorrentClient(DownloadClient):
url = f"{self._base_url}/api/v2/torrents/info"
def do_request(params: dict[str, str]) -> requests.Response:
# Ensure session is authenticated before using it directly
self._client.auth_log_in()
self._ensure_authenticated()
return self._client._session.get(url, params=params, timeout=10)
def parse_response(
@@ -285,9 +301,9 @@ class QBittorrentClient(DownloadClient):
*,
request_params: dict[str, str],
) -> tuple[list[SimpleNamespace], str | None]:
if response.status_code == _HTTP_STATUS_FORBIDDEN:
if response.status_code == _HTTP_STATUS_FORBIDDEN and self._can_reauthenticate:
logger.debug("qBittorrent returned 403; re-authenticating and retrying")
self._client.auth_log_in()
self._ensure_authenticated()
response = self._client._session.get(url, params=request_params, timeout=10)
if response.status_code == _HTTP_STATUS_FORBIDDEN:
@@ -409,7 +425,7 @@ class QBittorrentClient(DownloadClient):
def test_connection(self) -> tuple[bool, str]:
"""Test connection to qBittorrent."""
try:
self._client.auth_log_in()
self._ensure_authenticated()
api_version = self._client.app.web_api_version
except _QBITTORRENT_CLIENT_ERRORS as e:
return False, f"Connection failed: {e!s}"
@@ -513,7 +529,10 @@ class QBittorrentClient(DownloadClient):
# watching for the new torrent to appear.
expected_hash = self._discover_added_torrent_hash(name, category, known_hashes)
if not expected_hash:
_raise_runtime_error("Could not determine torrent hash from URL")
message = "Could not determine torrent hash from URL"
if torrent_info.fetch_error:
message = f"{message} (torrent file fetch failed: {torrent_info.fetch_error})"
_raise_runtime_error(message)
# Some qBittorrent-compatible clients return HTTP 200 with an empty body
# instead of qBittorrent's literal "Ok." response. Prefer verifying that
@@ -654,6 +673,26 @@ class QBittorrentClient(DownloadClient):
else:
return True
def set_category(self, download_id: str, category: str) -> bool:
"""Assign a category to a torrent in qBittorrent."""
try:
try:
self._client.torrents_create_category(name=category)
except _QBITTORRENT_CLIENT_ERRORS as e:
if "Conflict" not in type(e).__name__ and "409" not in str(e):
logger.debug("Could not create category '%s': %s", category, e)
self._client.torrents_set_category(
torrent_hashes=download_id,
category=category,
)
logger.info("Set qBittorrent category for %s to '%s'", download_id, category)
except _QBITTORRENT_CLIENT_ERRORS as e:
self._log_error("set_category", e)
return False
else:
return True
def get_download_path(self, download_id: str) -> str | None:
"""Get the path where torrent files are located.
@@ -725,11 +764,11 @@ class QBittorrentClient(DownloadClient):
import os
def get_with_auth(url: str, params: dict[str, str]) -> requests.Response:
self._client.auth_log_in()
self._ensure_authenticated()
resp = self._client._session.get(url, params=params, timeout=10)
if resp.status_code == _HTTP_STATUS_FORBIDDEN:
if resp.status_code == _HTTP_STATUS_FORBIDDEN and self._can_reauthenticate:
logger.debug("qBittorrent returned 403; re-authenticating and retrying")
self._client.auth_log_in()
self._ensure_authenticated()
resp = self._client._session.get(url, params=params, timeout=10)
return resp
+517
View File
@@ -0,0 +1,517 @@
"""Real-Debrid debrid service client for Shelfmark.
Routes magnet links through the Real-Debrid REST API (v1.0) to download
torrent content via Real-Debrid's CDN infrastructure.
"""
from __future__ import annotations
import shutil
import threading
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, ClassVar, NoReturn
import requests
from shelfmark.config.env import TMP_DIR
from shelfmark.core.config import config
from shelfmark.core.logger import setup_logger
from shelfmark.download.clients import (
DownloadClient,
DownloadState,
DownloadStatus,
register_client,
)
from shelfmark.download.clients._coercion import config_text
from shelfmark.download.http import download_url
from shelfmark.download.network import get_ssl_verify
logger = setup_logger(__name__)
_API_BASE = "https://api.real-debrid.com/rest/1.0"
_REALDEBRID_CLIENT_ERRORS = (
AttributeError,
OSError,
requests.exceptions.RequestException,
RuntimeError,
TypeError,
ValueError,
)
# Real-Debrid torrent status values.
_STATUS_DOWNLOADING = frozenset(
{
"magnet_conversion",
"waiting_files_selection",
"downloading",
"compressing",
"uploading",
}
)
_STATUS_READY = "downloaded"
_STATUS_ERROR = frozenset({"error", "virus", "dead"})
# Timeouts for API calls.
_API_TIMEOUT = 30
_STATUS_TIMEOUT = 15
# File extensions recognised as book or audiobook content.
_BOOK_EXTENSIONS = (
".aac",
".azw",
".azw3",
".cbr",
".cbz",
".djvu",
".doc",
".docx",
".epub",
".fb2",
".flac",
".lit",
".m4a",
".m4b",
".mobi",
".mp3",
".ogg",
".opus",
".pdf",
".rtf",
".txt",
".wma",
)
def _raise_runtime_error(message: str) -> NoReturn:
raise RuntimeError(message)
@dataclass
class _DownloadState:
"""Internal mutable state for an in-progress Real-Debrid download."""
torrent_id: str
name: str
target_dir: Path
phase: str = "uploading"
error_message: str | None = None
progress: float = 0.0
download_thread: threading.Thread | None = None
lock: threading.Lock = field(default_factory=threading.Lock)
@register_client("torrent")
class RealDebridClient(DownloadClient):
"""Real-Debrid debrid service client.
Downloads torrent content by uploading magnet links to Real-Debrid,
selecting all files for download on their servers, then unrestricting
and fetching the resulting files via direct HTTP download from
Real-Debrid's CDN.
API documentation: https://api.real-debrid.com/
"""
protocol = "torrent"
name = "realdebrid"
_downloads: ClassVar[dict[str, _DownloadState]] = {}
_downloads_lock = threading.Lock()
def __init__(self) -> None:
self._api_key = config_text(config.get("REALDEBRID_API_KEY", ""))
def _auth_headers(self) -> dict[str, str]:
"""Return Authorization header dict for API requests."""
return {"Authorization": f"Bearer {self._api_key}"}
# ------------------------------------------------------------------
# DownloadClient interface
# ------------------------------------------------------------------
@staticmethod
def is_configured() -> bool:
"""Return True when Real-Debrid is selected and an API key exists."""
client = config_text(config.get("PROWLARR_TORRENT_CLIENT", ""))
api_key = config_text(config.get("REALDEBRID_API_KEY", ""))
return client == "realdebrid" and bool(api_key)
def test_connection(self) -> tuple[bool, str]:
"""Validate the API key and check Premium subscription status."""
if not self._api_key:
return False, "Real-Debrid API Key is required"
try:
url = f"{_API_BASE}/user"
resp = requests.get(
url,
headers=self._auth_headers(),
timeout=_STATUS_TIMEOUT,
verify=get_ssl_verify(url),
)
resp.raise_for_status()
user = resp.json()
username = user.get("username", "Unknown")
account_type = user.get("type", "free")
if account_type != "premium":
return (
False,
f"Real-Debrid user '{username}' does not have "
f"a Premium subscription (type: {account_type})",
)
except _REALDEBRID_CLIENT_ERRORS as e:
return False, f"Connection failed: {e}"
else:
return True, f"Connected to Real-Debrid as '{username}' (Premium)"
def add_download(
self,
url: str,
name: str,
category: str | None = None,
expected_hash: str | None = None,
**kwargs: object,
) -> str:
"""Upload a magnet link to Real-Debrid and select all files."""
if not self._api_key:
msg = "Real-Debrid API key is not configured"
raise RuntimeError(msg)
magnet_link = url
if not magnet_link.startswith("magnet:") and expected_hash:
magnet_link = f"magnet:?xt=urn:btih:{expected_hash}"
add_url = f"{_API_BASE}/torrents/addMagnet"
try:
resp = requests.post(
add_url,
headers=self._auth_headers(),
data={"magnet": magnet_link},
timeout=_API_TIMEOUT,
verify=get_ssl_verify(add_url),
)
resp.raise_for_status()
data = resp.json()
torrent_id = str(data.get("id", ""))
if not torrent_id:
msg = "No torrent ID returned from Real-Debrid"
_raise_runtime_error(msg)
# Select all files so Real-Debrid starts downloading the torrent
select_url = f"{_API_BASE}/torrents/selectFiles/{torrent_id}"
sel_resp = requests.post(
select_url,
headers=self._auth_headers(),
data={"files": "all"},
timeout=_API_TIMEOUT,
verify=get_ssl_verify(select_url),
)
sel_resp.raise_for_status()
target_dir = TMP_DIR / f"realdebrid_{torrent_id}"
target_dir.mkdir(parents=True, exist_ok=True)
state = _DownloadState(
torrent_id=torrent_id,
name=name,
target_dir=target_dir,
phase="waiting_rd",
)
with self._downloads_lock:
self._downloads[torrent_id] = state
logger.info(
"Added torrent to Real-Debrid: ID %s (%s)",
torrent_id,
name,
)
except Exception:
logger.exception("Failed to upload magnet to Real-Debrid")
raise
else:
return torrent_id
def get_status(self, download_id: str) -> DownloadStatus:
"""Poll Real-Debrid for torrent status and drive the download."""
state = self._ensure_state(download_id)
# Return cached terminal / in-flight states immediately.
with state.lock:
if state.phase == "error":
return DownloadStatus.error(
state.error_message or "Real-Debrid error",
)
if state.phase == "complete":
return DownloadStatus(
progress=100.0,
state=DownloadState.COMPLETE,
message="Complete",
complete=True,
file_path=str(state.target_dir),
)
if state.phase == "downloading_http":
return DownloadStatus(
progress=state.progress,
state=DownloadState.DOWNLOADING,
message="Downloading files via HTTP...",
complete=False,
file_path=None,
)
# Query Real-Debrid for torrent info.
try:
info_url = f"{_API_BASE}/torrents/info/{download_id}"
resp = requests.get(
info_url,
headers=self._auth_headers(),
timeout=_STATUS_TIMEOUT,
verify=get_ssl_verify(info_url),
)
resp.raise_for_status()
info = resp.json()
return self._handle_torrent_info(info, state)
except Exception as e:
logger.exception(
"Error checking Real-Debrid status for %s",
download_id,
)
return DownloadStatus.error(str(e))
def remove(
self,
download_id: str,
*,
delete_files: bool = False,
) -> bool:
"""Delete the torrent from Real-Debrid and clean up local files."""
try:
url = f"{_API_BASE}/torrents/delete/{download_id}"
requests.delete(
url,
headers=self._auth_headers(),
timeout=_STATUS_TIMEOUT,
verify=get_ssl_verify(url),
)
except _REALDEBRID_CLIENT_ERRORS as e:
logger.warning("Failed to delete torrent from Real-Debrid: %s", e)
with self._downloads_lock:
state = self._downloads.pop(download_id, None)
if state and state.target_dir.exists():
shutil.rmtree(state.target_dir, ignore_errors=True)
return True
def get_download_path(self, download_id: str) -> str | None:
"""Return the local directory containing downloaded files."""
with self._downloads_lock:
state = self._downloads.get(download_id)
if state and state.phase == "complete":
return str(state.target_dir)
target_dir = TMP_DIR / f"realdebrid_{download_id}"
if target_dir.exists():
return str(target_dir)
return None
# ------------------------------------------------------------------
# Internal helpers
# ------------------------------------------------------------------
def _ensure_state(self, download_id: str) -> _DownloadState:
"""Get or create download state for the given torrent ID."""
with self._downloads_lock:
state = self._downloads.get(download_id)
if state:
return state
target_dir = TMP_DIR / f"realdebrid_{download_id}"
state = _DownloadState(
torrent_id=download_id,
name=f"Download {download_id}",
target_dir=target_dir,
phase="waiting_rd",
)
with self._downloads_lock:
self._downloads[download_id] = state
return state
def _handle_torrent_info(
self,
info: dict[str, Any],
state: _DownloadState,
) -> DownloadStatus:
"""Map Real-Debrid torrent info to a DownloadStatus."""
status = info.get("status", "")
if status in _STATUS_DOWNLOADING:
progress = float(info.get("progress", 0.0))
speed = int(info.get("speed", 0))
filename = info.get("filename", state.name)
return DownloadStatus(
progress=progress * 0.5,
state=DownloadState.DOWNLOADING,
message=f"Real-Debrid downloading torrent ({filename})",
complete=False,
file_path=None,
download_speed=speed,
)
if status == _STATUS_READY:
links = info.get("links", [])
files = info.get("files", [])
self._maybe_start_download_thread(state, links, files)
return DownloadStatus(
progress=50.0,
state=DownloadState.DOWNLOADING,
message="Real-Debrid ready, retrieving files...",
complete=False,
file_path=None,
)
# Terminal error from Real-Debrid.
error_txt = f"Real-Debrid status error: {status}"
with state.lock:
state.phase = "error"
state.error_message = error_txt
return DownloadStatus.error(error_txt)
def _maybe_start_download_thread(
self,
state: _DownloadState,
links: list[str],
files: list[dict[str, Any]],
) -> None:
"""Spawn a background thread to unrestrict and download files."""
with state.lock:
already_running = state.phase in (
"unrestricting",
"downloading_http",
"complete",
)
thread_alive = state.download_thread is not None and state.download_thread.is_alive()
if already_running or thread_alive:
return
state.phase = "unrestricting"
t = threading.Thread(
target=self._process_and_download,
args=(state, links, files),
daemon=True,
)
state.download_thread = t
t.start()
# ------------------------------------------------------------------
# File download pipeline
# ------------------------------------------------------------------
def _process_and_download(
self,
state: _DownloadState,
links: list[str],
files: list[dict[str, Any]],
) -> None:
"""Unrestrict links and download files via HTTP.
Runs in a background thread spawned by ``_maybe_start_download_thread``.
"""
try:
if not links:
msg = "No download links returned by Real-Debrid"
_raise_runtime_error(msg)
# Match selected files with links
selected_files = [f for f in files if f.get("selected") == 1]
# Filter relevant ebook / audiobook files
relevant_indices: list[int] = []
for i, f_info in enumerate(selected_files):
path_str = f_info.get("path", "").lower()
if path_str.endswith(_BOOK_EXTENSIONS):
relevant_indices.append(i)
if not relevant_indices:
relevant_indices = list(range(len(links)))
with state.lock:
state.phase = "downloading_http"
total = len(relevant_indices)
for idx, rel_idx in enumerate(relevant_indices):
if rel_idx >= len(links):
continue
link = links[rel_idx]
# Unrestrict the Real-Debrid link to get direct CDN download URL
unrestrict_url = f"{_API_BASE}/unrestrict/link"
unl_resp = requests.post(
unrestrict_url,
headers=self._auth_headers(),
data={"link": link},
timeout=_API_TIMEOUT,
verify=get_ssl_verify(unrestrict_url),
)
unl_resp.raise_for_status()
unl_data = unl_resp.json()
direct_url = unl_data.get("download")
filename = unl_data.get("filename")
if not direct_url:
msg = f"Failed to unrestrict Real-Debrid link: {link}"
_raise_runtime_error(msg)
# Determine relative file path
if rel_idx < len(selected_files):
rel_path_str = selected_files[rel_idx].get("path", "").lstrip("/")
rel_path = Path(rel_path_str)
else:
rel_path = Path(filename or f"file_{idx + 1}")
dest = state.target_dir / rel_path
dest.parent.mkdir(parents=True, exist_ok=True)
logger.info(
"Downloading Real-Debrid file %d/%d: %s",
idx + 1,
total,
rel_path,
)
buf = download_url(
direct_url,
referer="https://real-debrid.com/",
)
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 state.lock:
state.progress = 50.0 + (idx + 1) / total * 50.0
with state.lock:
state.phase = "complete"
state.progress = 100.0
logger.info(
"Real-Debrid download complete for ID %s at %s",
state.torrent_id,
state.target_dir,
)
except Exception:
logger.exception(
"Error in Real-Debrid download for ID %s",
state.torrent_id,
)
with state.lock:
state.phase = "error"
state.error_message = str(
state.error_message or "Download failed",
)
+29 -5
View File
@@ -47,7 +47,13 @@ class _RTorrentLoadProtocol(Protocol):
def start(self, target: str, url: str, commands: str) -> object: ...
class _RTorrentCustom1Protocol(Protocol):
def set(self, download_id: str, value: str) -> object: ...
class _RTorrentDownloadProtocol(Protocol):
custom1: _RTorrentCustom1Protocol
def multicall2(self, *args: object) -> list[list[Any]]: ...
def delete_tied(self, download_id: str) -> object: ...
@@ -206,7 +212,10 @@ class RTorrentClient(DownloadClient):
# watching for the new download to appear.
torrent_hash = self._discover_added_torrent_hash(name, label, known_hashes)
if not torrent_hash:
_raise_runtime_error("Could not determine torrent hash from URL")
message = "Could not determine torrent hash from URL"
if torrent_info.fetch_error:
message = f"{message} (torrent file fetch failed: {torrent_info.fetch_error})"
_raise_runtime_error(message)
logger.debug("Added torrent to rTorrent: %s", torrent_hash)
@@ -329,12 +338,14 @@ class RTorrentClient(DownloadClient):
"""
try:
# rtorrent is somehow case sensitive and requires uppercase hashes for look
torrent_hash = download_id.upper()
if delete_files:
self._rpc.d.delete_tied(download_id)
self._rpc.d.erase(download_id)
self._rpc.d.delete_tied(torrent_hash)
self._rpc.d.erase(torrent_hash)
else:
self._rpc.d.stop(download_id)
self._rpc.d.erase(download_id)
self._rpc.d.stop(torrent_hash)
self._rpc.d.erase(torrent_hash)
logger.info(
"Removed torrent from rTorrent: %s%s",
@@ -348,6 +359,19 @@ class RTorrentClient(DownloadClient):
else:
return True
def set_category(self, download_id: str, category: str) -> bool:
"""Assign a label to a torrent using rTorrent's custom1 field."""
try:
# rtorrent is somehow case sensitive and requires uppercase hashes for look
self._rpc.d.custom1.set(download_id.upper(), category)
logger.info("Set rTorrent label for %s to '%s'", download_id, category)
except _RTORRENT_CLIENT_ERRORS as e:
error_type = type(e).__name__
logger.exception("rTorrent set_category failed (%s)", error_type)
return False
else:
return True
def get_download_path(self, download_id: str) -> str | None:
"""Get the path where torrent files are located.
+94 -2
View File
@@ -159,6 +159,7 @@ def _test_qbittorrent_connection(current_values: dict[str, Any] | None = None) -
raw_url = _resolve_string_setting(current_values, config.get, "QBITTORRENT_URL")
username = _resolve_string_setting(current_values, config.get, "QBITTORRENT_USERNAME")
password = _resolve_string_setting(current_values, config.get, "QBITTORRENT_PASSWORD")
api_key = _resolve_string_setting(current_values, config.get, "QBITTORRENT_API_KEY")
if not raw_url:
return {"success": False, "message": "qBittorrent URL is required"}
@@ -174,6 +175,7 @@ def _test_qbittorrent_connection(current_values: dict[str, Any] | None = None) -
host=url,
username=username,
password=password,
api_key=api_key or None,
VERIFY_WEBUI_CERTIFICATE=get_ssl_verify(url),
)
client.auth_log_in()
@@ -181,9 +183,18 @@ def _test_qbittorrent_connection(current_values: dict[str, Any] | None = None) -
except ImportError:
return {"success": False, "message": "qbittorrent-api package not installed"}
except _QBITTORRENT_SETTINGS_ERRORS as e:
if isinstance(e, _QBittorrentLoginFailed):
# LoginFailed carries no message of its own, so name the rejected credential.
rejected = "API key" if api_key else "username or password"
return {"success": False, "message": f"qBittorrent rejected the {rejected}"}
return {"success": False, "message": f"Connection failed: {e!s}"}
else:
return {"success": True, "message": f"Connected to qBittorrent (API v{api_version})"}
# Both credentials can be set at once, so name the one that actually authenticated.
used = " using the API key" if api_key else ""
return {
"success": True,
"message": f"Connected to qBittorrent (API v{api_version}){used}",
}
def _test_transmission_connection(current_values: dict[str, Any] | None = None) -> dict[str, Any]:
@@ -520,6 +531,40 @@ def _test_sabnzbd_connection(current_values: dict[str, Any] | None = None) -> di
return {"success": True, "message": f"Connected to SABnzbd {version}"}
def _test_alldebrid_connection(current_values: dict[str, Any] | None = None) -> dict[str, Any]:
"""Test the AllDebrid API connection using current form values."""
from shelfmark.core.config import config
from shelfmark.download.clients.alldebrid import AllDebridClient
current_values = current_values or {}
api_key = _resolve_string_setting(current_values, config.get, "ALLDEBRID_API_KEY")
if not api_key:
return {"success": False, "message": "AllDebrid API Key is required"}
client = AllDebridClient()
client._api_key = api_key
success, message = client.test_connection()
return {"success": success, "message": message}
def _test_realdebrid_connection(current_values: dict[str, Any] | None = None) -> dict[str, Any]:
"""Test the Real-Debrid API connection using current form values."""
from shelfmark.core.config import config
from shelfmark.download.clients.realdebrid import RealDebridClient
current_values = current_values or {}
api_key = _resolve_string_setting(current_values, config.get, "REALDEBRID_API_KEY")
if not api_key:
return {"success": False, "message": "Real-Debrid API Key is required"}
client = RealDebridClient()
client._api_key = api_key
success, message = client.test_connection()
return {"success": success, "message": message}
# ==================== Download Clients Tab ====================
@@ -544,13 +589,45 @@ def prowlarr_clients_settings() -> list[SettingsField]:
description="Choose which torrent client to use",
options=[
{"value": "", "label": "None"},
{"value": "alldebrid", "label": "AllDebrid"},
{"value": "qbittorrent", "label": "qBittorrent"},
{"value": "realdebrid", "label": "Real-Debrid"},
{"value": "transmission", "label": "Transmission"},
{"value": "deluge", "label": "Deluge"},
{"value": "rtorrent", "label": "rTorrent"},
],
default="",
),
# --- AllDebrid Settings ---
PasswordField(
key="ALLDEBRID_API_KEY",
label="API Key",
description="AllDebrid API Key (apiv4) from your AllDebrid account settings",
show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "alldebrid"},
),
ActionButton(
key="test_alldebrid",
label="Test Connection",
description="Verify your AllDebrid configuration",
style="primary",
callback=_test_alldebrid_connection,
show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "alldebrid"},
),
# --- Real-Debrid Settings ---
PasswordField(
key="REALDEBRID_API_KEY",
label="API Key",
description="Real-Debrid API Key (Secret Token) from your Real-Debrid account settings",
show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "realdebrid"},
),
ActionButton(
key="test_realdebrid",
label="Test Connection",
description="Verify your Real-Debrid configuration",
style="primary",
callback=_test_realdebrid_connection,
show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "realdebrid"},
),
# --- qBittorrent Settings ---
TextField(
key="QBITTORRENT_URL",
@@ -572,6 +649,12 @@ def prowlarr_clients_settings() -> list[SettingsField]:
description="qBittorrent Web UI password",
show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "qbittorrent"},
),
PasswordField(
key="QBITTORRENT_API_KEY",
label="API Key",
description="Found in qBittorrent: Options > Web UI > API Key (qBittorrent 5.2.0+). Used instead of the username and password when set.",
show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "qbittorrent"},
),
ActionButton(
key="test_qbittorrent",
label="Test Connection",
@@ -771,14 +854,23 @@ def prowlarr_clients_settings() -> list[SettingsField]:
SelectField(
key="PROWLARR_TORRENT_ACTION",
label="Torrent Completion Action",
description="Remove deletes the torrent from your client immediately after import (stops seeding, files are kept); Keep leaves it in the client to continue seeding",
description="Choose whether to keep, remove, or move the torrent to another category or label after import",
options=[
{"value": "keep", "label": "Keep"},
{"value": "remove", "label": "Remove"},
{"value": "change_category", "label": "Change Category"},
],
default="keep",
show_when={"field": "PROWLARR_TORRENT_CLIENT", "notEmpty": True},
),
TextField(
key="PROWLARR_TORRENT_POST_IMPORT_CATEGORY",
label="Post-Import Category",
description="Category or label to assign after a successful import",
placeholder="imported",
default="",
show_when={"field": "PROWLARR_TORRENT_ACTION", "value": "change_category"},
),
# --- Usenet Client Selection ---
HeadingField(
key="usenet_heading",
+69 -13
View File
@@ -5,8 +5,10 @@ from __future__ import annotations
import base64
import hashlib
import re
import time
from binascii import Error as BinasciiError
from dataclasses import dataclass
from threading import Lock
from urllib.parse import ParseResult, parse_qs, urljoin, urlparse
import requests
@@ -36,6 +38,15 @@ _TORRENT_FETCH_ERRORS = (
_TORRENT_PARSE_ERRORS = (IndexError, KeyError, TypeError, ValueError)
_TRUSTED_TORRENT_FETCH_URL_CONFIG_KEYS = ("PROWLARR_URL", "NEWZNAB_URL")
# Successful torrent fetches are reused for a short window so one add attempt
# hits the download link only once. Tracker download links (e.g. private
# trackers behind Prowlarr's proxy) can be slow, rate-limited, or single-use,
# and both find_existing() and add_download() resolve the same URL (#1111).
_TORRENT_FETCH_CACHE_TTL_SECONDS = 120.0
_TORRENT_FETCH_CACHE_MAX_ENTRIES = 8
_torrent_fetch_cache_lock = Lock()
_torrent_fetch_cache: dict[str, tuple[float, TorrentInfo]] = {}
type BencodeValue = dict[str | bytes, BencodeValue] | list[BencodeValue] | int | bytes | str
@@ -55,6 +66,9 @@ class TorrentInfo:
magnet_url: str | None = None
"""The actual magnet URL, if available."""
fetch_error: str | None = None
"""Why fetching the .torrent URL failed, or None if it succeeded/was skipped."""
def with_info_hash(self, info_hash: str | None) -> TorrentInfo:
"""Return a copy with the info_hash replaced when provided."""
if info_hash:
@@ -63,6 +77,7 @@ class TorrentInfo:
torrent_data=self.torrent_data,
is_magnet=self.is_magnet,
magnet_url=self.magnet_url,
fetch_error=self.fetch_error,
)
return self
@@ -97,6 +112,48 @@ def extract_torrent_info(
if not fetch_torrent:
return TorrentInfo(info_hash=expected_hash, torrent_data=None, is_magnet=False)
info = _get_cached_torrent_fetch(url)
if info is None:
info = _fetch_torrent_info(url)
if info.fetch_error is None:
_store_cached_torrent_fetch(url, info)
return info.with_info_hash(info.info_hash or expected_hash)
def _get_cached_torrent_fetch(url: str) -> TorrentInfo | None:
with _torrent_fetch_cache_lock:
entry = _torrent_fetch_cache.get(url)
if entry is None:
return None
fetched_at, info = entry
if time.monotonic() - fetched_at > _TORRENT_FETCH_CACHE_TTL_SECONDS:
del _torrent_fetch_cache[url]
return None
logger.debug("Reusing recently fetched torrent data for: %s...", url[:80])
return info
def _store_cached_torrent_fetch(url: str, info: TorrentInfo) -> None:
with _torrent_fetch_cache_lock:
_torrent_fetch_cache[url] = (time.monotonic(), info)
while len(_torrent_fetch_cache) > _TORRENT_FETCH_CACHE_MAX_ENTRIES:
oldest_url = min(_torrent_fetch_cache, key=lambda key: _torrent_fetch_cache[key][0])
del _torrent_fetch_cache[oldest_url]
def clear_torrent_fetch_cache() -> None:
"""Drop all cached torrent fetches (used by tests)."""
with _torrent_fetch_cache_lock:
_torrent_fetch_cache.clear()
def _fetch_torrent_info(url: str) -> TorrentInfo:
"""Fetch a .torrent URL and parse out the info_hash and raw torrent data.
On failure, the returned TorrentInfo carries the reason in `fetch_error`
so callers can surface it instead of a generic hash error.
"""
# A release source can legitimately hand us a download URL on a different
# origin than the configured Prowlarr/Newznab endpoint (e.g. a direct
# tracker link, or Prowlarr reached through a separate proxy), and a trusted
@@ -145,18 +202,20 @@ def extract_torrent_info(
redirect_url = resolve_url(current_url, resp.headers.get("Location", ""))
if redirect_url.startswith("magnet:"):
logger.debug("Download URL redirected to magnet link")
info_hash = extract_hash_from_magnet(redirect_url)
if not info_hash and expected_hash:
info_hash = expected_hash
return TorrentInfo(
info_hash=info_hash,
info_hash=extract_hash_from_magnet(redirect_url),
torrent_data=None,
is_magnet=True,
magnet_url=redirect_url,
)
if redirects_remaining <= 0:
logger.debug("Too many redirects fetching torrent file: %s...", url[:80])
return TorrentInfo(info_hash=expected_hash, torrent_data=None, is_magnet=False)
logger.warning("Too many redirects fetching torrent file: %s...", url[:80])
return TorrentInfo(
info_hash=None,
torrent_data=None,
is_magnet=False,
fetch_error="too many redirects",
)
redirects_remaining -= 1
logger.debug("Following redirect to: %s...", redirect_url[:80])
current_url = redirect_url
@@ -170,25 +229,22 @@ def extract_torrent_info(
text_content = torrent_data.decode("utf-8", errors="ignore").strip()
if text_content.startswith("magnet:"):
logger.debug("Download URL returned magnet link as response body")
info_hash = extract_hash_from_magnet(text_content)
if not info_hash and expected_hash:
info_hash = expected_hash
return TorrentInfo(
info_hash=info_hash,
info_hash=extract_hash_from_magnet(text_content),
torrent_data=None,
is_magnet=True,
magnet_url=text_content,
)
info_hash = extract_info_hash_from_torrent(torrent_data) or expected_hash
info_hash = extract_info_hash_from_torrent(torrent_data)
if info_hash:
logger.debug("Extracted hash from torrent file: %s", info_hash)
else:
logger.warning("Could not extract hash from torrent file")
return TorrentInfo(info_hash=info_hash, torrent_data=torrent_data, is_magnet=False)
except _TORRENT_FETCH_ERRORS as e:
logger.debug("Could not fetch torrent file: %s", e)
return TorrentInfo(info_hash=expected_hash, torrent_data=None, is_magnet=False)
logger.warning("Could not fetch torrent file: %s", e)
return TorrentInfo(info_hash=None, torrent_data=None, is_magnet=False, fetch_error=str(e))
def _is_trusted_torrent_fetch_url(url: str) -> bool:
@@ -390,6 +390,30 @@ class TransmissionClient(DownloadClient):
else:
return True
def _get_torrent_labels(self, download_id: str) -> list[str]:
"""Return a torrent's current labels, preserving their order."""
torrent = self._client.get_torrent(download_id)
raw_labels = getattr(torrent, "labels", None) or []
return [str(label) for label in raw_labels if str(label)]
def set_category(self, download_id: str, category: str) -> bool:
"""Add the post-import label to a torrent, keeping labels set elsewhere."""
try:
existing_labels = self._get_torrent_labels(download_id)
if category in existing_labels:
logger.debug(
"Transmission torrent %s already has label '%s'", download_id, category
)
return True
self._client.change_torrent(ids=download_id, labels=[*existing_labels, category])
logger.info("Added Transmission label '%s' to %s", category, download_id)
except _TRANSMISSION_CLIENT_ERRORS as e:
self._log_error("set_category", e)
return False
else:
return True
def get_download_path(self, download_id: str) -> str | None:
"""Get the path where torrent files are located.
+23 -7
View File
@@ -170,16 +170,19 @@ def _build_retry_resolution_fields(
retry_download_url = normalize_optional_text(release_data.get("download_url"))
protocol = normalize_optional_text(release_data.get("protocol"))
source = normalize_optional_text(release_data.get("source"))
retry_source_context: dict[str, Any] = {}
if source is not None:
handler = get_handler(source)
source_retry_fields = handler.build_retry_resolution_fields(release_data)
retry_download_url = (
normalize_optional_text(source_retry_fields.get("retry_download_url"))
or retry_download_url
)
protocol = (
normalize_optional_text(source_retry_fields.get("retry_download_protocol")) or protocol
)
if "retry_download_url" in source_retry_fields:
retry_download_url = normalize_optional_text(
source_retry_fields.get("retry_download_url")
)
if "retry_download_protocol" in source_retry_fields:
protocol = normalize_optional_text(source_retry_fields.get("retry_download_protocol"))
raw_retry_source_context = source_retry_fields.get("retry_source_context")
if isinstance(raw_retry_source_context, dict):
retry_source_context = dict(raw_retry_source_context)
ratio_limit = _optional_number(release_data.get("ratio_limit"))
if ratio_limit is None and config.get("PROWLARR_USE_SEED_PREFERENCES", False):
@@ -202,6 +205,7 @@ def _build_retry_resolution_fields(
),
"retry_ratio_limit": ratio_limit,
"retry_seeding_time_limit_minutes": seeding_time_limit_minutes,
"retry_source_context": retry_source_context,
"can_retry_without_staged_source": True,
}
@@ -247,6 +251,7 @@ def queue_release(
series_name = release_data.get("series_name") or extra.get("series_name")
series_position = release_data.get("series_position") or extra.get("series_position")
subtitle = release_data.get("subtitle") or extra.get("subtitle")
language = release_data.get("language") or extra.get("language")
books_output_mode = (
str(config.get("BOOKS_OUTPUT_MODE", "folder", user_id=user_id) or "folder")
@@ -281,6 +286,7 @@ def queue_release(
series_name=series_name,
series_position=series_position,
subtitle=subtitle,
language=language,
search_mode=search_mode,
output_mode=output_mode,
output_args=output_args,
@@ -400,6 +406,7 @@ def serialize_task_for_retry(task: DownloadTask) -> dict[str, Any]:
search_mode = normalized_search_mode or None
raw_output_args = getattr(task, "output_args", None)
raw_retry_source_context = getattr(task, "retry_source_context", None)
return {
"task_id": getattr(task, "task_id", None),
@@ -415,6 +422,7 @@ def serialize_task_for_retry(task: DownloadTask) -> dict[str, Any]:
"series_name": getattr(task, "series_name", None),
"series_position": getattr(task, "series_position", None),
"subtitle": getattr(task, "subtitle", None),
"language": getattr(task, "language", None),
"search_mode": search_mode,
"output_mode": getattr(task, "output_mode", None),
"output_args": dict(raw_output_args) if isinstance(raw_output_args, dict) else {},
@@ -428,6 +436,9 @@ def serialize_task_for_retry(task: DownloadTask) -> dict[str, Any]:
"retry_expected_hash": getattr(task, "retry_expected_hash", None),
"retry_ratio_limit": getattr(task, "retry_ratio_limit", None),
"retry_seeding_time_limit_minutes": getattr(task, "retry_seeding_time_limit_minutes", None),
"retry_source_context": (
dict(raw_retry_source_context) if isinstance(raw_retry_source_context, dict) else {}
),
"can_retry_without_staged_source": bool(
getattr(task, "can_retry_without_staged_source", True)
),
@@ -453,6 +464,7 @@ def _restore_task_from_retry_payload(payload: object) -> DownloadTask | None:
search_mode = None
output_args = payload.get("output_args")
retry_source_context = payload.get("retry_source_context")
return DownloadTask(
task_id=task_id,
@@ -468,6 +480,7 @@ def _restore_task_from_retry_payload(payload: object) -> DownloadTask | None:
series_name=normalize_optional_text(payload.get("series_name")),
series_position=_optional_number(payload.get("series_position")),
subtitle=normalize_optional_text(payload.get("subtitle")),
language=normalize_optional_text(payload.get("language")),
search_mode=search_mode,
output_mode=normalize_optional_text(payload.get("output_mode")),
output_args=dict(output_args) if isinstance(output_args, dict) else {},
@@ -483,6 +496,9 @@ def _restore_task_from_retry_payload(payload: object) -> DownloadTask | None:
retry_seeding_time_limit_minutes=_optional_positive_int(
payload.get("retry_seeding_time_limit_minutes")
),
retry_source_context=(
dict(retry_source_context) if isinstance(retry_source_context, dict) else {}
),
can_retry_without_staged_source=bool(payload.get("can_retry_without_staged_source", True)),
)
@@ -229,6 +229,7 @@ def _build_custom_script_payload(
"series_name": context.task.series_name,
"series_position": context.task.series_position,
"subtitle": context.task.subtitle,
"language": context.task.language,
"original_download_path": context.task.original_download_path,
},
"output": {
@@ -12,6 +12,7 @@ from shelfmark.core.naming import (
assign_part_numbers,
build_library_path,
derive_primary_title,
normalize_language_code,
parse_naming_template,
sanitize_filename,
)
@@ -63,6 +64,7 @@ def build_metadata_dict(task: DownloadTask) -> dict:
"Year": task.year,
"Series": task.series_name,
"SeriesPosition": task.series_position,
"Language": normalize_language_code(task.language),
"User": task.username,
}
+17 -14
View File
@@ -24,12 +24,12 @@ Dataclass representing a book from a metadata provider:
```python
@dataclass
class BookMetadata:
provider: str # Internal provider name (e.g., "hardcover")
provider_id: str # ID in that provider's system
provider: str # Internal provider name (e.g., "hardcover")
provider_id: str # ID in that provider's system
title: str
# Optional fields
provider_display_name: str # Human-readable name (e.g., "Hardcover")
provider_display_name: str # Human-readable name (e.g., "Hardcover")
authors: List[str]
isbn_10: str
isbn_13: str
@@ -39,7 +39,7 @@ class BookMetadata:
publish_year: int
language: str
genres: List[str]
source_url: str # Link to book on provider's site
source_url: str # Link to book on provider's site
display_fields: List[DisplayField] # Provider-specific display data
```
@@ -50,9 +50,9 @@ Provider-specific metadata for UI cards (ratings, page counts, reader counts, et
```python
@dataclass
class DisplayField:
label: str # e.g., "Rating", "Pages", "Readers"
value: str # e.g., "4.5", "496", "8,041"
icon: str # Icon name: "star", "book", "users", "editions"
label: str # e.g., "Rating", "Pages", "Readers"
value: str # e.g., "4.5", "496", "8,041"
icon: str # Icon name: "star", "book", "users", "editions"
```
### MetadataSearchOptions
@@ -64,7 +64,7 @@ Unified search options that work across all providers:
class MetadataSearchOptions:
query: str
search_type: SearchType = SearchType.GENERAL # GENERAL, TITLE, AUTHOR, ISBN
language: str = None # ISO 639-1 code (e.g., "en")
language: str = None # ISO 639-1 code (e.g., "en")
sort: SortOrder = SortOrder.RELEVANCE
limit: int = 40
page: int = 1
@@ -88,10 +88,10 @@ All providers must implement this interface:
```python
class MetadataProvider(ABC):
name: str # Internal identifier
display_name: str # Human-readable name
requires_auth: bool # True if API key required
supported_sorts: List[SortOrder] # Supported sort options
name: str # Internal identifier
display_name: str # Human-readable name
requires_auth: bool # True if API key required
supported_sorts: List[SortOrder] # Supported sort options
@abstractmethod
def search(self, options: MetadataSearchOptions) -> List[BookMetadata]:
@@ -121,9 +121,9 @@ class MetadataProvider(ABC):
```python
from shelfmark.metadata_providers import register_provider
@register_provider("my_provider")
class MyProvider(MetadataProvider):
...
class MyProvider(MetadataProvider): ...
```
### Getting Providers
@@ -281,11 +281,13 @@ from shelfmark.config.env import (
METADATA_CACHE_BOOK_TTL,
)
@cacheable(ttl=METADATA_CACHE_SEARCH_TTL, key_prefix="myprovider:search")
def _search_cached(self, cache_key: str, options: MetadataSearchOptions):
# Cached search implementation
pass
@cacheable(ttl=METADATA_CACHE_BOOK_TTL, key_prefix="myprovider:book")
def get_book(self, book_id: str):
# Cached book lookup
@@ -302,6 +304,7 @@ from shelfmark.metadata_providers.openlibrary import RateLimiter
# 90 requests per 60 seconds
rate_limiter = RateLimiter(max_requests=90, window_seconds=60)
def make_request(self):
rate_limiter.wait_if_needed() # Blocks if rate limited
# ... make request
+7 -1
View File
@@ -163,6 +163,7 @@ class SortOption:
label: str # Display label in the sort dropdown
sort_key: str # Field to sort by on the Release object
default_direction: Literal["asc", "desc"] = "desc" # Which way "best first" runs
@dataclass
@@ -261,7 +262,12 @@ def serialize_column_config(config: ReleaseColumnConfig) -> dict[str, Any]:
# Include extra sort options (sort entries not tied to a column)
if config.extra_sort_options:
result["extra_sort_options"] = [
{"label": opt.label, "sort_key": opt.sort_key} for opt in config.extra_sort_options
{
"label": opt.label,
"sort_key": opt.sort_key,
"default_direction": opt.default_direction,
}
for opt in config.extra_sort_options
]
# Include action button if specified (replaces default expand search)
@@ -9,6 +9,7 @@ if TYPE_CHECKING:
from shelfmark.metadata_providers import BookMetadata
from shelfmark.core.config import config
from shelfmark.core.languages import normalize_language
from shelfmark.core.logger import setup_logger
from shelfmark.release_sources import (
ColumnAlign,
@@ -43,41 +44,6 @@ def _coerce_positive_int(value: object, default: int) -> int:
# Map language names to ISO 639-1 codes (matching frontend color maps)
LANGUAGE_MAP = {
"english": "en",
"spanish": "es",
"french": "fr",
"german": "de",
"italian": "it",
"portuguese": "pt",
"russian": "ru",
"japanese": "ja",
"chinese": "zh",
"dutch": "nl",
"swedish": "sv",
"norwegian": "no",
"danish": "da",
"finnish": "fi",
"polish": "pl",
"czech": "cs",
"hungarian": "hu",
"korean": "ko",
"arabic": "ar",
"hebrew": "he",
"turkish": "tr",
"greek": "el",
"hindi": "hi",
"thai": "th",
"vietnamese": "vi",
"indonesian": "id",
"ukrainian": "uk",
"romanian": "ro",
"bulgarian": "bg",
"catalan": "ca",
"croatian": "hr",
"slovenian": "sl",
"serbian": "sr",
}
def _split_title_and_author(raw_title: str) -> tuple[str, str | None]:
@@ -119,8 +85,9 @@ def _map_language(language: str) -> str | None:
if not language:
return None
lang_lower = language.lower().strip()
return LANGUAGE_MAP.get(lang_lower, lang_lower)
# Fall back to the raw value so an unrecognised language is still shown
# rather than silently dropped from the release row.
return normalize_language(language) or language.lower().strip()
def _parse_bitrate_to_kbps(bitrate: str | None) -> int | None:
+3 -30
View File
@@ -18,6 +18,7 @@ from bs4.element import NavigableString
from shelfmark.config.env import DEBUG_SKIP_SOURCES, TMP_DIR
from shelfmark.core.config import config
from shelfmark.core.languages import language_alias_map
from shelfmark.core.logger import setup_logger
from shelfmark.core.models import DownloadTask, SearchFilters, build_filename
from shelfmark.core.utils import CONTENT_TYPES, get_aa_content_type_dir
@@ -267,7 +268,7 @@ def _fold_text(value: str) -> str:
def _language_alias_to_code() -> dict[str, str]:
"""Build alias→code map from bundled language metadata (lazy, cached)."""
"""Alias to code map, delegating to the shared language data."""
global _LANGUAGE_ALIAS_TO_CODE
cached = _LANGUAGE_ALIAS_TO_CODE
if cached is not None:
@@ -278,35 +279,7 @@ def _language_alias_to_code() -> dict[str, str]:
if cached is not None:
return cached
mapping: dict[str, str] = {}
data_path = Path(__file__).resolve().parents[2] / "data" / "book-languages.json"
try:
raw = json.loads(data_path.read_text(encoding="utf-8"))
except OSError, ValueError, TypeError:
_LANGUAGE_ALIAS_TO_CODE = {}
return _LANGUAGE_ALIAS_TO_CODE
if not isinstance(raw, list):
_LANGUAGE_ALIAS_TO_CODE = {}
return _LANGUAGE_ALIAS_TO_CODE
for item in raw:
if not isinstance(item, dict):
continue
code = _normalize_language_token(str(item.get("code", "")))
name = _normalize_language_token(str(item.get("language", "")))
if not code:
continue
mapping.setdefault(code, code)
mapping.setdefault(code.replace("-", "_"), code)
mapping.setdefault(code.split("-")[0], code)
mapping.setdefault(_fold_text(code), code)
if name:
mapping.setdefault(name, code)
mapping.setdefault(_fold_text(name), code)
_LANGUAGE_ALIAS_TO_CODE = mapping
_LANGUAGE_ALIAS_TO_CODE = language_alias_map()
return _LANGUAGE_ALIAS_TO_CODE
+69 -33
View File
@@ -25,6 +25,8 @@ logger = setup_logger(__name__)
# Timing
SOCKET_TIMEOUT = 300.0 # 5 minutes - long because we wait for DCC offers
RECV_BUFFER = 4096
# How often a deadline-bound read wakes up to re-check the clock
POLL_INTERVAL = 2.0
# IRC channel user prefixes that indicate elevated status (ops, voice, etc.)
# These are the download bots/servers
@@ -248,11 +250,22 @@ class IRCClient:
# 366 = RPL_ENDOFNAMES - channel join is complete
if msg.command == "366":
logger.info(
"Joined #%s - %s servers online",
channel,
len(self.online_servers),
)
if not self.online_servers:
# Joining a channel that doesn't exist on this network
# silently creates an empty one, so an empty name list is
# the only hint that the channel name is wrong.
logger.warning(
"Joined #%s but no servers are online - the channel may "
"be empty or not exist on %s",
channel,
self.server,
)
else:
logger.info(
"Joined #%s - %s servers online",
channel,
len(self.online_servers),
)
return
# Check for errors (e.g., banned, channel doesn't exist)
@@ -296,27 +309,47 @@ class IRCClient:
data = f"{message}\r\n".encode()
self._socket.sendall(data)
def _recv_lines(self) -> Iterator[str]:
"""Receive and yield complete CRLF-delimited IRC lines."""
sock = self._require_socket()
while True:
# Check if we have a complete line in buffer
while "\r\n" in self._buffer:
line, self._buffer = self._buffer.split("\r\n", 1)
if line:
yield line
def _recv_lines(self, deadline: float | None = None) -> Iterator[str]:
"""Receive and yield complete CRLF-delimited IRC lines.
# Read more data
try:
data = sock.recv(RECV_BUFFER)
if not data:
return # Connection closed
self._buffer += data.decode("utf-8", errors="replace")
except TimeoutError:
continue # Keep waiting
except OSError as e:
logger.warning("Socket error: %s", e)
return # Connection error
A deadline stops the read once it passes, even if nothing ever arrives.
Callers time out by watching the messages they receive, so on a channel
with no traffic at all there is nothing to watch: the recv would just
keep blocking for SOCKET_TIMEOUT and retrying forever.
"""
sock = self._require_socket()
original_timeout = sock.gettimeout()
try:
while True:
# Check if we have a complete line in buffer
while "\r\n" in self._buffer:
line, self._buffer = self._buffer.split("\r\n", 1)
if line:
yield line
if deadline is not None:
remaining = deadline - time.time()
if remaining <= 0:
return
# Wake up often enough to notice the deadline pass
sock.settimeout(min(remaining, POLL_INTERVAL))
# Read more data
try:
data = sock.recv(RECV_BUFFER)
if not data:
return # Connection closed
self._buffer += data.decode("utf-8", errors="replace")
except TimeoutError:
continue # Keep waiting (the deadline is re-checked above)
except OSError as e:
logger.warning("Socket error: %s", e)
return # Connection error
finally:
if deadline is not None:
with suppress(OSError):
sock.settimeout(original_timeout)
def _parse_message(self, line: str) -> IRCMessage:
"""Parse an IRC message line into components.
@@ -427,9 +460,14 @@ class IRCClient:
return False
return True
def read_messages(self, *, auto_handle: bool = True) -> Iterator[IRCMessage]:
def read_messages(
self,
*,
auto_handle: bool = True,
deadline: float | None = None,
) -> Iterator[IRCMessage]:
"""Read and yield IRC messages, optionally auto-handling PING/VERSION."""
for line in self._recv_lines():
for line in self._recv_lines(deadline):
msg = self._parse_message(line)
# Auto-handle certain events
@@ -453,13 +491,9 @@ class IRCClient:
) -> DCCOffer | None:
"""Wait for a DCC SEND offer. Returns None on timeout or no results."""
target_event = IRCEvent.SEARCH_RESULT if result_type else IRCEvent.BOOK_RESULT
start = time.time()
for msg in self.read_messages():
if time.time() - start > timeout:
logger.warning("Timeout waiting for DCC offer")
return None
deadline = time.time() + timeout
for msg in self.read_messages(deadline=deadline):
if msg.event == target_event:
if not self._is_allowed_dcc_sender(msg, expected_senders):
continue
@@ -491,6 +525,8 @@ class IRCClient:
count = match.group(1)
logger.info("Found %s matches", count)
if time.time() >= deadline:
logger.warning("Timeout waiting for DCC offer")
return None
@property
+36 -1
View File
@@ -72,7 +72,10 @@ def irc_settings() -> list[SettingsField]:
key="IRC_CHANNEL",
label="Channel",
placeholder="e.g. ebooks",
description="Channel name without the # prefix",
description=(
"Channel name without the # prefix. Used for all searches unless a "
"separate audiobook channel is configured below."
),
required=True,
env_supported=True,
),
@@ -95,6 +98,38 @@ def irc_settings() -> list[SettingsField]:
required=True,
env_supported=True,
),
HeadingField(
key="audiobook_heading",
title="Audiobooks",
description=(
"Some networks index audiobooks in a separate channel from ebooks "
"(for example #ebooks for ebooks and #bookz for audiobooks). "
"Configure that channel here to search it for audiobook requests. "
"Leave these blank to search the main channel above for both."
),
),
TextField(
key="IRC_AUDIOBOOK_CHANNEL",
label="Audiobook channel",
placeholder="e.g. bookz",
description=(
"Optional. Channel name (without the # prefix) to use for audiobook "
"searches. Leave blank to use the main channel above for audiobooks too."
),
required=False,
env_supported=True,
),
TextField(
key="IRC_AUDIOBOOK_SEARCH_BOT",
label="Audiobook search bot",
placeholder="e.g. search",
description=(
"Optional. Search bot for the audiobook channel. Leave blank to reuse "
"the main search bot above. Only used when an audiobook channel is set."
),
required=False,
env_supported=True,
),
HeadingField(
key="cache_heading",
title="Search Cache",
+13
View File
@@ -240,6 +240,19 @@ class IRCReleaseSource(ReleaseSource):
nick = _config_text("IRC_NICK")
search_bot = _config_text("IRC_SEARCH_BOT")
# Audiobooks may be indexed in a separate channel from ebooks on some networks
# (e.g. #ebooks for ebooks, #bookz for audiobooks). When an audiobook channel is
# configured and an audiobook was requested, route the search there (with its own
# search bot if set). Otherwise fall back to the main channel/bot, which keeps the
# single-channel networks that index both formats working unchanged.
if is_audiobook(content_type):
audiobook_channel = _config_text("IRC_AUDIOBOOK_CHANNEL")
if audiobook_channel:
channel = audiobook_channel
audiobook_search_bot = _config_text("IRC_AUDIOBOOK_SEARCH_BOT")
if audiobook_search_bot:
search_bot = audiobook_search_bot
# Never post an unaddressed query to the channel. A bare book title looks like
# spam to everyone else in the channel and gets the nick banned. Searches must
# be addressed to a search bot ("@<bot> <query>").
+33 -7
View File
@@ -158,36 +158,55 @@ class ProwlarrClient:
logger.info("Prowlarr connection successful: version %s", version)
return True, f"Connected to Prowlarr {version}"
def get_indexers(self) -> list[dict[str, Any]]:
"""Get all configured indexers."""
def get_indexers(self, *, raise_on_error: bool = False) -> list[dict[str, Any]]:
"""Get all configured indexers.
Args:
raise_on_error: When True, propagate API failures instead of
returning an empty list. Callers that must distinguish
"no indexers" from "the request failed" should set this.
"""
try:
return _normalize_json_object_list(
self._request("GET", "/api/v1/indexer"),
context="Prowlarr indexer list",
)
except _PROWLARR_CLIENT_ERRORS:
if raise_on_error:
raise
logger.exception("Failed to get indexers")
return []
def get_enabled_indexers_detailed(self) -> list[dict[str, Any]]:
def get_enabled_indexers_detailed(
self, *, raise_on_error: bool = False
) -> list[dict[str, Any]]:
"""Get enabled indexers, including implementation metadata.
Note: Prowlarr indexer "name" is user-configurable; prefer
"implementation"/"implementationName" for stable identification.
"""
indexers = self.get_indexers()
indexers = self.get_indexers(raise_on_error=raise_on_error)
return [idx for idx in indexers if idx.get("enable", False)]
def get_enriched_indexer_ids(self, *, restrict_to: list[int] | None = None) -> list[int]:
def get_enriched_indexer_ids(
self,
*,
restrict_to: list[int] | None = None,
indexers: list[dict[str, Any]] | None = None,
) -> list[int]:
"""Return enabled indexer IDs that benefit from extra Torznab handling.
Args:
restrict_to: Optional list of candidate indexer IDs to consider.
indexers: Optional already-fetched enabled indexer list, so callers
that need the full records for other reasons can avoid a second
round trip.
"""
enriched_ids: list[int] = []
for idx in self.get_enabled_indexers_detailed():
for idx in indexers if indexers is not None else self.get_enabled_indexers_detailed():
idx_id_int = coerce_int_like(idx.get("id"))
if idx_id_int is None:
continue
@@ -214,10 +233,17 @@ class ProwlarrClient:
Prowlarr exposes seedTime in minutes, which is also the unit expected by
torrent clients.
Raises:
requests.exceptions.RequestException (and other client errors) when
the indexer list cannot be fetched. An empty dict strictly means
"no share limits are configured", never "the request failed" -
callers rely on this to avoid silently dropping seed limits.
"""
settings_by_indexer: dict[int, IndexerSeedSettings] = {}
for idx in self.get_enabled_indexers_detailed():
for idx in self.get_enabled_indexers_detailed(raise_on_error=True):
idx_id_int = coerce_int_like(idx.get("id"))
if idx_id_int is None:
continue
+200 -16
View File
@@ -1,10 +1,15 @@
"""Prowlarr download handler - resolves releases and delegates lifecycle to shared clients."""
from typing import TYPE_CHECKING, Any
from urllib.parse import urlparse
import requests
from shelfmark.core.config import config
from shelfmark.core.logger import setup_logger
from shelfmark.core.request_helpers import normalize_optional_text
from shelfmark.core.search_plan import build_release_search_plan
from shelfmark.core.utils import normalize_http_url
from shelfmark.download.clients import (
DownloadClient,
get_client,
@@ -23,9 +28,13 @@ from shelfmark.download.clients.base_handler import (
DownloadRequest,
ExternalClientHandler,
)
from shelfmark.metadata_providers import BookMetadata
from shelfmark.release_sources import register_handler
from shelfmark.release_sources.prowlarr.cache import get_release, remove_release
from shelfmark.release_sources.prowlarr.api import IndexerSeedSettings, ProwlarrClient
from shelfmark.release_sources.prowlarr.cache import cache_release, get_release, remove_release
from shelfmark.release_sources.prowlarr.source import ProwlarrSource
from shelfmark.release_sources.prowlarr.utils import (
build_source_id,
coerce_int_like,
get_preferred_download_url,
get_protocol,
@@ -37,6 +46,16 @@ if TYPE_CHECKING:
from shelfmark.core.models import DownloadTask
logger = setup_logger(__name__)
# Errors that ProwlarrClient can raise when fetching indexer settings.
_SEED_SETTINGS_FALLBACK_ERRORS = (
requests.exceptions.RequestException,
OSError,
RuntimeError,
TypeError,
ValueError,
)
__all__ = [
"ProwlarrHandler",
"POLL_INTERVAL",
@@ -49,6 +68,11 @@ __all__ = [
POLL_INTERVAL = _DEFAULT_POLL_INTERVAL
COMPLETED_PATH_RETRY_INTERVAL = _DEFAULT_COMPLETED_PATH_RETRY_INTERVAL
COMPLETED_PATH_MAX_ATTEMPTS = _DEFAULT_COMPLETED_PATH_MAX_ATTEMPTS
EXPIRED_LINK_REFRESH_ERROR = (
"The indexer download link expired and the release could not be refreshed. "
"Search again for a fresh result."
)
HASH_DETECTION_ERROR = "Could not determine torrent hash from URL"
def _coerce_positive_minutes(raw_minutes: object) -> int | None:
@@ -62,6 +86,47 @@ def _coerce_positive_minutes(raw_minutes: object) -> int | None:
class ProwlarrHandler(ExternalClientHandler):
"""Handler for Prowlarr downloads via configured torrent or usenet client."""
@staticmethod
def _build_prowlarr_client() -> ProwlarrClient | None:
"""Build a ProwlarrClient from config, or None if not configured."""
raw_url = config.get("PROWLARR_URL", "")
raw_api_key = config.get("PROWLARR_API_KEY", "")
url = normalize_optional_text(raw_url) if isinstance(raw_url, str) else None
api_key = normalize_optional_text(raw_api_key) if isinstance(raw_api_key, str) else None
if not url or not api_key:
return None
normalized_url = normalize_http_url(url)
if not normalized_url:
return None
return ProwlarrClient(normalized_url, api_key)
def _fetch_seed_settings_fallback(self, raw_indexer_id: object) -> IndexerSeedSettings | None:
"""Fetch share limits for one indexer directly from Prowlarr.
Used when the cached release is missing its search-time seed-limit
enrichment so that transient failures during search cannot cause a
torrent to be added without its configured share limits.
"""
indexer_id = coerce_int_like(raw_indexer_id)
if indexer_id is None:
return None
client = self._build_prowlarr_client()
if client is None:
return None
try:
settings = client.get_indexer_seed_settings(restrict_to=[indexer_id])
except _SEED_SETTINGS_FALLBACK_ERRORS:
logger.warning(
"Grab-time seed settings fallback failed for indexerId=%s",
indexer_id,
exc_info=True,
)
return None
return settings.get(indexer_id)
def _get_client(self, protocol: str) -> DownloadClient | None:
"""Compatibility shim so module-level patching still works in tests."""
return get_client(protocol)
@@ -81,18 +146,30 @@ class ProwlarrHandler(ExternalClientHandler):
def build_retry_resolution_fields(self, release_data: dict[str, Any]) -> dict[str, Any]:
source_id = normalize_optional_text(release_data.get("source_id"))
if source_id is None:
return {}
extra = release_data.get("extra")
if not isinstance(extra, dict):
extra = {}
prowlarr_result = get_release(source_id)
if prowlarr_result is None:
return {}
retry_source_context: dict[str, Any] = {}
indexer_id = release_data.get("indexer_id") or extra.get("indexer_id")
if indexer_id is not None:
retry_source_context["indexer_id"] = indexer_id
indexer = normalize_optional_text(release_data.get("indexer") or extra.get("indexer"))
if indexer is not None and indexer.lower() != "unknown":
retry_source_context["indexer"] = indexer
info_url = normalize_optional_text(release_data.get("info_url") or extra.get("info_url"))
if info_url is not None:
retry_source_context["info_url"] = info_url
if source_id is not None:
retry_source_context["source_id"] = source_id
return {
"retry_download_url": normalize_optional_text(
get_preferred_download_url(prowlarr_result)
),
"retry_download_protocol": normalize_optional_text(get_protocol(prowlarr_result)),
"retry_download_url": None,
"retry_download_protocol": None,
"retry_source_context": retry_source_context,
}
@classmethod
@@ -139,13 +216,12 @@ class ProwlarrHandler(ExternalClientHandler):
# Look up the cached release
prowlarr_result = get_release(task.task_id)
if not prowlarr_result:
restored_request = self._restore_download_request_from_task(task)
if restored_request is None:
logger.warning("Release cache miss: %s", task.task_id)
status_callback("error", "Release not found in cache (may have expired)")
logger.info("Prowlarr release cache miss, refreshing: %s", task.task_id)
prowlarr_result = self._refresh_release(task)
if prowlarr_result is None:
logger.warning("Prowlarr release refresh failed: %s", task.task_id)
status_callback("error", EXPIRED_LINK_REFRESH_ERROR)
return None
logger.info("Restored Prowlarr download request for retry: %s", task.task_id)
return restored_request
# Extract download URL
download_url = get_preferred_download_url(prowlarr_result)
@@ -171,6 +247,28 @@ class ProwlarrHandler(ExternalClientHandler):
seeding_time_limit = _coerce_positive_minutes(raw_configured_seed_time)
ratio_limit = float(raw_configured_ratio) if raw_configured_ratio is not None else None
# Fallback: search-time enrichment can be missing when the indexer
# settings fetch transiently failed during the search (#795).
# Re-resolve the limits from Prowlarr at grab time so torrents are
# never sent to the client without their configured share limits.
if seeding_time_limit is None and ratio_limit is None and protocol == "torrent":
fallback = self._fetch_seed_settings_fallback(prowlarr_result.get("indexerId"))
if fallback:
seeding_time_limit = _coerce_positive_minutes(
fallback.get("seeding_time_limit_minutes")
)
raw_ratio = fallback.get("ratio_limit")
ratio_limit = float(raw_ratio) if raw_ratio is not None else None
if seeding_time_limit is None and ratio_limit is None and protocol == "torrent":
logger.warning(
"Prowlarr seed preferences are enabled but no share limits "
"could be resolved for release '%s' (indexerId=%s); the "
"torrent will use the client's global limits",
release_name,
prowlarr_result.get("indexerId"),
)
return DownloadRequest(
url=download_url,
protocol=protocol,
@@ -180,6 +278,92 @@ class ProwlarrHandler(ExternalClientHandler):
ratio_limit=ratio_limit,
)
def _refresh_release(self, task: DownloadTask) -> dict[str, Any] | None:
"""Re-query Prowlarr and cache the exact original release if it still exists."""
title = normalize_optional_text(task.title)
if title is None:
return None
context = getattr(task, "retry_source_context", None)
if not isinstance(context, dict):
context = {}
indexer = normalize_optional_text(context.get("indexer"))
book = BookMetadata(
provider="shelfmark",
provider_id=task.task_id,
title=title,
authors=[task.author] if task.author else [],
search_title=title,
search_author=task.author,
)
plan = build_release_search_plan(
book,
indexers=[indexer] if indexer is not None else None,
)
source = ProwlarrSource()
results = source.search(book, plan, content_type=task.content_type or "ebook")
for release in results:
raw_release = get_release(release.source_id)
if raw_release is None:
continue
if not self._raw_release_matches_task(raw_release, task.task_id):
continue
cache_release(task.task_id, raw_release)
logger.info("Refreshed Prowlarr release: %s", task.task_id)
return raw_release
return None
@staticmethod
def _raw_release_matches_task(raw_release: dict[str, Any], task_id: str) -> bool:
wanted = normalize_optional_text(task_id)
if wanted is None:
return False
bare = [
identity
for identity in (
normalize_optional_text(raw_release.get("guid")),
normalize_optional_text(raw_release.get("infoUrl")),
)
if identity is not None
]
identities = [*bare, build_source_id(raw_release)]
indexer_id = coerce_int_like(raw_release.get("indexerId"))
if indexer_id is not None:
identities.extend(f"{indexer_id}:{identity}" for identity in bare)
return wanted in identities
def _refresh_download_request_after_add_failure(
self,
*,
task: DownloadTask,
request: DownloadRequest,
error: Exception,
status_callback: Callable[[str, str | None], None],
) -> DownloadRequest | None:
"""Refresh once when a cached Prowlarr torrent proxy URL has expired."""
if request.protocol != "torrent":
return None
if HASH_DETECTION_ERROR not in str(error):
return None
parsed = urlparse(request.url)
if parsed.scheme.lower() not in {"http", "https"}:
return None
logger.info("Refreshing stale Prowlarr torrent URL for %s", task.task_id)
remove_release(task.task_id)
refreshed_request = self._resolve_download(task, status_callback)
if refreshed_request is None:
raise RuntimeError(EXPIRED_LINK_REFRESH_ERROR) from error
return refreshed_request
def _on_download_complete(self, task: DownloadTask) -> None:
"""Remove completed release from the Prowlarr cache."""
remove_release(task.task_id)
@@ -190,6 +190,18 @@ def prowlarr_config_settings() -> list[SettingsField]:
description="Automatically retry search without category filtering if no results are found",
show_when={"field": "PROWLARR_ENABLED", "value": True},
),
CheckboxField(
key="PROWLARR_COLLAPSE_DUPLICATES",
label="Show one row per release",
default=True,
description=(
"Collapse a release that several indexer entries returned down to a single row, "
"keeping the entry with the best Prowlarr priority. Turn this off to see every "
"entry that carried it, which is what makes results from filter-specific entries "
"(freeleech and the like) visible."
),
show_when={"field": "PROWLARR_ENABLED", "value": True},
),
CheckboxField(
key="PROWLARR_USE_SEED_PREFERENCES",
label="Use Prowlarr seed preferences",
+193 -63
View File
@@ -2,13 +2,17 @@
import re
import time
from threading import Lock
from typing import TYPE_CHECKING, ClassVar, NoReturn
import requests
if TYPE_CHECKING:
from shelfmark.core.search_plan import ReleaseSearchPlan
from shelfmark.metadata_providers import BookMetadata
from shelfmark.core.config import config
from shelfmark.core.languages import normalize_language
from shelfmark.core.logger import setup_logger
from shelfmark.core.request_helpers import normalize_optional_text
from shelfmark.core.search_plan import ReleaseSearchVariant
@@ -30,6 +34,7 @@ from shelfmark.release_sources import (
from shelfmark.release_sources.prowlarr.api import IndexerSeedSettings, ProwlarrClient
from shelfmark.release_sources.prowlarr.cache import cache_release
from shelfmark.release_sources.prowlarr.utils import (
build_source_id,
coerce_float_like,
coerce_int_like,
get_protocol,
@@ -41,6 +46,14 @@ _SIZE_UNIT_BASE = 1024
_TWO_FORMATS = 2
_PROWLARR_SOURCE_ERRORS = (AttributeError, OSError, RuntimeError, TypeError, ValueError)
# Prowlarr indexer priority is 1-50 and lower is preferred; unknown sorts last.
_UNRANKED_INDEXER_RANK = 51
# Errors that can surface from ProwlarrClient.get_indexer_seed_settings(). The
# client raises requests exceptions (subclasses of OSError via IOError lineage
# is not guaranteed), so include RequestException explicitly.
_PROWLARR_SEED_SETTINGS_ERRORS = (*_PROWLARR_SOURCE_ERRORS, requests.exceptions.RequestException)
def _raise_timeout_error(message: str) -> NoReturn:
raise TimeoutError(message)
@@ -61,6 +74,118 @@ def _coerce_indexer_id(value: object) -> int | None:
return coerce_int_like(value)
def _identity_text(value: object) -> str | None:
"""Trimmed text for an identity field, or None when there is nothing usable."""
if isinstance(value, str):
return value.strip() or None
if isinstance(value, (int, float)) and not isinstance(value, bool):
return str(value)
return None
def _release_identity(result: dict) -> str | None:
"""Identify the underlying release, independent of which indexer surfaced it.
Strong identifiers only. Title is deliberately excluded because matching on
it here would merge two genuinely different releases that happen to share a
name, and every caller of this either drops or overwrites a row on a match.
Returns None when nothing identifies the result.
"""
for field in ("guid", "downloadUrl", "magnetUrl", "infoUrl"):
identity = _identity_text(result.get(field))
if identity is not None:
return identity
return None
def _result_dedup_key(result: dict) -> tuple[int | None, str] | None:
"""Dedup key for a raw Prowlarr result, or None if it cannot be identified.
One tracker is often configured in Prowlarr as several indexer entries that
differ only by a server-side search filter, say a "freeleech only" entry
alongside an unfiltered one. Those entries return the same guid for the same
torrent, so keying on the guid alone throws away the filtered entry's copy
and with it the only signal that the release matched the filter. Including
the indexer id keeps the entries distinct.
Title is an acceptable last resort here, unlike in _release_identity, because
the indexer id is part of the key: it only ever collapses a literal repeat
from one indexer, never two rows from different entries.
"""
identity = _release_identity(result) or _identity_text(result.get("title"))
if identity is None:
return None
return (_coerce_indexer_id(result.get("indexerId")), identity)
def _build_indexer_priority(indexers: list[dict]) -> dict[int, int]:
"""Map indexer id to the priority configured in Prowlarr. Lower is preferred.
Users already rank their indexers in Prowlarr, and on trackers configured as
several entries that ranking is usually the meaningful one: a "freeleech
only" entry is typically given a better priority than the unfiltered entry
beside it. Reusing it avoids asking for the same ordering a second time.
"""
priority: dict[int, int] = {}
for indexer in indexers:
indexer_id = _coerce_indexer_id(indexer.get("id"))
if indexer_id is None:
continue
rank = coerce_int_like(indexer.get("priority"))
if rank is not None:
priority[indexer_id] = rank
return priority
def _rank_for_indexer_id(indexer_id: object, priority: dict[int, int]) -> int:
"""Preference rank for an indexer id. Lower wins, unknown ranks last."""
coerced = _coerce_indexer_id(indexer_id)
if coerced is None:
return _UNRANKED_INDEXER_RANK
return priority.get(coerced, _UNRANKED_INDEXER_RANK)
def _indexer_rank(result: dict, priority: dict[int, int]) -> int:
"""Preference rank of the indexer that surfaced a raw result."""
return _rank_for_indexer_id(result.get("indexerId"), priority)
def _release_indexer_rank(release: Release, priority: dict[int, int]) -> int:
"""Preference rank of the indexer that surfaced a converted release."""
return _rank_for_indexer_id(release.extra.get("indexer_id"), priority)
def _collapse_duplicate_indexer_results(
results: list[dict], priority: dict[int, int]
) -> list[dict]:
"""Reduce a release to a single row, keeping the preferred indexer entry.
Opt-in behaviour for users who want one row per torrent. Ties keep the
result that was queried first, and the winner holds the loser's position so
the overall result order stays stable.
"""
position_by_identity: dict[str, int] = {}
kept: list[dict] = []
for result in results:
identity = _release_identity(result)
if identity is None:
kept.append(result)
continue
existing_position = position_by_identity.get(identity)
if existing_position is None:
position_by_identity[identity] = len(kept)
kept.append(result)
continue
if _indexer_rank(result, priority) < _indexer_rank(kept[existing_position], priority):
kept[existing_position] = result
return kept
def _parse_size(size_bytes: int | None) -> str | None:
"""Convert bytes to human-readable size string."""
if size_bytes is None or size_bytes <= 0:
@@ -102,50 +227,6 @@ AUDIOBOOK_FORMATS = ["m4b", "mp3", "m4a", "flac", "ogg", "wma", "aac", "wav", "o
# Combined list for format detection (audiobook formats first for priority)
ALL_BOOK_FORMATS = AUDIOBOOK_FORMATS + EBOOK_FORMATS
# Map 3-char MAM language codes to 2-char ISO codes used by frontend color maps
MAM_LANGUAGE_MAP = {
"eng": "en",
"ita": "it",
"spa": "es",
"fra": "fr",
"fre": "fr",
"ger": "de",
"deu": "de",
"por": "pt",
"rus": "ru",
"jpn": "ja",
"jap": "ja",
"chi": "zh",
"zho": "zh",
"dut": "nl",
"nld": "nl",
"swe": "sv",
"nor": "no",
"dan": "da",
"fin": "fi",
"pol": "pl",
"cze": "cs",
"ces": "cs",
"hun": "hu",
"kor": "ko",
"ara": "ar",
"heb": "he",
"tur": "tr",
"gre": "el",
"ell": "el",
"hin": "hi",
"tha": "th",
"vie": "vi",
"ind": "id",
"ukr": "uk",
"rom": "ro",
"ron": "ro",
"bul": "bg",
"cat": "ca",
"hrv": "hr",
"slv": "sl",
"srp": "sr",
}
# Backend safeguard: cap total Prowlarr search time per request.
PROWLARR_SEARCH_TIMEOUT_SECONDS = 120.0
@@ -193,8 +274,9 @@ def _extract_mam_language(raw_title: str) -> str | None:
for token in tokens:
lang_code = token.lower()
if lang_code in MAM_LANGUAGE_MAP:
return MAM_LANGUAGE_MAP[lang_code]
resolved = normalize_language(lang_code)
if resolved is not None:
return resolved
return None
@@ -379,8 +461,7 @@ def _prowlarr_result_to_release(
formats_display = _formats_display(formats)
language_detected = _extract_mam_language(str(raw_title or ""))
# Build the source_id from GUID or generate from indexer + title
source_id = result.get("guid") or f"{indexer}:{hash(raw_title)}"
source_id = build_source_id(result)
# Cache the raw Prowlarr result so handler can look it up by source_id
cache_release(source_id, result)
@@ -443,6 +524,36 @@ def _prowlarr_result_to_release(
)
# Last successfully fetched per-indexer share limits. Used as a fallback when
# a transient Prowlarr API failure prevents fetching fresh settings during a
# search, so results are never silently cached without seed limits (#795).
_seed_settings_lock = Lock()
_last_known_seed_settings: dict[int, IndexerSeedSettings] = {}
def _fetch_indexer_seed_settings(
client: ProwlarrClient,
indexer_ids: list[int] | None,
) -> dict[int, IndexerSeedSettings]:
"""Fetch per-indexer share limits, falling back to last-known-good on failure."""
try:
fetched = client.get_indexer_seed_settings(restrict_to=indexer_ids)
except _PROWLARR_SEED_SETTINGS_ERRORS:
with _seed_settings_lock:
fallback = dict(_last_known_seed_settings)
logger.warning(
"Failed to fetch Prowlarr indexer seed settings; "
"falling back to last known settings for %s indexer(s)",
len(fallback),
exc_info=True,
)
return fallback
with _seed_settings_lock:
_last_known_seed_settings.update(fetched)
return fetched
def _apply_indexer_seed_settings(
result: dict,
indexer_seed_settings: dict[int, IndexerSeedSettings],
@@ -574,6 +685,11 @@ class ProwlarrSource(ReleaseSource):
],
extra_sort_options=[
SortOption(label="Peers", sort_key="seeders"),
SortOption(
label="Indexer priority",
sort_key="extra.indexer_priority",
default_direction="asc",
),
],
grid_template="minmax(0,2fr) minmax(140px,1fr) 50px 50px 90px 80px",
leading_cell=LeadingCellConfig(
@@ -779,11 +895,15 @@ class ProwlarrSource(ReleaseSource):
try:
auto_expand_enabled = config.get("PROWLARR_AUTO_EXPAND", False)
deadline = time.monotonic() + PROWLARR_SEARCH_TIMEOUT_SECONDS
enabled_indexers = client.get_enabled_indexers_detailed()
indexer_priority = _build_indexer_priority(enabled_indexers)
# Some indexers benefit from title+author queries and extra format detection.
enriched_indexer_ids = client.get_enriched_indexer_ids(restrict_to=indexer_ids)
enriched_indexer_ids = client.get_enriched_indexer_ids(
restrict_to=indexer_ids, indexers=enabled_indexers
)
enriched_indexer_ids_set = set(enriched_indexer_ids)
indexer_seed_settings = (
client.get_indexer_seed_settings(restrict_to=indexer_ids)
_fetch_indexer_seed_settings(client, indexer_ids)
if config.get("PROWLARR_USE_SEED_PREFERENCES", False)
else {}
)
@@ -821,7 +941,7 @@ class ProwlarrSource(ReleaseSource):
return results
seen_keys: set[str] = set()
seen_keys: set[tuple[int | None, str]] = set()
all_results: list[dict] = []
for idx, variant in enumerate(variants, start=1):
@@ -849,18 +969,22 @@ class ProwlarrSource(ReleaseSource):
self.last_search_type = "expanded"
for r in raw_results:
key = (
r.get("guid")
or r.get("downloadUrl")
or r.get("magnetUrl")
or r.get("infoUrl")
or f"{r.get('indexerId')}:{r.get('title')}"
)
if key in seen_keys:
continue
seen_keys.add(key)
key = _result_dedup_key(r)
if key is not None:
if key in seen_keys:
continue
seen_keys.add(key)
all_results.append(r)
if config.get("PROWLARR_COLLAPSE_DUPLICATES", True):
before_collapse = len(all_results)
all_results = _collapse_duplicate_indexer_results(all_results, indexer_priority)
if len(all_results) != before_collapse:
logger.debug(
"Prowlarr: collapsed %s duplicate result(s) across indexer entries",
before_collapse - len(all_results),
)
results: list[Release] = []
enriched_source_ids: set[str] = set()
@@ -879,13 +1003,19 @@ class ProwlarrSource(ReleaseSource):
content_type,
enable_format_detection=is_enriched,
)
if idx_id_int is not None and idx_id_int in indexer_priority:
release.extra["indexer_priority"] = indexer_priority[idx_id_int]
results.append(release)
if is_enriched:
enriched_source_ids.add(release.source_id)
# Sort results: enriched indexers first, then others
results.sort(key=lambda r: 0 if r.source_id in enriched_source_ids else 1)
results.sort(
key=lambda r: (
_release_indexer_rank(r, indexer_priority),
0 if r.source_id in enriched_source_ids else 1,
)
)
if results:
torrent_count = sum(1 for r in results if r.protocol == ReleaseProtocol.TORRENT)
@@ -32,6 +32,28 @@ def coerce_int_like(value: object) -> int | None:
return int(normalized)
def build_source_id(result: dict) -> str:
"""Build the Release.source_id for a raw Prowlarr result.
Qualified by the indexer id because one tracker is often configured in
Prowlarr as several indexer entries that differ only by a server-side search
filter, and those entries return the same guid for the same torrent. Without
the qualifier the entries collide in the release cache and a grab routes
through whichever entry happened to cache last.
"""
guid = result.get("guid")
if guid:
base = str(guid)
else:
indexer = result.get("indexer", "Unknown")
base = f"{indexer}:{hash(result.get('title', 'Unknown'))}"
indexer_id = coerce_int_like(result.get("indexerId"))
if indexer_id is None:
return base
return f"{indexer_id}:{base}"
def coerce_float_like(value: object) -> float | None:
"""Return a float for float-like config/API values, else None."""
if isinstance(value, bool):
+762 -401
View File
File diff suppressed because it is too large Load Diff
+9 -9
View File
@@ -17,24 +17,24 @@
"test:unit": "vitest run"
},
"dependencies": {
"@tailwindcss/vite": "^4.3.2",
"@tailwindcss/vite": "^4.3.3",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-router-dom": "^7.18.1",
"socket.io-client": "^4.7.5"
},
"devDependencies": {
"@types/node": "^26.0.1",
"@types/node": "^26.1.1",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.3",
"knip": "^6.23.0",
"oxfmt": "^0.57.0",
"oxlint": "^1.72.0",
"oxlint-tsgolint": "^0.23.0",
"knip": "^6.27.0",
"oxfmt": "^0.59.0",
"oxlint": "^1.74.0",
"oxlint-tsgolint": "^0.25.0",
"tailwindcss": "^4.2.2",
"typescript": "^6.0.3",
"vite": "^8.1.0",
"vitest": "^4.1.9"
"typescript": "^7.0.2",
"vite": "^8.1.5",
"vitest": "^4.1.10"
}
}
+3
View File
@@ -1098,6 +1098,9 @@ function App() {
series_name: book.series_name,
series_position: book.series_position,
subtitle: book.subtitle,
// From the release, never the book: book.language is the provider's
// canonical edition, which would mislabel a translated release.
language: release.language ?? undefined,
};
},
[],
+5 -1
View File
@@ -382,7 +382,11 @@ export const ReleaseCell = ({
className={`flex items-center ${alignClass} gap-1.5 truncate text-xs text-gray-600 dark:text-gray-300`}
>
<span className={`h-2 w-2 shrink-0 rounded-full ${dotColor}`} title={protocolLabel} />
<span className="truncate">{displayValue}</span>
{/* Titled because one tracker can appear as several indexer entries whose
names share a prefix, and truncation would make the rows look identical */}
<span className="truncate" title={displayValue}>
{displayValue}
</span>
{peers && <span className="shrink-0 text-gray-400 dark:text-gray-500">{peers}</span>}
</div>
);
+1 -1
View File
@@ -956,7 +956,7 @@ const ReleaseModalSession = ({
const fromExtra = (columnConfig.extra_sort_options || []).map((opt) => ({
label: opt.label,
sortKey: opt.sort_key,
defaultDirection: 'desc' as const, // Extra sort options are typically numeric (e.g., peers)
defaultDirection: opt.default_direction ?? ('desc' as const),
}));
return [...fromColumns, ...fromExtra];
}, [sortableColumns, columnConfig.extra_sort_options]);
@@ -21,7 +21,6 @@ import {
normalizeUserOverrideSections,
UserOverridesSections,
} from './users';
import type { PerUserSettings } from './users/types';
import { UserAccountCardContent, UserEditActions, UserIdentityHeader } from './users/UserCard';
import { useUserOverridesState } from './users/useUserOverridesState';
@@ -153,7 +152,7 @@ const SelfSettingsModalSession = ({
normalizeUserOverrideSections(context.visibleUserSettingsSections, 'self'),
);
applyUserOverridesContext({
settings: (context.user.settings || {}) as PerUserSettings,
settings: context.user.settings || {},
userOverridableKeys: context.userOverridableKeys || [],
});
setEditPassword('');
+4 -4
View File
@@ -146,7 +146,7 @@ export const useUsersFetch = ({ onShowToast }: UseUsersFetchParams) => {
if (fullUser.settings) {
userSettings = {
...fullUser.settings,
} as PerUserSettings;
};
}
const userOverridableSettings = new Set<string>();
@@ -162,7 +162,7 @@ export const useUsersFetch = ({ onShowToast }: UseUsersFetchParams) => {
userSettings = {
...userSettings,
...deliveryResult.value.userOverrides,
} as PerUserSettings;
};
}
deliveryResult.value.keys.forEach((key) => userOverridableSettings.add(key));
}
@@ -173,7 +173,7 @@ export const useUsersFetch = ({ onShowToast }: UseUsersFetchParams) => {
userSettings = {
...userSettings,
...searchResult.value.userOverrides,
} as PerUserSettings;
};
}
searchResult.value.keys.forEach((key) => userOverridableSettings.add(key));
}
@@ -184,7 +184,7 @@ export const useUsersFetch = ({ onShowToast }: UseUsersFetchParams) => {
userSettings = {
...userSettings,
...notificationResult.value.userOverrides,
} as PerUserSettings;
};
}
notificationResult.value.keys.forEach((key) => userOverridableSettings.add(key));
}
+1
View File
@@ -500,6 +500,7 @@ export type DownloadReleasePayload = {
series_name?: string;
series_position?: number;
subtitle?: string;
language?: string; // Release language code, for the {Language} naming variable
search_author?: string;
search_mode?: 'direct' | 'universal';
};
@@ -48,4 +48,47 @@ describe('namingTemplatePreview', () => {
expect(preview.unknownTokens).toEqual(['NotAThing']);
expect(preview.value).toBe('Arthur Conan Doyle');
});
it('offers Language as a core variable for both content types', () => {
const language = NAMING_TEMPLATE_TOKENS.find((token) => token.token === 'Language');
expect(language?.group).toBe('Core');
expect(language?.audiobookOnly).toBeFalsy();
});
it('separates translated editions into their own folder', () => {
const template = '{Author}/{Title}{ (Language)}';
const swedish = renderNamingTemplate(
template,
{
...SAMPLE_NAMING_METADATA,
Author: 'Andy Weir',
Title: 'Project Hail Mary',
Language: 'sv',
},
{ allowPathSeparators: true },
);
const english = renderNamingTemplate(
template,
{ ...SAMPLE_NAMING_METADATA, Author: 'Andy Weir', Title: 'Project Hail Mary', Language: '' },
{ allowPathSeparators: true },
);
expect(swedish.value).toBe('Andy Weir/Project Hail Mary (sv)');
expect(english.value).toBe('Andy Weir/Project Hail Mary');
expect(swedish.value).not.toBe(english.value);
});
it('keeps the picker and the known-token list in lockstep', () => {
// KNOWN_TOKENS is a hand-maintained duplicate of the Python list. A token
// added to the picker but not to it would render as an unknown variable.
for (const token of NAMING_TEMPLATE_TOKENS) {
const preview = renderNamingTemplate(`{${token.token}}`, SAMPLE_NAMING_METADATA, {
allowPathSeparators: true,
});
expect(preview.unknownTokens, `${token.token} is missing from KNOWN_TOKENS`).toEqual([]);
}
});
});
@@ -138,4 +138,29 @@ describe('requestPayload utilities', () => {
);
expect(getRequestSuccessMessage(payloadUntitled)).toBe('Request submitted: Untitled');
});
it('takes the language from the release, not the book', () => {
// book.language is the metadata provider's canonical edition. Using it would
// label a Swedish release "en" and put both editions back in one folder.
const data = buildReleaseDataFromMetadataRelease(
{ ...baseBook, language: 'en' },
{ ...baseRelease, language: 'sv' },
'ebook',
);
expect(data.language).toBe('sv');
});
it('leaves language undefined when the release has none', () => {
const data = buildReleaseDataFromMetadataRelease(baseBook, baseRelease, 'ebook');
expect(data.language).toBeUndefined();
});
it('uses the book language when browsing a source directly', () => {
// In direct mode the book record IS the release record.
const data = buildReleaseDataFromDirectBook({ ...baseBook, language: 'de' });
expect(data.language).toBe('de');
});
});
+1
View File
@@ -414,6 +414,7 @@ export interface LeadingCellConfig {
export interface ExtraSortOption {
label: string; // Display label in the sort dropdown
sort_key: string; // Field to sort by on the Release object
default_direction?: 'asc' | 'desc'; // Which way "best first" runs (defaults to desc)
}
export interface SourceActionButton {
@@ -48,6 +48,13 @@ export const NAMING_TEMPLATE_TOKENS: NamingTemplateToken[] = [
value: '1902',
group: 'Core',
},
{
token: 'Language',
label: 'Language',
description: 'Release language code, so translations do not share a folder',
value: 'en',
group: 'Core',
},
{
token: 'User',
label: 'User',
@@ -98,6 +105,7 @@ const KNOWN_TOKENS = [
'primarytitle',
'originalname',
'partnumber',
'language',
'subtitle',
'author',
'series',
+3
View File
@@ -90,6 +90,7 @@ export const buildReleaseDataFromMetadataRelease = (
series_position: book.series_position,
series_count: book.series_count,
subtitle: book.subtitle,
language: release.language,
...(isSourceBackedReleaseContext ? { search_mode: 'direct' as const } : {}),
};
};
@@ -106,6 +107,8 @@ export const buildReleaseDataFromDirectBook = (book: Book) => {
size: book.size,
preview: book.preview,
content_type: 'ebook' as const,
// Browsing a source directly means the book record IS the release record.
language: book.language,
search_mode: 'direct' as const,
};
};
+2
View File
@@ -169,6 +169,7 @@ uv run pytest tests/ --cov=shelfmark -m "not integration"
```python
from unittest.mock import MagicMock, patch
class TestMyFeature:
def test_something(self, monkeypatch):
# Mock config values
@@ -188,6 +189,7 @@ class TestMyFeature:
import pytest
from .conftest import APIClient, DownloadTracker
@pytest.mark.e2e
class TestMyEndpoint:
def test_endpoint_works(self, protected_api_client: APIClient):
+1 -1
View File
@@ -53,7 +53,7 @@ def test_generated_env_docs_include_custom_component_value_fields() -> None:
assert (
"| `TEMPLATE_AUDIOBOOK_ORGANIZE` | Use / to create folders. Variables: "
"{Author}, {Title}, {Year}, {User}, {OriginalName} "
"{Author}, {Title}, {Year}, {Language}, {User}, {OriginalName} "
"(source filename without extension), {Series}, {SeriesPosition}, {Subtitle}, "
"{PrimaryTitle}, {PartNumber}. Use arbitrary prefix/suffix:"
) in docs
+82
View File
@@ -0,0 +1,82 @@
"""Tests for the OIDC Test Connection handler."""
from unittest.mock import MagicMock, patch
from shelfmark.config.security_handlers import check_oidc_connection
DISCOVERY_URL = "https://auth.example.com/.well-known/openid-configuration"
DISCOVERY_DOCUMENT = {
"issuer": "https://auth.example.com",
"authorization_endpoint": "https://auth.example.com/authorize",
"token_endpoint": "https://auth.example.com/token",
"jwks_uri": "https://auth.example.com/jwks",
}
def _mock_response(payload):
response = MagicMock()
response.json.return_value = payload
response.raise_for_status.return_value = None
return response
def _run_check(responses):
"""Run check_oidc_connection with requests.get returning the given responses."""
with (
patch("requests.get", side_effect=responses) as mock_get,
patch("shelfmark.config.security_handlers.get_ssl_verify", return_value=True),
):
result = check_oidc_connection(
load_security_config=lambda: {"OIDC_DISCOVERY_URL": DISCOVERY_URL},
current_values={},
logger=MagicMock(),
)
return result, mock_get
class TestCheckOIDCConnection:
def test_succeeds_when_discovery_and_jwks_are_valid(self):
responses = [
_mock_response(DISCOVERY_DOCUMENT),
_mock_response({"keys": [{"kty": "RSA", "kid": "abc"}]}),
]
result, mock_get = _run_check(responses)
assert result["success"] is True
assert "Connected to" in result["message"]
jwks_call = mock_get.call_args_list[1]
assert jwks_call.args[0] == DISCOVERY_DOCUMENT["jwks_uri"]
def test_fails_with_signing_key_guidance_when_jwks_is_empty(self):
responses = [
_mock_response(DISCOVERY_DOCUMENT),
_mock_response({}),
]
result, _ = _run_check(responses)
assert result["success"] is False
assert "no token signing keys" in result["message"]
assert "Signing Key" in result["message"]
def test_fails_with_signing_key_guidance_when_jwks_keys_list_is_empty(self):
responses = [
_mock_response(DISCOVERY_DOCUMENT),
_mock_response({"keys": []}),
]
result, _ = _run_check(responses)
assert result["success"] is False
assert "no token signing keys" in result["message"]
def test_fails_when_discovery_document_missing_jwks_uri(self):
document = {k: v for k, v in DISCOVERY_DOCUMENT.items() if k != "jwks_uri"}
responses = [_mock_response(document)]
result, _ = _run_check(responses)
assert result["success"] is False
assert "jwks_uri" in result["message"]
def test_fails_when_jwks_request_errors(self):
jwks_response = MagicMock()
jwks_response.raise_for_status.side_effect = RuntimeError("boom")
responses = [_mock_response(DISCOVERY_DOCUMENT), jwks_response]
result, _ = _run_check(responses)
assert result["success"] is False
assert "Connection failed" in result["message"]
+323 -5
View File
@@ -1,9 +1,20 @@
from __future__ import annotations
import os
import shutil
import signal
import socket
import subprocess
from contextlib import suppress
from pathlib import Path
import pytest
TOR_SCRIPT_PATH = Path(__file__).resolve().parents[2] / "tor.sh"
IPTABLES_BLOCK_START = 'echo "[*] Setting up iptables rules..."'
IPTABLES_BLOCK_END = 'echo "[✓] Transparent Tor routing enabled."'
def _generated_tor_healthcheck_script() -> str:
script = TOR_SCRIPT_PATH.read_text()
@@ -14,17 +25,208 @@ def _generated_tor_healthcheck_script() -> str:
def _tor_script_rule_lines() -> list[str]:
return [
line.strip()
for line in TOR_SCRIPT_PATH.read_text().splitlines()
if line.strip().startswith("iptables ")
]
"""Return the iptables invocations in tor.sh, in file order.
Rules that are applied on a best-effort basis are written as
``if iptables ...; then``, so the shell wrapper is stripped to keep the
ordering assertions below comparing rules rather than syntax.
"""
lines = []
for raw_line in TOR_SCRIPT_PATH.read_text().splitlines():
line = raw_line.strip().removeprefix("if ")
if line.startswith("iptables "):
lines.append(line)
return lines
def _line_index(lines: list[str], needle: str) -> int:
return next(index for index, line in enumerate(lines) if needle in line)
def _tor_iptables_block() -> str:
"""Extract the firewall setup section of tor.sh so it can be executed."""
script = TOR_SCRIPT_PATH.read_text()
start = script.index(IPTABLES_BLOCK_START)
end = script.index(IPTABLES_BLOCK_END)
return script[start:end]
# Verbatim stderr of an iptables build whose kernel lacks the xt_owner module,
# as reported from Synology DSM in issue #1150.
MISSING_XT_OWNER_STDERR = (
"Warning: Extension owner revision 0 not supported, missing kernel module?\n"
"iptables: No chain/target/match by that name.\n"
)
IPTABLES_STUB = f"""#!/bin/bash
if [ "$FAKE_OWNER_MATCH_SUPPORTED" != "1" ]; then
case "$*" in
*--uid-owner*)
printf '%s' {MISSING_XT_OWNER_STDERR!r} >&2
exit 1
;;
esac
fi
echo "$*" >> "$FAKE_IPTABLES_LOG"
exit 0
"""
# tor.sh resolves the Tor uid with `id -u debian-tor`; that account only exists
# inside the image, so stub it out with the uid the Debian tor package uses.
ID_STUB = """#!/bin/bash
echo 107
"""
def _run_tor_iptables_block(tmp_path: Path, *, owner_match_supported: bool):
"""Run tor.sh's firewall block against a fake iptables.
Returns the completed process plus the rules the fake iptables accepted.
"""
bin_dir = tmp_path / "bin"
bin_dir.mkdir()
for name, source in (("iptables", IPTABLES_STUB), ("id", ID_STUB)):
stub = bin_dir / name
stub.write_text(source)
stub.chmod(0o755)
applied_rules_log = tmp_path / "applied-rules.log"
# tor.sh runs under `set -e`, which is what turns a rejected rule into a
# container restart loop, so reproduce that here.
result = subprocess.run(
["bash", "-c", "set -e\n" + _tor_iptables_block()],
capture_output=True,
text=True,
env={
"PATH": f"{bin_dir}:/usr/bin:/bin",
"FAKE_IPTABLES_LOG": str(applied_rules_log),
"FAKE_OWNER_MATCH_SUPPORTED": "1" if owner_match_supported else "0",
},
)
applied_rules = applied_rules_log.read_text().splitlines() if applied_rules_log.exists() else []
return result, applied_rules
requires_bash = pytest.mark.skipif(
shutil.which("bash") is None, reason="bash is required to execute tor.sh"
)
# The watchdog shells out to `timeout`, which is not present on stock macOS.
requires_timeout = pytest.mark.skipif(
shutil.which("timeout") is None, reason="coreutils timeout is required"
)
SUPERVISORCTL_STUB = """#!/bin/bash
# Emulates `supervisorctl status tor`. Normally reports FAKE_TOR_STATE, but when
# FAKE_TOR_FAIL_FIRST is set the first call reports a transient failure so the
# watchdog's retry behaviour can be exercised.
if [ -n "$FAKE_TOR_FAIL_FIRST" ] && [ ! -f "$FAKE_TOR_CALL_MARKER" ]; then
touch "$FAKE_TOR_CALL_MARKER"
echo "tor FATAL Exited too quickly"
exit 3
fi
echo "tor $FAKE_TOR_STATE pid 42, uptime 0:01:00"
"""
def _watchdog_path_env(bin_dir: Path) -> str:
"""PATH exposing the stubs plus the real bash/timeout the watchdog calls."""
parts = [str(bin_dir)]
for tool in ("bash", "timeout"):
resolved = shutil.which(tool)
if resolved:
parts.append(str(Path(resolved).parent))
parts += ["/usr/bin", "/bin"]
return ":".join(parts)
def _run_tor_watchdog(
tmp_path: Path,
*,
tor_state: str = "RUNNING",
bootstrapped: bool = True,
trans_port_open: bool = True,
fail_first: bool = False,
run_for: float = 2.0,
):
"""Run tor.sh's watchdog against a fake Tor and see if it resets the container.
Returns ``(reset_signal, stdout)`` where ``reset_signal`` is the signal the
stand-in for PID 1 received, or ``None`` if it was left alone.
"""
bin_dir = tmp_path / "bin"
bin_dir.mkdir()
supervisorctl = bin_dir / "supervisorctl"
supervisorctl.write_text(SUPERVISORCTL_STUB)
supervisorctl.chmod(0o755)
notices_log = tmp_path / "notices.log"
notices_log.write_text(
"Bootstrapped 100%: Done\n" if bootstrapped else "Bootstrapped 45%: Loading\n"
)
script = tmp_path / "tor_healthcheck.sh"
script.write_text(_generated_tor_healthcheck_script())
script.chmod(0o755)
listener = socket.socket()
listener.bind(("127.0.0.1", 0))
trans_port = listener.getsockname()[1]
if trans_port_open:
listener.listen(5)
else:
# Closing it leaves a port nothing is listening on, which is what a dead
# or wedged Tor looks like from the outside.
listener.close()
# Stands in for PID 1: the watchdog signals it instead of dumb-init.
container_process = subprocess.Popen(
["sleep", "120"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
)
env = {
"PATH": _watchdog_path_env(bin_dir),
"TOR_CHECK_INTERVAL": "0.2",
"TOR_NOTICES_LOG": str(notices_log),
"TOR_TRANS_PORT": str(trans_port),
"TOR_CONTAINER_PID": str(container_process.pid),
"FAKE_TOR_STATE": tor_state,
"FAKE_TOR_CALL_MARKER": str(tmp_path / "supervisorctl.called"),
}
if fail_first:
env["FAKE_TOR_FAIL_FIRST"] = "1"
watchdog = subprocess.Popen(
["bash", str(script)],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
env=env,
start_new_session=True,
)
try:
try:
container_process.wait(timeout=run_for)
except subprocess.TimeoutExpired:
pass
reset_signal = (
-container_process.returncode if container_process.poll() is not None else None
)
finally:
# Only the watchdog gets its own session; signalling the group is how we
# reap the `sleep` and `timeout` children it spawns.
with suppress(ProcessLookupError, PermissionError):
os.killpg(os.getpgid(watchdog.pid), signal.SIGKILL)
with suppress(ProcessLookupError):
container_process.kill()
with suppress(OSError):
listener.close()
container_process.wait()
stdout = watchdog.communicate()[0] or ""
return reset_signal, stdout
def test_tor_nat_rules_bypass_private_networks_before_tcp_redirect():
lines = _tor_script_rule_lines()
tcp_redirect_index = _line_index(lines, "--syn -j REDIRECT --to-ports 9040")
@@ -63,3 +265,119 @@ def test_tor_healthcheck_uses_local_tor_state_without_clear_net_probe():
assert "curl " not in healthcheck_script
assert "supervisorctl status tor" in healthcheck_script
assert "Bootstrapped 100%" in healthcheck_script
@requires_bash
def test_tor_iptables_setup_survives_kernel_without_owner_match(tmp_path):
"""Regression test for issue #1150.
Synology (and other NAS/embedded) kernels ship without xt_owner. The owner
exemption is a best-effort optimisation, so a kernel that rejects it must
not abort tor.sh and put the container into a restart loop.
"""
result, _ = _run_tor_iptables_block(tmp_path, owner_match_supported=False)
assert result.returncode == 0, (
"tor.sh aborted on a kernel without xt_owner:\n"
f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}"
)
@requires_bash
def test_tor_routing_still_applied_when_owner_match_unsupported(tmp_path):
"""Degrading past the owner rule must not skip the rules that torify traffic."""
_, applied_rules = _run_tor_iptables_block(tmp_path, owner_match_supported=False)
assert any("--syn -j REDIRECT --to-ports 9040" in rule for rule in applied_rules)
for cidr in ("127.0.0.0/8", "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"):
assert any(f"-d {cidr} -j RETURN" in rule for rule in applied_rules)
for protocol in ("udp", "tcp"):
assert any(f"-p {protocol} --dport 53" in rule for rule in applied_rules)
@requires_bash
def test_tor_warns_when_owner_match_unsupported(tmp_path):
"""The degraded path has to be visible in the logs, not silent."""
result, _ = _run_tor_iptables_block(tmp_path, owner_match_supported=False)
assert "owner" in result.stdout.lower()
assert "[!]" in result.stdout
@requires_bash
def test_tor_exempts_own_traffic_when_owner_match_supported(tmp_path):
"""On a normal kernel the exemption must still be installed."""
result, applied_rules = _run_tor_iptables_block(tmp_path, owner_match_supported=True)
assert result.returncode == 0, result.stderr
owner_rules = [rule for rule in applied_rules if "--uid-owner" in rule]
assert len(owner_rules) == 1
assert "-j RETURN" in owner_rules[0]
owner_index = applied_rules.index(owner_rules[0])
redirect_index = next(
index for index, rule in enumerate(applied_rules) if "--syn -j REDIRECT" in rule
)
assert owner_index < redirect_index
@requires_bash
@requires_timeout
def test_tor_watchdog_resets_container_when_tor_dies(tmp_path):
"""A Tor that is no longer running must take the container down with it."""
reset_signal, stdout = _run_tor_watchdog(tmp_path, tor_state="STOPPED")
assert reset_signal == signal.SIGTERM, f"container was not reset:\n{stdout}"
@requires_bash
@requires_timeout
def test_tor_watchdog_resets_container_when_tor_stops_accepting_connections(tmp_path):
"""Tor can be 'running' yet unusable; the container must still be reset."""
reset_signal, stdout = _run_tor_watchdog(tmp_path, tor_state="RUNNING", trans_port_open=False)
assert reset_signal == signal.SIGTERM, f"container was not reset:\n{stdout}"
@requires_bash
@requires_timeout
def test_tor_watchdog_leaves_healthy_tor_alone(tmp_path):
"""A healthy Tor must never be reset, no matter how many cycles run."""
reset_signal, stdout = _run_tor_watchdog(tmp_path)
assert reset_signal is None, f"healthy Tor was reset:\n{stdout}"
@requires_bash
@requires_timeout
def test_tor_watchdog_retries_once_before_resetting(tmp_path):
"""A single transient failure is retried, not escalated to a reset."""
reset_signal, stdout = _run_tor_watchdog(tmp_path, fail_first=True)
assert reset_signal is None, f"transient failure caused a reset:\n{stdout}"
assert "Healthcheck failed (1/2)" in stdout
assert "Tor recovered." in stdout
@requires_bash
@requires_timeout
def test_tor_watchdog_waits_for_first_bootstrap_before_policing(tmp_path):
"""A slow first bootstrap must not be mistaken for a failure.
Otherwise every user on a slow link would be reset into a restart loop
before Tor ever had a chance to come up.
"""
reset_signal, stdout = _run_tor_watchdog(
tmp_path, tor_state="STOPPED", bootstrapped=False, trans_port_open=False
)
assert reset_signal is None, f"reset during initial bootstrap:\n{stdout}"
assert "Waiting for initial Tor bootstrap" in stdout
def test_tor_watchdog_escalates_to_container_reset_not_in_place_restart():
"""The watchdog must signal PID 1 rather than bounce Tor under supervisor."""
healthcheck_script = _generated_tor_healthcheck_script()
assert "kill -TERM" in healthcheck_script
assert "supervisorctl restart" not in healthcheck_script
+10
View File
@@ -29,6 +29,16 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import pytest
@pytest.fixture(autouse=True)
def _clear_torrent_fetch_cache():
"""Keep the shared torrent fetch cache from leaking between tests."""
from shelfmark.download.clients.torrent_utils import clear_torrent_fetch_cache
clear_torrent_fetch_cache()
yield
clear_torrent_fetch_cache()
@pytest.fixture
def sample_prowlarr_result():
"""Sample Prowlarr API search result."""
@@ -0,0 +1,280 @@
{
"_comment": "Frozen snapshot of the per-source language handling as it was before consolidation into shelfmark.core.languages. prowlarr_three_letter and audiobookbay_names were explicit maps; direct_download_derived is what its loader built from the data file, including the underscore spelling of a hyphenated code. Every alias here must still resolve to the same code. The one deliberate change: Traditional Chinese was canonically 'zh‑Hant' with a U+2011 non-breaking hyphen and is now the ASCII 'zh-Hant', so expectations for it name the new code while the old spelling remains a resolvable alias.",
"audiobookbay_names": {
"afrikaans": "af",
"arabic": "ar",
"bangla": "bn",
"bengali": "bn",
"bosnian": "bs",
"bulgarian": "bg",
"burmese": "my",
"catalan": "ca",
"chinese": "zh",
"croatian": "hr",
"czech": "cs",
"danish": "da",
"dutch": "nl",
"english": "en",
"estonian": "et",
"farsi": "fa",
"filipino": "fil",
"finnish": "fi",
"french": "fr",
"german": "de",
"greek": "el",
"gujarati": "gu",
"hebrew": "he",
"hindi": "hi",
"hungarian": "hu",
"icelandic": "is",
"indonesian": "id",
"irish": "ga",
"italian": "it",
"japanese": "ja",
"javanese": "jv",
"kannada": "kn",
"korean": "ko",
"latin": "la",
"latvian": "lv",
"lithuanian": "lt",
"malay": "ms",
"malayalam": "ml",
"manx": "gv",
"marathi": "mr",
"norwegian": "no",
"persian": "fa",
"polish": "pl",
"portuguese": "pt",
"punjabi": "pa",
"romanian": "ro",
"russian": "ru",
"sanskrit": "sa",
"scottish gaelic": "gd",
"serbian": "sr",
"slovenian": "sl",
"spanish": "es",
"swedish": "sv",
"tagalog": "fil",
"tamil": "ta",
"telugu": "te",
"thai": "th",
"turkish": "tr",
"ukrainian": "uk",
"urdu": "ur",
"vietnamese": "vi"
},
"direct_download_derived": {
"af": "af",
"afrikaans": "af",
"albanian": "sq",
"ar": "ar",
"arabic": "ar",
"armenian": "hy",
"az": "az",
"azerbaijani": "az",
"ba": "ba",
"bangla": "bn",
"bashkir": "ba",
"be": "be",
"belarusian": "be",
"bg": "bg",
"bn": "bn",
"bo": "bo",
"bulgarian": "bg",
"ca": "ca",
"catalan": "ca",
"chinese": "zh",
"croatian": "hr",
"cs": "cs",
"czech": "cs",
"da": "da",
"danish": "da",
"de": "de",
"dutch": "nl",
"el": "el",
"en": "en",
"english": "en",
"eo": "eo",
"es": "es",
"esperanto": "eo",
"fa": "fa",
"fi": "fi",
"fil": "fil",
"filipino": "fil",
"finnish": "fi",
"fr": "fr",
"french": "fr",
"ga": "ga",
"galician": "gl",
"georgian": "ka",
"german": "de",
"gl": "gl",
"greek": "el",
"gu": "gu",
"gujarati": "gu",
"he": "he",
"hebrew": "he",
"hi": "hi",
"hindi": "hi",
"hr": "hr",
"hu": "hu",
"hungarian": "hu",
"hy": "hy",
"id": "id",
"indonesian": "id",
"irish": "ga",
"it": "it",
"italian": "it",
"ja": "ja",
"japanese": "ja",
"javanese": "jv",
"jv": "jv",
"ka": "ka",
"kannada": "kn",
"kazakh": "kk",
"kinyarwanda": "rw",
"kk": "kk",
"kn": "kn",
"ko": "ko",
"korean": "ko",
"ky": "ky",
"kyrgyz": "ky",
"la": "la",
"latin": "la",
"latvian": "lv",
"lithuanian": "lt",
"lt": "lt",
"lv": "lv",
"malay": "ms",
"malayalam": "ml",
"marathi": "mr",
"ml": "ml",
"mn": "mn",
"mongolian": "mn",
"mr": "mr",
"ms": "ms",
"nl": "nl",
"no": "no",
"norwegian": "no",
"pa": "pa",
"persian": "fa",
"pl": "pl",
"polish": "pl",
"portuguese": "pt",
"pt": "pt",
"punjabi": "pa",
"qu": "qu",
"quechua": "qu",
"ro": "ro",
"romanian": "ro",
"ru": "ru",
"russian": "ru",
"rw": "rw",
"serbian": "sr",
"shan": "shn",
"shn": "shn",
"sk": "sk",
"sl": "sl",
"slovak": "sk",
"slovenian": "sl",
"spanish": "es",
"sq": "sq",
"sr": "sr",
"sv": "sv",
"sw": "sw",
"swahili": "sw",
"swedish": "sv",
"ta": "ta",
"tamil": "ta",
"te": "te",
"telugu": "te",
"th": "th",
"thai": "th",
"tibetan": "bo",
"tr": "tr",
"traditional chinese": "zh-Hant",
"turkish": "tr",
"ug": "ug",
"uk": "uk",
"ukrainian": "uk",
"ur": "ur",
"urdu": "ur",
"uyghur": "ug",
"vi": "vi",
"vietnamese": "vi",
"zh": "zh",
"zh‑hant": "zh-Hant"
},
"prowlarr_three_letter": {
"afr": "af",
"ara": "ar",
"ben": "bn",
"bos": "bs",
"bul": "bg",
"bur": "my",
"cat": "ca",
"ces": "cs",
"chi": "zh",
"cze": "cs",
"dan": "da",
"deu": "de",
"dut": "nl",
"ell": "el",
"eng": "en",
"est": "et",
"fas": "fa",
"fin": "fi",
"fra": "fr",
"fre": "fr",
"ger": "de",
"gla": "gd",
"gle": "ga",
"glv": "gv",
"gre": "el",
"guj": "gu",
"heb": "he",
"hin": "hi",
"hrv": "hr",
"hun": "hu",
"ice": "is",
"ind": "id",
"isl": "is",
"ita": "it",
"jap": "ja",
"jav": "jv",
"jpn": "ja",
"kan": "kn",
"kor": "ko",
"lat": "la",
"lav": "lv",
"lit": "lt",
"mal": "ml",
"mar": "mr",
"may": "ms",
"msa": "ms",
"mya": "my",
"nld": "nl",
"nor": "no",
"pan": "pa",
"per": "fa",
"pol": "pl",
"por": "pt",
"rom": "ro",
"ron": "ro",
"rus": "ru",
"san": "sa",
"slv": "sl",
"spa": "es",
"srp": "sr",
"swe": "sv",
"tam": "ta",
"tel": "te",
"tgl": "fil",
"tha": "th",
"tur": "tr",
"ukr": "uk",
"urd": "ur",
"vie": "vi",
"zho": "zh"
}
}
+43
View File
@@ -131,3 +131,46 @@ def test_frontend_dist_resolves_from_repo_root(main_module):
assert main_module.PROJECT_ROOT == expected_project_root
assert main_module.FRONTEND_DIST == expected_project_root / "frontend-dist"
def test_config_endpoint_serves_languages_without_resolution_aliases(main_module, client):
"""book_languages is a client contract, not a dump of the language data file.
data/book-languages.json also carries the aliases used to resolve a source's
spelling of a language to a code. Those are server-side only: the frontend
Language type is {code, language}, and shipping the aliases inflated every
config response by around 40%.
"""
_set_session(client, user_id="reader-1", db_user_id=1, is_admin=False)
with (
patch("shelfmark.config.env._is_config_dir_writable", return_value=True),
patch("shelfmark.core.onboarding.is_onboarding_complete", return_value=True),
):
resp = client.get("/api/config")
assert resp.status_code == 200
languages = resp.get_json()["book_languages"]
assert languages, "no languages served"
offending = [entry for entry in languages if set(entry) != {"code", "language"}]
assert offending == [], f"unexpected keys leaked to clients: {offending[:3]}"
def test_language_data_file_is_only_read_by_the_shared_module(main_module):
"""Reading data/book-languages.json anywhere else reintroduces the drift the
shared module exists to prevent, and bypasses the alias handling."""
del main_module
repo_root = Path(__file__).resolve().parents[2]
allowed = {Path("shelfmark/core/languages.py")}
offenders = []
for path in (repo_root / "shelfmark").rglob("*.py"):
relative = path.relative_to(repo_root)
if relative in allowed:
continue
if "book-languages" in path.read_text(encoding="utf-8"):
offenders.append(str(relative))
assert offenders == [], f"should use shelfmark.core.languages instead: {offenders}"
@@ -0,0 +1,196 @@
"""Tests for the {Language} template variable.
Different-language editions of one book resolve to the same title, so without a
language token they render to the same path and land in one folder. Audiobookshelf
treats a folder as exactly one library item, so the two editions become a single
book with both files as tracks (calibrain/shelfmark#1138).
"""
import pytest
from shelfmark.core.models import DownloadTask
from shelfmark.core.naming import (
KNOWN_TOKENS,
normalize_language_code,
parse_naming_template,
)
from shelfmark.download.orchestrator import (
_restore_task_from_retry_payload,
serialize_task_for_retry,
)
from shelfmark.download.postprocess.transfer import build_metadata_dict
class TestLanguageInKnownTokens:
def test_language_in_known_tokens(self):
assert "language" in KNOWN_TOKENS
def test_language_token_parsed(self):
assert parse_naming_template("{Language}", {"Language": "sv"}) == "sv"
def test_language_token_case_insensitive(self):
assert parse_naming_template("{language}", {"Language": "sv"}) == "sv"
class TestKnownTokensOrdering:
"""find_placeholder() does a substring find over KNOWN_TOKENS in list order.
Nothing else guards this contract, so a future token added in the wrong
position would silently shadow an existing one.
"""
def test_tokens_are_ordered_longest_first(self):
lengths = [len(token) for token in KNOWN_TOKENS]
assert lengths == sorted(lengths, reverse=True)
def test_no_token_is_shadowed_by_an_earlier_substring(self):
for shorter_index, shorter in enumerate(KNOWN_TOKENS):
for longer_index, longer in enumerate(KNOWN_TOKENS):
if shorter is longer or shorter not in longer:
continue
assert shorter_index > longer_index, (
f"{shorter!r} precedes {longer!r} and would shadow it"
)
class TestLanguageTemplateSubstitution:
"""The acceptance cases from the issue."""
TEMPLATE = "{Author}/{Title}{ (Language)}/{Author} - {Title}"
BASE = {"Author": "Andy Weir", "Title": "Project Hail Mary"}
def test_translated_edition_gets_its_own_folder(self):
result = parse_naming_template(
self.TEMPLATE, {**self.BASE, "Language": "sv"}, allow_path_separators=True
)
assert result == "Andy Weir/Project Hail Mary (sv)/Andy Weir - Project Hail Mary"
def test_untagged_edition_is_unchanged(self):
result = parse_naming_template(
self.TEMPLATE, {**self.BASE, "Language": None}, allow_path_separators=True
)
assert result == "Andy Weir/Project Hail Mary/Andy Weir - Project Hail Mary"
def test_the_two_editions_do_not_collide(self):
english = parse_naming_template(
self.TEMPLATE, {**self.BASE, "Language": None}, allow_path_separators=True
)
swedish = parse_naming_template(
self.TEMPLATE, {**self.BASE, "Language": "sv"}, allow_path_separators=True
)
assert english != swedish
def test_language_as_a_leading_folder(self):
result = parse_naming_template(
"{Language/}{Author}/{Title}",
{**self.BASE, "Language": "sv"},
allow_path_separators=True,
)
assert result == "sv/Andy Weir/Project Hail Mary"
def test_language_in_a_filename_template(self):
result = parse_naming_template(
"{Author} - {Title}{ (Language)}", {**self.BASE, "Language": "sv"}
)
assert result == "Andy Weir - Project Hail Mary (sv)"
def test_language_is_sanitized(self):
result = parse_naming_template("{Title}{ (Language)}", {"Title": "Book", "Language": "s/v"})
assert "/" not in result
class TestNormalizeLanguageCode:
def test_lowercases(self):
assert normalize_language_code("EN") == "en"
assert normalize_language_code("Sv") == "sv"
def test_strips_whitespace(self):
assert normalize_language_code(" sv ") == "sv"
def test_placeholders_render_empty(self):
for placeholder in ("unknown", "unk", "n/a", "na", "-", "--", "none", "null", ""):
assert normalize_language_code(placeholder) == "", placeholder
def test_placeholders_are_matched_case_insensitively(self):
assert normalize_language_code("Unknown") == ""
def test_none_renders_empty(self):
assert normalize_language_code(None) == ""
class TestBuildMetadataWithLanguage:
def test_language_reaches_the_template_metadata(self):
task = DownloadTask(task_id="t", source="prowlarr", title="Book", language="sv")
assert build_metadata_dict(task)["Language"] == "sv"
def test_language_is_normalized_on_the_way_out(self):
task = DownloadTask(task_id="t", source="prowlarr", title="Book", language="SV")
assert build_metadata_dict(task)["Language"] == "sv"
def test_placeholder_language_does_not_reach_the_path(self):
# Anna's Archive reports the literal string "unknown" when it cannot tell.
task = DownloadTask(task_id="t", source="direct", title="Book", language="unknown")
assert build_metadata_dict(task)["Language"] == ""
def test_missing_language_renders_empty(self):
task = DownloadTask(task_id="t", source="prowlarr", title="Book")
assert build_metadata_dict(task)["Language"] == ""
class TestLanguageSurvivesRetry:
"""DownloadTask is not rebuilt from dataclasses.fields(), so each of the
three orchestrator sites has to carry the field explicitly."""
def test_roundtrip_preserves_language(self):
task = DownloadTask(task_id="t", source="prowlarr", title="Book", language="sv")
restored = _restore_task_from_retry_payload(serialize_task_for_retry(task))
assert restored is not None
assert restored.language == "sv"
def test_legacy_payload_without_language_restores_cleanly(self):
task = DownloadTask(task_id="t", source="prowlarr", title="Book", language="sv")
payload = serialize_task_for_retry(task)
del payload["language"]
restored = _restore_task_from_retry_payload(payload)
assert restored is not None
assert restored.language is None
class TestEverySpellingCollapsesToOneFolder:
"""Sources report the same language differently; if the token rendered each
spelling verbatim they would land in separate folders, which is the exact
collision this token exists to prevent (reported on PR #1142)."""
TEMPLATE = "{Author}/{Title}{ (Language)}/{Title}"
def _folder(self, language):
task = DownloadTask(
task_id="t", source="prowlarr", title="Dune", author="Frank Herbert", language=language
)
return parse_naming_template(
self.TEMPLATE, build_metadata_dict(task), allow_path_separators=True
)
@pytest.mark.parametrize(
"spellings",
[
("en", "eng", "English", "english", "ENG", " Eng "),
("sv", "swe", "Swedish"),
("de", "ger", "deu", "German"),
("ml", "mal", "Malayalam"),
("fa", "per", "fas", "Farsi", "Persian"),
],
)
def test_all_spellings_of_a_language_share_one_folder(self, spellings):
rendered = {self._folder(spelling) for spelling in spellings}
assert len(rendered) == 1, f"{spellings} produced {sorted(rendered)}"
def test_a_language_we_cannot_resolve_is_kept_rather_than_dropped(self):
# It still separates editions, and cannot collide with a resolved code
# precisely because nothing resolves to it.
assert "klingon" in self._folder("Klingon")
def test_different_languages_still_get_different_folders(self):
assert self._folder("English") != self._folder("Swedish")
+256
View File
@@ -0,0 +1,256 @@
"""Tests for the shared language resolution used by every release source."""
import json
from pathlib import Path
import pytest
from shelfmark.core.languages import (
LANGUAGE_DATA_PATH,
known_language_codes,
language_alias_map,
language_name,
normalize_language,
supported_book_languages,
)
BASELINE = json.loads(
(Path(__file__).parent / "fixtures" / "language_alias_baseline.json").read_text(
encoding="utf-8"
)
)
class TestBaselineEquivalence:
"""Every alias the per-source maps used to handle must still resolve the same.
These maps lived in prowlarr/source.py and audiobookbay/source.py before they
were consolidated here. The fixture is a frozen snapshot taken before the
move, so a regression shows up as a concrete alias rather than a vague
behaviour change.
"""
@pytest.mark.parametrize(
("alias", "expected"), sorted(BASELINE["prowlarr_three_letter"].items())
)
def test_prowlarr_three_letter_aliases_unchanged(self, alias, expected):
assert normalize_language(alias) == expected
@pytest.mark.parametrize(("alias", "expected"), sorted(BASELINE["audiobookbay_names"].items()))
def test_audiobookbay_names_unchanged(self, alias, expected):
assert normalize_language(alias) == expected
@pytest.mark.parametrize(
("alias", "expected"), sorted(BASELINE["direct_download_derived"].items())
)
def test_direct_download_derived_aliases_unchanged(self, alias, expected):
# Direct Download built its aliases from the data file rather than a
# literal map, so consolidating silently dropped the spellings it
# derived -- notably the underscore form of a hyphenated code.
assert normalize_language(alias) == expected
class TestNormalizeLanguage:
def test_accepts_two_letter_codes(self):
assert normalize_language("en") == "en"
assert normalize_language("sv") == "sv"
def test_accepts_three_letter_codes_in_both_iso_639_2_forms(self):
# Bibliographic and terminological forms differ for these.
assert normalize_language("ger") == normalize_language("deu") == "de"
assert normalize_language("fre") == normalize_language("fra") == "fr"
assert normalize_language("per") == normalize_language("fas") == "fa"
assert normalize_language("ice") == normalize_language("isl") == "is"
assert normalize_language("may") == normalize_language("msa") == "ms"
def test_accepts_english_names(self):
assert normalize_language("Swedish") == "sv"
assert normalize_language("Scottish Gaelic") == "gd"
def test_is_case_and_whitespace_insensitive(self):
assert normalize_language(" ENG ") == "en"
assert normalize_language("sWeDiSh") == "sv"
def test_returns_none_for_placeholders(self):
for placeholder in ("unknown", "unk", "n/a", "na", "-", "--", "none", "null", "", " "):
assert normalize_language(placeholder) is None, placeholder
def test_returns_none_for_unknown_values(self):
assert normalize_language("xyz") is None
assert normalize_language("Klingon") is None
def test_returns_none_for_none(self):
assert normalize_language(None) is None
class TestLanguageData:
def test_every_alias_resolves_to_a_known_code(self):
codes = known_language_codes()
assert set(language_alias_map().values()) <= codes
def test_codes_are_ascii(self):
# "zh-Hant" once used a U+2011 non-breaking hyphen, which silently
# defeats any comparison against the normal spelling.
entries = json.loads(LANGUAGE_DATA_PATH.read_text(encoding="utf-8"))
assert [e["code"] for e in entries if not e["code"].isascii()] == []
def test_codes_are_unique(self):
entries = json.loads(LANGUAGE_DATA_PATH.read_text(encoding="utf-8"))
codes = [e["code"] for e in entries]
assert len(codes) == len(set(codes))
def test_language_name_round_trips(self):
assert language_name("sv") == "Swedish"
assert language_name("ml") == "Malayalam"
assert language_name("zzz") is None
assert language_name(None) is None
class TestMyAnonamouseCoverage:
"""MyAnonamouse offers 62 languages and Prowlarr passes its code through
untransformed, so every one has to resolve here or the language is lost."""
# Observed in live MyAnonamouse data via Prowlarr.
OBSERVED = {"ENG": "en", "SWE": "sv", "MAL": "ml"}
@pytest.mark.parametrize(("tag", "expected"), sorted(OBSERVED.items()))
def test_observed_tags_resolve(self, tag, expected):
assert normalize_language(tag) == expected
def test_every_offered_language_resolves(self):
# Names as MyAnonamouse's own searchLanguages selector lists them.
offered = [
"English",
"Afrikaans",
"Arabic",
"Bengali",
"Bosnian",
"Bulgarian",
"Burmese",
"Catalan",
"Chinese",
"Croatian",
"Czech",
"Danish",
"Dutch",
"Estonian",
"Farsi",
"Finnish",
"French",
"German",
"Greek",
"Gujarati",
"Hebrew",
"Hindi",
"Hungarian",
"Icelandic",
"Indonesian",
"Irish",
"Italian",
"Japanese",
"Javanese",
"Kannada",
"Korean",
"Lithuanian",
"Latin",
"Latvian",
"Malay",
"Malayalam",
"Manx",
"Marathi",
"Norwegian",
"Polish",
"Portuguese",
"Punjabi",
"Romanian",
"Russian",
"Scottish Gaelic",
"Sanskrit",
"Serbian",
"Slovenian",
"Spanish",
"Swedish",
"Tagalog",
"Tamil",
"Telugu",
"Thai",
"Turkish",
"Ukrainian",
"Urdu",
"Vietnamese",
]
unresolved = [name for name in offered if normalize_language(name) is None]
assert unresolved == []
class TestSupportedBookLanguages:
"""What the settings dropdown and /api/config expose to clients."""
def test_exposes_only_the_fields_clients_declare(self):
# The frontend Language type is {code, language}. Aliases are an
# implementation detail and would bloat every /api/config response.
entries = supported_book_languages()
assert entries
assert all(set(e) == {"code", "language"} for e in entries)
def test_covers_every_known_code(self):
assert {e["code"] for e in supported_book_languages()} == set(known_language_codes())
class TestLegacyTraditionalChineseCode:
"""Traditional Chinese was stored with a U+2011 non-breaking hyphen.
The canonical code is now the ASCII spelling, but anything persisted
earlier still carries U+2011, so both have to resolve to the same language
or those users lose their selection (reported on PR #1142).
"""
LEGACY = "zh\u2011Hant"
CANONICAL = "zh-Hant"
def test_the_legacy_spelling_still_resolves(self):
assert normalize_language(self.LEGACY) == self.CANONICAL
def test_both_spellings_are_the_same_language(self):
assert normalize_language(self.LEGACY) == normalize_language(self.CANONICAL)
def test_the_legacy_spelling_really_does_use_a_different_character(self):
# Guards the test itself: if this ever became a plain hyphen the two
# cases above would pass for the wrong reason.
assert self.LEGACY != self.CANONICAL
assert not self.LEGACY.isascii()
@pytest.mark.parametrize("dash", ["-", "\u2010", "\u2011", "\u2012", "\u2013", "\u2014"])
def test_any_dash_variant_resolves(self, dash):
assert normalize_language(f"zh{dash}Hant") == self.CANONICAL
class TestCodesDoNotShadowEachOther:
"""A code must never resolve to a different language than itself.
'zh' and 'zh-Hant' are distinct entries; registering the base of a
hyphenated code as an alias made 'zh' resolve correctly only because
Chinese happens to appear first in the data file.
"""
def test_chinese_does_not_resolve_to_traditional_chinese(self):
assert normalize_language("zh") == "zh"
assert normalize_language("zh-Hant") == "zh-Hant"
def test_every_code_resolves_to_itself(self):
for code in known_language_codes():
assert normalize_language(code) == code, f"{code} resolved elsewhere"
class TestSubtagSeparators:
"""The U+2011 in the old Traditional Chinese code renders close enough to
both a hyphen and an underscore that either is a plausible thing to type."""
@pytest.mark.parametrize("separator", ["-", "_", "‐", "‑", "–", "—"])
def test_any_separator_spelling_resolves(self, separator):
assert normalize_language(f"zh{separator}Hant") == "zh-Hant"
def test_separators_do_not_merge_unrelated_codes(self):
# Folding a separator must not make one language answer to another.
assert normalize_language("zh") == "zh"
assert normalize_language("en_GB") is None
+37
View File
@@ -413,6 +413,43 @@ class TestOIDCCallbackEndpoint:
assert error is not None
assert "issuer validation failed" in error
@patch("shelfmark.core.oidc_routes._get_oidc_client")
def test_callback_redirects_with_signing_key_guidance_on_empty_jwks(
self, mock_get_client, client
):
fake_client = Mock()
fake_client.authorize_access_token.side_effect = KeyError("keys")
fake_client.fetch_jwk_set.return_value = {}
mock_get_client.return_value = (fake_client, MOCK_OIDC_CONFIG)
resp = client.get("/api/auth/oidc/callback?code=abc123&state=test-state")
error = _get_oidc_error(resp)
assert error is not None
assert "no token signing keys" in error
assert "Signing Key" in error
@patch("shelfmark.core.oidc_routes._get_oidc_client")
def test_callback_uses_generic_error_when_jwks_has_keys(self, mock_get_client, client):
fake_client = Mock()
fake_client.authorize_access_token.side_effect = KeyError("keys")
fake_client.fetch_jwk_set.return_value = {"keys": [{"kty": "RSA", "kid": "abc"}]}
mock_get_client.return_value = (fake_client, MOCK_OIDC_CONFIG)
resp = client.get("/api/auth/oidc/callback?code=abc123&state=test-state")
error = _get_oidc_error(resp)
assert error == "Authentication failed"
@patch("shelfmark.core.oidc_routes._get_oidc_client")
def test_callback_uses_generic_error_when_jwks_diagnosis_fails(self, mock_get_client, client):
fake_client = Mock()
fake_client.authorize_access_token.side_effect = KeyError("keys")
fake_client.fetch_jwk_set.side_effect = RuntimeError("jwks fetch failed")
mock_get_client.return_value = (fake_client, MOCK_OIDC_CONFIG)
resp = client.get("/api/auth/oidc/callback?code=abc123&state=test-state")
error = _get_oidc_error(resp)
assert error == "Authentication failed"
@patch("shelfmark.core.oidc_routes._get_oidc_client")
def test_callback_redirects_when_auto_provision_disabled_and_no_email_match(
self, mock_get_client, client
+23 -3
View File
@@ -43,10 +43,10 @@ class TestNaturalSortKey:
"file10.mp3",
]
def test_uses_filename_only(self):
def test_uses_full_path(self):
files = [Path("/z/dir/file1.mp3"), Path("/a/dir/file2.mp3")]
sorted_files = sorted(files, key=natural_sort_key)
assert sorted_files[0].name == "file1.mp3"
assert sorted_files == [Path("/a/dir/file2.mp3"), Path("/z/dir/file1.mp3")]
class TestAssignPartNumbers:
@@ -72,6 +72,23 @@ class TestAssignPartNumbers:
(Path("Chapter 10.mp3"), "03"),
]
def test_nested_folders_are_numbered_in_folder_order(self):
files = [
Path("06_Side 6/002.mp3"),
Path("01_Side 1/001.mp3"),
Path("00_Introduction/001_About.mp3"),
Path("06_Side 6/001.mp3"),
Path("01_Side 1/002.mp3"),
]
assert assign_part_numbers(files) == [
(Path("00_Introduction/001_About.mp3"), "01"),
(Path("01_Side 1/001.mp3"), "02"),
(Path("01_Side 1/002.mp3"), "03"),
(Path("06_Side 6/001.mp3"), "04"),
(Path("06_Side 6/002.mp3"), "05"),
]
def test_custom_zero_padding(self):
files = [Path("a.mp3"), Path("b.mp3")]
assert assign_part_numbers(files, zero_pad_width=3) == [
@@ -190,7 +207,10 @@ class TestEdgeCases:
def test_identical_filenames_different_dirs(self):
files = [Path("/dir2/track.mp3"), Path("/dir1/track.mp3")]
result = assign_part_numbers(files)
assert len(result) == 2
assert result == [
(Path("/dir1/track.mp3"), "01"),
(Path("/dir2/track.mp3"), "02"),
]
def test_unicode_filenames(self):
files = [Path("日本語タイトル 02.mp3"), Path("日本語タイトル 01.mp3")]
@@ -205,7 +205,7 @@ def test_download_task_rejects_unavailable_source_before_handler(monkeypatch):
orchestrator.get_handler.assert_not_called()
def test_queue_release_persists_generic_retry_resolution_fields(monkeypatch):
def test_queue_release_persists_prowlarr_retry_context_without_download_url(monkeypatch):
import shelfmark.download.orchestrator as orchestrator
captured: dict[str, object] = {}
@@ -227,6 +227,7 @@ def test_queue_release_persists_generic_retry_resolution_fields(monkeypatch):
"protocol": "torrent",
"indexer": "MyIndexer",
"extra": {
"indexer_id": 12,
"configured_ratio_limit": 1.25,
"configured_seed_time_minutes": 90,
"info_hash": "ABC123",
@@ -239,14 +240,23 @@ def test_queue_release_persists_generic_retry_resolution_fields(monkeypatch):
assert success is True
assert error is None
task = captured["task"]
assert task.retry_download_url == "magnet:?xt=urn:btih:abc123"
assert task.retry_download_protocol == "torrent"
assert task.retry_download_url is None
assert task.retry_download_protocol is None
assert task.retry_source_context == {
"source_id": "prowlarr-release-1",
"indexer": "MyIndexer",
"indexer_id": 12,
}
assert task.retry_release_name == "Queued Prowlarr Release"
assert task.retry_expected_hash == "ABC123"
assert task.retry_ratio_limit == 1.25
assert task.retry_seeding_time_limit_minutes == 90
assert task.can_retry_without_staged_source is True
payload = orchestrator.serialize_task_for_retry(task)
assert payload["retry_download_url"] is None
assert payload["retry_source_context"] == task.retry_source_context
def test_queue_release_prefers_configured_seed_time_minutes_for_retry(monkeypatch):
import shelfmark.download.orchestrator as orchestrator
@@ -377,3 +387,58 @@ def test_queue_release_returns_error_for_operational_queue_failure(monkeypatch):
assert success is False
assert error == "Error queueing release: queue offline"
def _queue_and_capture(monkeypatch, release_data):
import shelfmark.download.orchestrator as orchestrator
captured: dict[str, object] = {}
monkeypatch.setattr(orchestrator.config, "get", lambda key, default=None, user_id=None: default)
monkeypatch.setattr(
orchestrator.book_queue, "add", lambda task: captured.setdefault("task", task) or True
)
monkeypatch.setattr(orchestrator, "ws_manager", None)
success, error = orchestrator.queue_release(release_data, user_id=1, username="alice")
assert success is True, error
return captured["task"]
def test_queue_release_carries_top_level_language(monkeypatch):
task = _queue_and_capture(
monkeypatch,
{
"source": "prowlarr",
"source_id": "release-sv",
"title": "Project Hail Mary",
"language": "sv",
},
)
assert task.language == "sv"
def test_queue_release_falls_back_to_language_in_extra(monkeypatch):
# direct_download sets language inside extra as well as top level.
task = _queue_and_capture(
monkeypatch,
{
"source": "direct_download",
"source_id": "release-de",
"title": "Project Hail Mary",
"extra": {"language": "de"},
},
)
assert task.language == "de"
def test_queue_release_without_language_leaves_it_unset(monkeypatch):
task = _queue_and_capture(
monkeypatch,
{"source": "prowlarr", "source_id": "release-none", "title": "Project Hail Mary"},
)
assert task.language is None
+3 -3
View File
@@ -24,7 +24,7 @@ def test_wait_for_dcc_ignores_unexpected_sender(monkeypatch) -> None:
event=IRCEvent.BOOK_RESULT,
),
]
monkeypatch.setattr(client, "read_messages", lambda: iter(messages))
monkeypatch.setattr(client, "read_messages", lambda **_: iter(messages))
offer = client.wait_for_dcc(timeout=1.0, result_type=False)
@@ -43,7 +43,7 @@ def test_wait_for_dcc_uses_expected_sender_over_online_server_list(monkeypatch)
event=IRCEvent.BOOK_RESULT,
)
]
monkeypatch.setattr(client, "read_messages", lambda: iter(messages))
monkeypatch.setattr(client, "read_messages", lambda **_: iter(messages))
offer = client.wait_for_dcc(
timeout=1.0,
@@ -76,7 +76,7 @@ def test_wait_for_dcc_ignores_unsafe_offer_and_keeps_waiting(monkeypatch) -> Non
event=IRCEvent.BOOK_RESULT,
),
]
monkeypatch.setattr(client, "read_messages", lambda: iter(messages))
monkeypatch.setattr(client, "read_messages", lambda **_: iter(messages))
offer = client.wait_for_dcc(timeout=1.0, result_type=False)
+62
View File
@@ -0,0 +1,62 @@
"""A silent channel must still time out.
An empty or misnamed channel sends nothing at all, so a timeout that is only
checked when a message arrives never fires and the search hangs forever.
"""
import time
import pytest
from shelfmark.release_sources.irc.client import IRCClient
class _SilentSocket:
"""A socket that never delivers a line, only recv timeouts."""
def __init__(self) -> None:
self.timeout: float | None = 300.0
self.recv_calls = 0
def gettimeout(self) -> float | None:
return self.timeout
def settimeout(self, value: float | None) -> None:
self.timeout = value
def recv(self, _bufsize: int) -> bytes:
self.recv_calls += 1
# A real socket blocks for the full timeout before raising; sleep a
# sliver of it so the test stays fast but the deadline still advances.
time.sleep(min(self.timeout or 0.0, 0.01))
raise TimeoutError
def test_wait_for_dcc_times_out_on_silent_channel() -> None:
client = IRCClient(nick="reader", server="irc.example.test", port=6697)
sock = _SilentSocket()
client._socket = sock
client._connected = True
start = time.time()
offer = client.wait_for_dcc(timeout=0.5, result_type=True)
elapsed = time.time() - start
assert offer is None
assert elapsed == pytest.approx(0.5, abs=0.5)
assert sock.recv_calls > 0
# The long connection-wide timeout is put back for the next caller
assert sock.timeout == 300.0
def test_recv_lines_caps_socket_timeout_at_the_deadline() -> None:
client = IRCClient(nick="reader", server="irc.example.test", port=6697)
sock = _SilentSocket()
client._socket = sock
client._connected = True
lines = list(client._recv_lines(deadline=time.time() + 0.05))
assert lines == []
# Never waits past the deadline on a single recv
assert sock.timeout == 300.0
+139
View File
@@ -161,6 +161,145 @@ def test_search_no_dcc_offer_releases_connection_and_caches_empty_result(monkeyp
]
def test_audiobook_search_routes_to_configured_audiobook_channel_and_bot(monkeypatch):
"""An audiobook request uses the audiobook channel/bot when configured."""
import shelfmark.release_sources.irc.source as irc_source
source = IRCReleaseSource()
connection_kwargs: dict[str, object] = {}
cache_calls: list[dict[str, object]] = []
class FakeClient:
online_servers: set[str] = set()
def send_message(self, channel: str, message: str) -> None:
self.channel = channel
self.message = message
def wait_for_dcc(
self, *, timeout: float, result_type: bool, expected_senders: object = None
) -> None:
return None
client = FakeClient()
monkeypatch.setattr(
irc_source,
"_config_text",
lambda key: {
"IRC_SERVER": "irc.example.net",
"IRC_CHANNEL": "ebooks",
"IRC_NICK": "tester",
"IRC_SEARCH_BOT": "search",
"IRC_AUDIOBOOK_CHANNEL": "bookz",
"IRC_AUDIOBOOK_SEARCH_BOT": "audiosearch",
}.get(key, ""),
)
irc_source._recent_message_sends.clear()
monkeypatch.setattr(source, "is_available", lambda: True)
monkeypatch.setattr(irc_source, "_enforce_rate_limit", lambda: None)
monkeypatch.setattr(irc_source, "_emit_status", lambda *_args, **_kwargs: None)
monkeypatch.setattr(
"shelfmark.release_sources.irc.cache.get_cached_results",
lambda cache_key, *_args, **_kwargs: None,
)
monkeypatch.setattr(
"shelfmark.release_sources.irc.cache.cache_results",
lambda cache_key, title, releases, *, online_servers=None: cache_calls.append(
{"cache_key": cache_key}
),
)
def fake_get_connection(**kwargs: object) -> FakeClient:
connection_kwargs.update(kwargs)
return client
monkeypatch.setattr(
"shelfmark.release_sources.irc.connection_manager.connection_manager.get_connection",
fake_get_connection,
)
monkeypatch.setattr(
"shelfmark.release_sources.irc.connection_manager.connection_manager.release_connection",
lambda _client: None,
)
book = BookMetadata(provider="hardcover", provider_id="ab", title="Audio Book")
plan = SimpleNamespace(primary_query="Audio Book")
source.search(book, plan, content_type="audiobook")
# Joined the audiobook channel, addressed the audiobook search bot, and the cache
# key is namespaced to the audiobook channel (so it won't collide with the ebook one).
assert connection_kwargs["channel"] == "bookz"
assert client.channel == "#bookz"
assert client.message == "@audiosearch Audio Book"
assert cache_calls == [{"cache_key": "irc.example.net:bookz:audio book"}]
def test_audiobook_search_reuses_main_bot_when_only_channel_configured(monkeypatch):
"""With an audiobook channel but no audiobook bot, reuse the main search bot."""
import shelfmark.release_sources.irc.source as irc_source
source = IRCReleaseSource()
class FakeClient:
online_servers: set[str] = set()
def send_message(self, channel: str, message: str) -> None:
self.channel = channel
self.message = message
def wait_for_dcc(
self, *, timeout: float, result_type: bool, expected_senders: object = None
) -> None:
return None
client = FakeClient()
monkeypatch.setattr(
irc_source,
"_config_text",
lambda key: {
"IRC_SERVER": "irc.example.net",
"IRC_CHANNEL": "ebooks",
"IRC_NICK": "tester",
"IRC_SEARCH_BOT": "search",
"IRC_AUDIOBOOK_CHANNEL": "bookz",
"IRC_AUDIOBOOK_SEARCH_BOT": "",
}.get(key, ""),
)
irc_source._recent_message_sends.clear()
monkeypatch.setattr(source, "is_available", lambda: True)
monkeypatch.setattr(irc_source, "_enforce_rate_limit", lambda: None)
monkeypatch.setattr(irc_source, "_emit_status", lambda *_args, **_kwargs: None)
monkeypatch.setattr(
"shelfmark.release_sources.irc.cache.get_cached_results",
lambda cache_key, *_args, **_kwargs: None,
)
monkeypatch.setattr(
"shelfmark.release_sources.irc.cache.cache_results",
lambda *_args, **_kwargs: None,
)
monkeypatch.setattr(
"shelfmark.release_sources.irc.connection_manager.connection_manager.get_connection",
lambda **_kwargs: client,
)
monkeypatch.setattr(
"shelfmark.release_sources.irc.connection_manager.connection_manager.release_connection",
lambda _client: None,
)
book = BookMetadata(provider="hardcover", provider_id="ab2", title="Audio Book")
plan = SimpleNamespace(primary_query="Audio Book")
source.search(book, plan, content_type="audiobook")
assert client.channel == "#bookz"
assert client.message == "@search Audio Book"
def test_search_without_search_bot_never_posts_to_channel(monkeypatch):
"""A bare (unaddressed) query must never reach the channel; refuse to connect."""
import shelfmark.release_sources.irc.source as irc_source
+11
View File
@@ -15,6 +15,17 @@ def make_config_getter(values):
return getter
def test_set_category_uses_label_plugin(monkeypatch):
from shelfmark.download.clients.deluge import DelugeClient
client = DelugeClient.__new__(DelugeClient)
monkeypatch.setattr(client, "_ensure_connected", MagicMock())
monkeypatch.setattr(client, "_try_set_label", MagicMock(return_value=True))
assert client.set_category("abc123", "imported") is True
client._try_set_label.assert_called_once_with("abc123", "imported")
class TestDelugeClientAddDownload:
"""Tests for DelugeClient.add_download()."""
+1 -1
View File
@@ -520,7 +520,7 @@ class TestCacheFailures:
assert result is None
assert recorder.had_error
assert "not found in cache" in recorder.last_message.lower()
assert "could not be refreshed" in recorder.last_message
def test_release_missing_download_url(self, handler, recorder, cancel_flag, sample_task):
"""Handler should error when release has no download URL."""
+479 -13
View File
@@ -15,8 +15,10 @@ from shelfmark.download.clients import (
DownloadState,
DownloadStatus,
)
from shelfmark.release_sources import Release, ReleaseProtocol
from shelfmark.release_sources.prowlarr.cache import cache_release, remove_release
from shelfmark.release_sources.prowlarr.handler import ProwlarrHandler
from shelfmark.release_sources.prowlarr.utils import get_protocol
from shelfmark.release_sources.prowlarr.utils import build_source_id, get_protocol
class ProgressRecorder:
@@ -103,13 +105,19 @@ class TestProwlarrHandlerDownloadErrors:
assert result is None
assert recorder.last_status == "error"
assert recorder.last_message is not None
assert "cache" in recorder.last_message.lower()
assert "could not be refreshed" in recorder.last_message
def test_resolve_download_uses_task_retry_fields_when_cache_is_missing(self):
"""Generic retry fields should let restarts recover without the in-memory cache."""
with patch(
"shelfmark.release_sources.prowlarr.handler.get_release",
return_value=None,
def test_download_fails_clearly_when_cache_miss_cannot_refresh(self):
"""Prowlarr retry URLs are not durable; cache misses must refresh by identity."""
with (
patch(
"shelfmark.release_sources.prowlarr.handler.get_release",
return_value=None,
),
patch(
"shelfmark.release_sources.prowlarr.handler.ProwlarrSource.search",
return_value=[],
),
):
handler = ProwlarrHandler()
task = DownloadTask(
@@ -122,14 +130,245 @@ class TestProwlarrHandlerDownloadErrors:
retry_seeding_time_limit_minutes=60,
retry_ratio_limit=1.5,
)
recorder = ProgressRecorder()
request = handler._resolve_download(task, lambda *_: None)
request = handler._resolve_download(task, recorder.status_callback)
assert request is not None
assert request.url == "magnet:?xt=urn:btih:abc123"
assert request.protocol == "torrent"
assert request.seeding_time_limit == 60
assert request.ratio_limit == 1.5
assert request is None
assert recorder.last_status == "error"
assert recorder.last_message is not None
assert "could not be refreshed" in recorder.last_message
def test_cache_miss_re_resolves_and_uses_fresh_download_url(self):
"""Cache miss should re-query Prowlarr and use a fresh exact-match URL."""
task_id = "fresh-guid-1"
fresh_url = "https://prowlarr.example.com/download/fresh-token"
def mock_search(*_args, **_kwargs):
cache_release(
task_id,
{
"guid": task_id,
"protocol": "torrent",
"downloadUrl": fresh_url,
"title": "Fresh Release",
},
)
return [
Release(
source="prowlarr",
source_id=task_id,
title="Fresh Release",
info_url="https://tracker.example.com/release/1",
protocol=ReleaseProtocol.TORRENT,
)
]
mock_client = MagicMock()
mock_client.name = "qbittorrent"
mock_client.find_existing.return_value = None
mock_client.add_download.return_value = "download_id"
remove_release(task_id)
try:
with (
patch(
"shelfmark.release_sources.prowlarr.handler.ProwlarrSource.search",
side_effect=mock_search,
) as mock_search_method,
patch(
"shelfmark.release_sources.prowlarr.handler.get_client",
return_value=mock_client,
),
patch.object(ProwlarrHandler, "_poll_and_complete", return_value=None),
):
handler = ProwlarrHandler()
task = DownloadTask(
task_id=task_id,
source="prowlarr",
title="Fresh Book",
retry_source_context={"indexer": "MyIndexer"},
)
recorder = ProgressRecorder()
handler.download(
task=task,
cancel_flag=Event(),
progress_callback=recorder.progress_callback,
status_callback=recorder.status_callback,
)
mock_search_method.assert_called_once()
assert mock_client.add_download.call_args.kwargs["url"] == fresh_url
finally:
remove_release(task_id)
def test_stale_cached_url_add_failure_re_resolves_once_and_succeeds(self):
"""Expired cached proxy URL should be refreshed once after qBittorrent hash failure."""
task_id = "stale-guid-1"
stale_url = "https://prowlarr.example.com/download/stale-token"
fresh_url = "https://prowlarr.example.com/download/fresh-token"
def mock_search(*_args, **_kwargs):
cache_release(
task_id,
{
"guid": task_id,
"protocol": "torrent",
"downloadUrl": fresh_url,
"title": "Fresh Release",
},
)
return [
Release(
source="prowlarr",
source_id=task_id,
title="Fresh Release",
protocol=ReleaseProtocol.TORRENT,
)
]
mock_client = MagicMock()
mock_client.name = "qbittorrent"
mock_client.find_existing.return_value = None
mock_client.add_download.side_effect = [
RuntimeError("Could not determine torrent hash from URL"),
"download_id",
]
cache_release(
task_id,
{
"guid": task_id,
"protocol": "torrent",
"downloadUrl": stale_url,
"title": "Stale Release",
},
)
try:
with (
patch(
"shelfmark.release_sources.prowlarr.handler.ProwlarrSource.search",
side_effect=mock_search,
) as mock_search_method,
patch(
"shelfmark.release_sources.prowlarr.handler.get_client",
return_value=mock_client,
),
patch.object(ProwlarrHandler, "_poll_and_complete", return_value=None),
):
handler = ProwlarrHandler()
task = DownloadTask(task_id=task_id, source="prowlarr", title="Stale Book")
recorder = ProgressRecorder()
handler.download(
task=task,
cancel_flag=Event(),
progress_callback=recorder.progress_callback,
status_callback=recorder.status_callback,
)
assert mock_client.add_download.call_count == 2
assert mock_client.add_download.call_args_list[0].kwargs["url"] == stale_url
assert mock_client.add_download.call_args_list[1].kwargs["url"] == fresh_url
mock_search_method.assert_called_once()
finally:
remove_release(task_id)
def test_refresh_without_exact_match_fails_clearly(self):
"""Refresh must not pick a different Prowlarr result when identity differs."""
task_id = "missing-guid-1"
other_id = "other-guid-1"
def mock_search(*_args, **_kwargs):
cache_release(
other_id,
{
"guid": other_id,
"protocol": "torrent",
"downloadUrl": "https://prowlarr.example.com/download/other",
"title": "Other Release",
},
)
return [
Release(
source="prowlarr",
source_id=other_id,
title="Other Release",
protocol=ReleaseProtocol.TORRENT,
)
]
remove_release(task_id)
remove_release(other_id)
try:
with patch(
"shelfmark.release_sources.prowlarr.handler.ProwlarrSource.search",
side_effect=mock_search,
):
handler = ProwlarrHandler()
task = DownloadTask(task_id=task_id, source="prowlarr", title="Missing Book")
recorder = ProgressRecorder()
request = handler._resolve_download(task, recorder.status_callback)
assert request is None
assert recorder.last_status == "error"
assert recorder.last_message is not None
assert "could not be refreshed" in recorder.last_message
finally:
remove_release(task_id)
remove_release(other_id)
def test_magnet_result_does_not_trigger_prowlarr_url_refresh(self):
"""Magnet failures should not be treated as expired Prowlarr proxy URLs."""
task_id = "magnet-guid-1"
magnet = "magnet:?xt=urn:btih:abc123&dn=test"
mock_client = MagicMock()
mock_client.name = "qbittorrent"
mock_client.find_existing.return_value = None
mock_client.add_download.side_effect = RuntimeError(
"Could not determine torrent hash from URL"
)
cache_release(
task_id,
{
"guid": task_id,
"protocol": "torrent",
"downloadUrl": "https://prowlarr.example.com/download/stale-token",
"magnetUrl": magnet,
"title": "Magnet Release",
},
)
try:
with (
patch(
"shelfmark.release_sources.prowlarr.handler.ProwlarrSource.search",
return_value=[],
) as mock_search_method,
patch(
"shelfmark.release_sources.prowlarr.handler.get_client",
return_value=mock_client,
),
):
handler = ProwlarrHandler()
task = DownloadTask(task_id=task_id, source="prowlarr", title="Magnet Book")
recorder = ProgressRecorder()
result = handler.download(
task=task,
cancel_flag=Event(),
progress_callback=recorder.progress_callback,
status_callback=recorder.status_callback,
)
assert result is None
assert mock_client.add_download.call_count == 1
assert mock_client.add_download.call_args.kwargs["url"] == magnet
mock_search_method.assert_not_called()
finally:
remove_release(task_id)
def test_download_fails_without_download_url(self):
"""Test that download fails when release has no download URL."""
@@ -316,6 +555,124 @@ class TestProwlarrHandlerSeedCriteria:
assert request.seeding_time_limit is None
assert request.ratio_limit is None
def test_resolve_download_falls_back_to_prowlarr_when_enrichment_missing(self):
"""Regression test for #795: when search-time enrichment is missing,
share limits are re-resolved from Prowlarr at grab time."""
mock_client = MagicMock()
mock_client.get_indexer_seed_settings.return_value = {
5: {"seeding_time_limit_minutes": 4320, "ratio_limit": 1.0}
}
def config_get(key, default=None):
return True if key == "PROWLARR_USE_SEED_PREFERENCES" else default
with (
patch(
"shelfmark.release_sources.prowlarr.handler.get_release",
return_value={
"protocol": "torrent",
"title": "Test Release",
"magnetUrl": "magnet:?xt=urn:btih:abc123",
"indexerId": 5,
},
),
patch(
"shelfmark.release_sources.prowlarr.handler.config.get",
side_effect=config_get,
),
patch.object(
ProwlarrHandler,
"_build_prowlarr_client",
return_value=mock_client,
),
):
handler = ProwlarrHandler()
task = DownloadTask(
task_id="seed-time-fallback",
source="prowlarr",
title="Test Book",
)
request = handler._resolve_download(task, lambda *_: None)
assert request is not None
assert request.seeding_time_limit == 4320
assert request.ratio_limit == 1.0
mock_client.get_indexer_seed_settings.assert_called_once_with(restrict_to=[5])
def test_resolve_download_fallback_failure_leaves_limits_unset(self):
mock_client = MagicMock()
mock_client.get_indexer_seed_settings.side_effect = RuntimeError("prowlarr down")
def config_get(key, default=None):
return True if key == "PROWLARR_USE_SEED_PREFERENCES" else default
with (
patch(
"shelfmark.release_sources.prowlarr.handler.get_release",
return_value={
"protocol": "torrent",
"title": "Test Release",
"magnetUrl": "magnet:?xt=urn:btih:abc123",
"indexerId": 5,
},
),
patch(
"shelfmark.release_sources.prowlarr.handler.config.get",
side_effect=config_get,
),
patch.object(
ProwlarrHandler,
"_build_prowlarr_client",
return_value=mock_client,
),
):
handler = ProwlarrHandler()
task = DownloadTask(
task_id="seed-time-fallback-failure",
source="prowlarr",
title="Test Book",
)
request = handler._resolve_download(task, lambda *_: None)
assert request is not None
assert request.seeding_time_limit is None
assert request.ratio_limit is None
def test_resolve_download_skips_fallback_when_enrichment_present(self):
with (
patch(
"shelfmark.release_sources.prowlarr.handler.get_release",
return_value={
"protocol": "torrent",
"title": "Test Release",
"magnetUrl": "magnet:?xt=urn:btih:abc123",
"indexerId": 5,
"configuredSeedTimeMinutes": 7200,
},
),
patch(
"shelfmark.release_sources.prowlarr.handler.config.get",
side_effect=lambda key, default=None: (
True if key == "PROWLARR_USE_SEED_PREFERENCES" else default
),
),
patch.object(ProwlarrHandler, "_build_prowlarr_client") as mock_builder,
):
handler = ProwlarrHandler()
task = DownloadTask(
task_id="seed-time-no-fallback",
source="prowlarr",
title="Test Book",
)
request = handler._resolve_download(task, lambda *_: None)
assert request is not None
assert request.seeding_time_limit == 7200
mock_builder.assert_not_called()
def test_download_passes_seed_limits_to_client(self):
mock_client = MagicMock()
mock_client.name = "qbittorrent"
@@ -1099,6 +1456,61 @@ class TestProwlarrHandlerFileStaging:
class TestProwlarrHandlerPostProcessCleanup:
def test_torrent_change_category_sets_post_import_category(self):
handler = ProwlarrHandler()
task = DownloadTask(task_id="torrent-category", source="prowlarr", title="Test")
mock_client = MagicMock()
mock_client.name = "qbittorrent"
mock_client.set_category.return_value = True
handler._cleanup_refs[task.task_id] = (mock_client, "abc123", "torrent")
config_values = {
"PROWLARR_TORRENT_ACTION": "change_category",
"PROWLARR_TORRENT_POST_IMPORT_CATEGORY": "imported",
}
with patch(
"shelfmark.download.clients.base_handler.config.get",
side_effect=lambda key, default="": config_values.get(key, default),
):
handler.post_process_cleanup(task, success=True)
mock_client.set_category.assert_called_once_with("abc123", "imported")
mock_client.remove.assert_not_called()
def test_torrent_keep_does_not_change_category(self):
handler = ProwlarrHandler()
task = DownloadTask(task_id="torrent-no-category", source="prowlarr", title="Test")
mock_client = MagicMock()
handler._cleanup_refs[task.task_id] = (mock_client, "abc123", "torrent")
with patch("shelfmark.download.clients.base_handler.config.get", return_value="keep"):
handler.post_process_cleanup(task, success=True)
mock_client.set_category.assert_not_called()
mock_client.remove.assert_not_called()
def test_torrent_change_category_ignores_empty_category(self):
handler = ProwlarrHandler()
task = DownloadTask(task_id="torrent-empty-category", source="prowlarr", title="Test")
mock_client = MagicMock()
handler._cleanup_refs[task.task_id] = (mock_client, "abc123", "torrent")
config_values = {
"PROWLARR_TORRENT_ACTION": "change_category",
"PROWLARR_TORRENT_POST_IMPORT_CATEGORY": "",
}
with patch(
"shelfmark.download.clients.base_handler.config.get",
side_effect=lambda key, default="": config_values.get(key, default),
):
handler.post_process_cleanup(task, success=True)
mock_client.set_category.assert_not_called()
mock_client.remove.assert_not_called()
def test_usenet_move_triggers_client_cleanup(self):
handler = ProwlarrHandler()
task = DownloadTask(task_id="cleanup-test", source="prowlarr", title="Test")
@@ -1227,3 +1639,57 @@ class TestProwlarrHandlerPostProcessCleanup:
assert args[1] == "nzbget"
assert args[2] == "123"
assert str(args[3]) == "delete failed"
class TestRawReleaseMatchesTask:
"""Refreshing a stale release has to find it by whichever id form the task holds."""
RAW = {
"guid": "https://tracker.example/torrent/555",
"infoUrl": "https://tracker.example/details/555",
"indexerId": 25,
}
def test_matches_the_indexer_qualified_source_id(self):
assert ProwlarrHandler._raw_release_matches_task(
self.RAW, "25:https://tracker.example/torrent/555"
)
def test_still_matches_a_bare_guid_from_a_task_queued_before_the_change(self):
assert ProwlarrHandler._raw_release_matches_task(
self.RAW, "https://tracker.example/torrent/555"
)
def test_still_matches_a_bare_info_url(self):
assert ProwlarrHandler._raw_release_matches_task(
self.RAW, "https://tracker.example/details/555"
)
def test_does_not_match_the_same_guid_from_a_different_indexer_entry(self):
assert not ProwlarrHandler._raw_release_matches_task(
self.RAW, "10:https://tracker.example/torrent/555"
)
def test_does_not_match_an_unrelated_release(self):
assert not ProwlarrHandler._raw_release_matches_task(self.RAW, "25:something-else")
class TestRawReleaseMatchesTaskEdgeCases:
"""Matching the wrong release here grabs the wrong torrent."""
def test_blank_task_id_never_matches(self):
raw = {"guid": "g", "infoUrl": "i", "indexerId": 25}
assert not ProwlarrHandler._raw_release_matches_task(raw, "")
assert not ProwlarrHandler._raw_release_matches_task(raw, " ")
assert not ProwlarrHandler._raw_release_matches_task(raw, None)
def test_a_release_with_no_identifiers_does_not_match_a_blank_task_id(self):
assert not ProwlarrHandler._raw_release_matches_task({}, None)
assert not ProwlarrHandler._raw_release_matches_task({}, "")
def test_matches_a_qualified_id_built_from_a_guidless_release(self):
raw = {"indexerId": 25, "indexer": "MAM", "title": "Dune"}
built = build_source_id(raw)
assert ProwlarrHandler._raw_release_matches_task(raw, built)
+1 -1
View File
@@ -126,7 +126,7 @@ class TestHandlerCacheOperations:
assert result is None
assert recorder.last_status == "error"
assert "cache" in recorder.last_message.lower()
assert "could not be refreshed" in recorder.last_message
def test_download_fails_without_download_url(self):
"""Test that download fails when release has no download URL."""
+140
View File
@@ -5,6 +5,7 @@ These tests mock the qbittorrentapi library to test the client logic
without requiring a running qBittorrent instance.
"""
from contextlib import contextmanager
from unittest.mock import MagicMock, patch
import pytest
@@ -58,6 +59,20 @@ def create_mock_session_response(torrents, status_code=200):
return mock_response
def test_set_category_creates_and_assigns_category():
from shelfmark.download.clients.qbittorrent import QBittorrentClient
client = QBittorrentClient.__new__(QBittorrentClient)
client._client = MagicMock()
assert client.set_category("abc123", "imported") is True
client._client.torrents_create_category.assert_called_once_with(name="imported")
client._client.torrents_set_category.assert_called_once_with(
torrent_hashes="abc123",
category="imported",
)
class TestQBittorrentClientIsConfigured:
"""Tests for QBittorrentClient.is_configured()."""
@@ -179,6 +194,76 @@ class TestQBittorrentClientTestConnection:
assert "401" in message or "failed" in message.lower()
class TestQBittorrentClientApiKeyAuth:
"""Tests for API key authentication (qBittorrent 5.2.0+)."""
API_KEY = "qbt_0123456789abcdefghijklmnopqr"
@contextmanager
def _build_client(self, monkeypatch, api_key, mock_client_instance=None):
"""Construct the client against a stubbed qbittorrentapi, yielding it and the stub."""
config_values = {
"QBITTORRENT_URL": "http://localhost:8080",
"QBITTORRENT_USERNAME": "admin",
"QBITTORRENT_PASSWORD": "password",
"QBITTORRENT_API_KEY": api_key,
"QBITTORRENT_CATEGORY": "test",
}
monkeypatch.setattr(
"shelfmark.download.clients.qbittorrent.config.get",
lambda key, default="": config_values.get(key, default),
)
mock_client_class = MagicMock(return_value=mock_client_instance or MagicMock())
with patch.dict("sys.modules", {"qbittorrentapi": MagicMock(Client=mock_client_class)}):
import importlib
import shelfmark.download.clients.qbittorrent as qb_module
importlib.reload(qb_module)
yield qb_module.QBittorrentClient(), mock_client_class
@pytest.mark.parametrize(
("api_key", "expected_kwarg"),
[(API_KEY, API_KEY), ("", None)],
)
def test_api_key_forwarded_to_qbittorrentapi(self, monkeypatch, api_key, expected_kwarg):
"""A configured key is handed to qbittorrent-api; without one it falls back to password."""
with self._build_client(monkeypatch, api_key) as (_client, mock_client_class):
assert mock_client_class.call_args.kwargs["api_key"] == expected_kwarg
@pytest.mark.parametrize(("api_key", "logs_in"), [(API_KEY, False), ("", True)])
def test_direct_requests_log_in_only_for_cookie_auth(self, monkeypatch, api_key, logs_in):
"""Bearer auth is stateless, so only cookie auth needs a login before direct calls."""
mock_client_instance = MagicMock()
mock_client_instance._session.get.return_value = create_mock_session_response(
[MockTorrent(progress=0.5, state="downloading")], status_code=200
)
with self._build_client(monkeypatch, api_key, mock_client_instance) as (client, _):
status = client.get_status("abc123")
assert status.progress == 50.0
assert mock_client_instance.auth_log_in.called is logs_in
@pytest.mark.parametrize(("api_key", "expected_requests"), [(API_KEY, 1), ("", 2)])
def test_403_retried_only_when_a_login_can_refresh_it(
self, monkeypatch, api_key, expected_requests
):
"""A bearer token cannot be refreshed, so re-issuing a 403 would just waste a request."""
mock_client_instance = MagicMock()
mock_client_instance._session.get.return_value = create_mock_session_response(
[], status_code=403
)
with self._build_client(monkeypatch, api_key, mock_client_instance) as (client, _):
status = client.get_status("abc123")
assert status.state_value == "error"
assert mock_client_instance._session.get.call_count == expected_requests
class TestQBittorrentClientGetStatus:
"""Tests for QBittorrentClient.get_status()."""
@@ -903,6 +988,61 @@ class TestQBittorrentClientAddDownload:
"http://tracker.example/download/book.torrent", "Test Download"
)
def test_add_download_error_includes_fetch_failure_reason(self, monkeypatch):
"""Surface why the .torrent prefetch failed instead of only the hash error.
Regression for #1111: a Prowlarr proxy fetch that fails (e.g. HTTP 500
because the tracker rejected the request) was reported as a bare
"Could not determine torrent hash from URL", hiding the actual cause.
"""
config_values = {
"QBITTORRENT_URL": "http://localhost:8080",
"QBITTORRENT_USERNAME": "admin",
"QBITTORRENT_PASSWORD": "password",
"QBITTORRENT_CATEGORY": "books",
}
monkeypatch.setattr(
"shelfmark.download.clients.qbittorrent.config.get",
lambda key, default="": config_values.get(key, default),
)
monkeypatch.setattr(
"shelfmark.download.clients.qbittorrent.time.sleep", lambda _seconds: None
)
mock_client_instance = MagicMock()
mock_client_instance.torrents_add.return_value = "Ok."
mock_client_instance._session.get.return_value = create_mock_session_response([])
mock_client_class = MagicMock(return_value=mock_client_instance)
with patch.dict("sys.modules", {"qbittorrentapi": MagicMock(Client=mock_client_class)}):
import importlib
import shelfmark.download.clients.qbittorrent as qb_module
importlib.reload(qb_module)
with patch(
"shelfmark.download.clients.qbittorrent.extract_torrent_info",
autospec=True,
) as mock_extract:
mock_extract.return_value = TorrentInfo(
info_hash=None,
torrent_data=None,
is_magnet=False,
magnet_url=None,
fetch_error="500 Server Error: Internal Server Error for url: http://prowlarr:9696/26/download",
)
client = qb_module.QBittorrentClient()
with pytest.raises(RuntimeError) as exc_info:
client.add_download(
"http://prowlarr:9696/26/download?apikey=key&link=token",
"Test Download",
)
assert "Could not determine torrent hash from URL" in str(exc_info.value)
assert "500 Server Error" in str(exc_info.value)
def test_add_download_creates_category(self, monkeypatch):
"""Test that add_download creates category if needed."""
config_values = {
+112
View File
@@ -0,0 +1,112 @@
"""qBittorrent download client settings fields and test-connection callback."""
import types
from unittest.mock import patch
import pytest
from qbittorrentapi import LoginFailed
from shelfmark.core.settings_registry import PasswordField
API_KEY = "qbt_0123456789abcdefghijklmnopqr"
def make_config_getter(values):
"""Create a config.get function that returns values from a dict."""
def getter(key, default=""):
return values.get(key, default)
return getter
def _get_field(fields, key):
"""Find a field by key."""
return next((f for f in fields if f.key == key), None)
def fake_qbittorrentapi(*, web_api_version="2.15.1", reject_auth=False, captured=None):
"""Build a stand-in qbittorrentapi module recording Client kwargs into `captured`."""
class FakeClient:
def __init__(self, **kwargs):
if captured is not None:
captured.update(kwargs)
self.app = types.SimpleNamespace(web_api_version=web_api_version)
def auth_log_in(self):
if reject_auth:
raise LoginFailed
module = types.ModuleType("qbittorrentapi")
module.Client = FakeClient
return module
def _run_test_connection(monkeypatch, current_values, fake_module):
"""Invoke the Test Connection callback against a stubbed qbittorrentapi."""
from shelfmark.core.config import config as config_obj
from shelfmark.download.clients import settings as settings_module
monkeypatch.setattr(config_obj, "get", make_config_getter(current_values))
monkeypatch.setattr(settings_module, "get_ssl_verify", lambda _url: True)
with patch.dict("sys.modules", {"qbittorrentapi": fake_module}):
return settings_module._test_qbittorrent_connection(current_values=current_values)
def test_api_key_field_is_registered():
"""The API key is offered alongside the other qBittorrent credentials."""
from shelfmark.download.clients.settings import prowlarr_clients_settings
field = _get_field(prowlarr_clients_settings(), "QBITTORRENT_API_KEY")
assert isinstance(field, PasswordField)
assert field.show_when == {"field": "PROWLARR_TORRENT_CLIENT", "value": "qbittorrent"}
@pytest.mark.parametrize(
("api_key", "expected_kwarg", "expected_suffix"),
[(API_KEY, API_KEY, " using the API key"), ("", None, "")],
)
def test_settings_test_connection_forwards_api_key(
monkeypatch, api_key, expected_kwarg, expected_suffix
):
"""The Test Connection button authenticates with the key when set, and says which it used."""
captured = {}
result = _run_test_connection(
monkeypatch,
{
"QBITTORRENT_URL": "http://localhost:8080",
"QBITTORRENT_USERNAME": "admin",
"QBITTORRENT_PASSWORD": "password",
"QBITTORRENT_API_KEY": api_key,
},
fake_qbittorrentapi(captured=captured),
)
assert captured["api_key"] == expected_kwarg
assert result == {
"success": True,
"message": f"Connected to qBittorrent (API v2.15.1){expected_suffix}",
}
@pytest.mark.parametrize(
("api_key", "rejected"),
[(API_KEY, "API key"), ("", "username or password")],
)
def test_settings_test_connection_names_rejected_credential(monkeypatch, api_key, rejected):
"""LoginFailed carries no message of its own, so the callback names the credential."""
result = _run_test_connection(
monkeypatch,
{
"QBITTORRENT_URL": "http://localhost:8080",
"QBITTORRENT_USERNAME": "admin",
"QBITTORRENT_PASSWORD": "password",
"QBITTORRENT_API_KEY": api_key,
},
fake_qbittorrentapi(reject_auth=True),
)
assert result == {"success": False, "message": f"qBittorrent rejected the {rejected}"}
+16 -4
View File
@@ -27,6 +27,17 @@ def create_mock_xmlrpc_module():
return mock_module
def test_set_category_updates_custom1_label():
from shelfmark.download.clients.rtorrent import RTorrentClient
client = RTorrentClient.__new__(RTorrentClient)
client._rpc = MagicMock()
# rTorrent lookups are case sensitive and info hashes reach us lowercase.
assert client.set_category("abc123", "imported") is True
client._rpc.d.custom1.set.assert_called_once_with("ABC123", "imported")
class TestRTorrentClientIsConfigured:
"""Tests for RTorrentClient.is_configured()."""
@@ -769,8 +780,9 @@ class TestRTorrentClientRemove:
result = client.remove("abc123def456", delete_files=False)
assert result is True
mock_rpc.d.stop.assert_called_once_with("abc123def456")
mock_rpc.d.erase.assert_called_once_with("abc123def456")
# rTorrent lookups are case sensitive and info hashes reach us lowercase.
mock_rpc.d.stop.assert_called_once_with("ABC123DEF456")
mock_rpc.d.erase.assert_called_once_with("ABC123DEF456")
def test_remove_with_files(self, monkeypatch):
"""Test torrent removal with file deletion."""
@@ -803,8 +815,8 @@ class TestRTorrentClientRemove:
result = client.remove("abc123def456", delete_files=True)
assert result is True
mock_rpc.d.delete_tied.assert_called_once_with("abc123def456")
mock_rpc.d.erase.assert_called_once_with("abc123def456")
mock_rpc.d.delete_tied.assert_called_once_with("ABC123DEF456")
mock_rpc.d.erase.assert_called_once_with("ABC123DEF456")
def test_remove_failure(self, monkeypatch):
"""Test failed torrent removal."""
+564 -10
View File
@@ -11,11 +11,22 @@ from shelfmark.metadata_providers import BookMetadata
from shelfmark.release_sources.prowlarr.api import ProwlarrClient
from shelfmark.release_sources.prowlarr.source import (
ProwlarrSource,
_build_indexer_priority,
_collapse_duplicate_indexer_results,
_detect_content_type_from_categories,
_extract_format,
_extract_mam_language,
_fetch_indexer_seed_settings,
_last_known_seed_settings,
_parse_size,
_release_identity,
_result_dedup_key,
)
from shelfmark.release_sources.prowlarr.utils import (
build_source_id,
get_protocol_display,
sanitize_download_url,
)
from shelfmark.release_sources.prowlarr.utils import get_protocol_display, sanitize_download_url
class _AvailableSource:
@@ -234,7 +245,7 @@ class FakeTorznabClient:
self.search_results = search_results or []
self.seed_settings = seed_settings or {}
def get_enabled_indexers_detailed(self):
def get_enabled_indexers_detailed(self, *, raise_on_error=False):
return [
{
"id": 1,
@@ -263,8 +274,8 @@ class FakeTorznabClient:
self.queries.append(query)
return self.search_results
def get_enriched_indexer_ids(self, restrict_to=None):
del restrict_to
def get_enriched_indexer_ids(self, restrict_to=None, indexers=None):
del restrict_to, indexers
return []
def get_indexer_seed_settings(self, restrict_to=None):
@@ -278,7 +289,7 @@ class TestProwlarrIndexerSeedSettings:
monkeypatch.setattr(
client,
"get_enabled_indexers_detailed",
lambda: [
lambda *, raise_on_error=False: [
{
"id": 13,
"protocol": "torrent",
@@ -517,8 +528,12 @@ class TestProwlarrLocalizedQueries:
assert success is True
assert error is None
assert captured_tasks[0].retry_download_url == secret_download_url
assert captured_tasks[0].retry_download_protocol == "usenet"
assert captured_tasks[0].retry_download_url is None
assert captured_tasks[0].retry_download_protocol is None
assert captured_tasks[0].retry_source_context == {
"source_id": "secret-prowlarr-release",
"info_url": "secret-prowlarr-release",
}
assert "retry_download_url" not in orchestrator._task_to_dict(captured_tasks[0])
def test_search_uses_localized_titles_when_available(self, monkeypatch):
@@ -640,7 +655,7 @@ class TestProwlarrLocalizedQueries:
source = ProwlarrSource()
class FailingClient:
def get_enabled_indexers_detailed(self):
def get_enabled_indexers_detailed(self, *, raise_on_error=False):
raise RuntimeError("indexers unavailable")
monkeypatch.setattr(source, "_get_client", lambda: FailingClient())
@@ -655,7 +670,7 @@ class TestProwlarrLocalizedQueries:
source = ProwlarrSource()
class FailingClient:
def get_enabled_indexers_detailed(self):
def get_enabled_indexers_detailed(self, *, raise_on_error=False):
raise RuntimeError("indexers unavailable")
assert source._resolve_indexer_ids_from_names(FailingClient(), ["Alpha"]) is None
@@ -664,7 +679,546 @@ class TestProwlarrLocalizedQueries:
source = ProwlarrSource()
class FailingClient:
def get_enabled_indexers_detailed(self):
def get_enabled_indexers_detailed(self, *, raise_on_error=False):
raise RuntimeError("indexers unavailable")
assert source._get_search_indexer_ids(FailingClient(), None, [7000]) == []
class TestFetchIndexerSeedSettingsFallback:
"""Regression tests for #795: transient Prowlarr API failures must not
silently strip per-indexer share limits from search results."""
@pytest.fixture(autouse=True)
def _clear_last_known_seed_settings(self):
_last_known_seed_settings.clear()
yield
_last_known_seed_settings.clear()
def test_success_updates_last_known_good(self):
class Client:
def get_indexer_seed_settings(self, restrict_to=None):
del restrict_to
return {13: {"seeding_time_limit_minutes": 4320, "ratio_limit": 1.0}}
fetched = _fetch_indexer_seed_settings(Client(), None)
assert fetched == {13: {"seeding_time_limit_minutes": 4320, "ratio_limit": 1.0}}
assert _last_known_seed_settings == fetched
def test_failure_falls_back_to_last_known_good(self):
_last_known_seed_settings[13] = {"seeding_time_limit_minutes": 4320}
class FailingClient:
def get_indexer_seed_settings(self, restrict_to=None):
del restrict_to
raise RuntimeError("indexers unavailable")
fetched = _fetch_indexer_seed_settings(FailingClient(), None)
assert fetched == {13: {"seeding_time_limit_minutes": 4320}}
# The fallback must be a copy so callers cannot mutate the cache.
fetched[99] = {"ratio_limit": 2.0}
assert 99 not in _last_known_seed_settings
def test_failure_with_no_history_returns_empty(self):
class FailingClient:
def get_indexer_seed_settings(self, restrict_to=None):
del restrict_to
raise RuntimeError("indexers unavailable")
assert _fetch_indexer_seed_settings(FailingClient(), None) == {}
class TestMamLanguageCoverage:
"""MyAnonamouse offers 62 languages; an unmapped code is dropped entirely,
which would leave {Language} empty and different-language editions colliding."""
def test_unmapped_language_is_dropped_not_passed_through(self):
# Documents why coverage matters: there is no raw fallback.
assert _extract_mam_language("Some Book [XYZ / EPUB]") is None
def test_maps_the_common_three_letter_codes(self):
cases = {
"ENG": "en",
"SWE": "sv",
"GER": "de",
"DEU": "de",
"FRE": "fr",
"FRA": "fr",
"CZE": "cs",
"CES": "cs",
}
for tag, expected in cases.items():
assert _extract_mam_language(f"Book [{tag} / EPUB]") == expected, tag
def test_maps_languages_added_for_mam_parity(self):
cases = {
"LAT": "la",
"PER": "fa",
"FAS": "fa",
"TAM": "ta",
"URD": "ur",
"EST": "et",
"ICE": "is",
"ISL": "is",
"GLE": "ga",
"TGL": "fil",
"BEN": "bn",
"BOS": "bs",
"SAN": "sa",
"GLA": "gd",
"GLV": "gv",
"MAY": "ms",
"MSA": "ms",
"BUR": "my",
"MYA": "my",
}
for tag, expected in cases.items():
assert _extract_mam_language(f"Book [{tag} / M4B]") == expected, tag
class _MultiIndexerClient:
"""Torznab client where each indexer entry returns its own result set.
Models one tracker configured in Prowlarr as several entries differing by a
server-side search filter, so the same guid comes back from more than one.
"""
def __init__(self, results_by_indexer: dict[int, list[dict]], priorities=None):
self.results_by_indexer = results_by_indexer
self.priorities = priorities or {}
def get_enabled_indexers_detailed(self, *, raise_on_error=False):
del raise_on_error
return [
{
"id": indexer_id,
"enable": True,
"priority": self.priorities.get(indexer_id, 25),
"capabilities": {
"categories": [
{"id": 7000, "subCategories": []},
{"id": 3030, "subCategories": []},
]
},
}
for indexer_id in sorted(self.results_by_indexer)
]
def torznab_search(
self,
*,
indexer_id: int,
query: str,
categories=None,
search_type="book",
limit=100,
offset=0,
):
del query, categories, search_type, limit, offset
return self.results_by_indexer.get(indexer_id, [])
def get_enriched_indexer_ids(self, restrict_to=None, indexers=None):
del restrict_to, indexers
return []
def get_indexer_seed_settings(self, restrict_to=None):
del restrict_to
return {}
def _mam_result(indexer_id: int, indexer: str, guid: str, *, freeleech: bool = False) -> dict:
return {
"guid": guid,
"title": "Dune",
"indexerId": indexer_id,
"indexer": indexer,
"protocol": "torrent",
"size": 1048576,
"seeders": 10,
"leechers": 1,
"categories": [{"id": 7020}],
"downloadVolumeFactor": 0.0 if freeleech else 1.0,
"infoUrl": f"https://tracker.example/{guid}",
}
class TestIndexerAwareDeduplication:
"""One tracker as several Prowlarr entries must not collapse to one row (#1137).
These assert the split itself, so they turn PROWLARR_COLLAPSE_DUPLICATES off:
it ships on, which keeps the release list as it was before #1137 for everyone
who has not asked for the per-entry rows.
"""
SPLIT = {"PROWLARR_COLLAPSE_DUPLICATES": False}
ONLY_ACTIVE = 10
FREELEECH = 25
def _search(self, monkeypatch, results_by_indexer, config_values=None, priorities=None):
import shelfmark.release_sources.prowlarr.source as prowlarr_source
from shelfmark.core.search_plan import build_release_search_plan
values = {"PROWLARR_INDEXERS": "", "PROWLARR_AUTO_EXPAND": False}
values.update(config_values or {})
monkeypatch.setattr(
prowlarr_source.config, "get", lambda key, default=None: values.get(key, default)
)
source = ProwlarrSource()
monkeypatch.setattr(
source, "_get_client", lambda: _MultiIndexerClient(results_by_indexer, priorities)
)
book = BookMetadata(
provider="hardcover", provider_id="123", title="Dune", authors=["Frank Herbert"]
)
plan = build_release_search_plan(book, languages=["en"])
return source.search(book, plan)
def test_same_guid_from_two_indexer_entries_both_survive(self, monkeypatch):
shared_guid = "https://tracker.example/torrent/555"
releases = self._search(
monkeypatch,
{
self.ONLY_ACTIVE: [_mam_result(self.ONLY_ACTIVE, "MyAnonamouse", shared_guid)],
self.FREELEECH: [
_mam_result(
self.FREELEECH, "MyAnonamouse - Freeleech", shared_guid, freeleech=True
)
],
},
config_values=self.SPLIT,
)
assert len(releases) == 2
assert {r.indexer for r in releases} == {"MyAnonamouse", "MyAnonamouse - Freeleech"}
assert [r.extra["freeleech"] for r in releases].count(True) == 1
def test_surviving_rows_have_distinct_source_ids(self, monkeypatch):
shared_guid = "https://tracker.example/torrent/555"
releases = self._search(
monkeypatch,
{
self.ONLY_ACTIVE: [_mam_result(self.ONLY_ACTIVE, "MyAnonamouse", shared_guid)],
self.FREELEECH: [
_mam_result(self.FREELEECH, "MyAnonamouse - Freeleech", shared_guid)
],
},
config_values=self.SPLIT,
)
source_ids = [r.source_id for r in releases]
assert len(source_ids) == 2
assert len(set(source_ids)) == 2
def test_source_ids_resolve_to_their_own_indexer_entry(self, monkeypatch):
from shelfmark.release_sources.prowlarr.cache import get_release
shared_guid = "https://tracker.example/torrent/555"
releases = self._search(
monkeypatch,
{
self.ONLY_ACTIVE: [_mam_result(self.ONLY_ACTIVE, "MyAnonamouse", shared_guid)],
self.FREELEECH: [
_mam_result(self.FREELEECH, "MyAnonamouse - Freeleech", shared_guid)
],
},
config_values=self.SPLIT,
)
assert len(releases) == 2
cached_indexer_ids = []
for release in releases:
cached = get_release(release.source_id)
assert cached is not None
assert cached["indexerId"] == release.extra["indexer_id"]
cached_indexer_ids.append(cached["indexerId"])
assert sorted(cached_indexer_ids) == [self.ONLY_ACTIVE, self.FREELEECH]
def test_repeat_of_same_result_from_one_indexer_still_collapses(self, monkeypatch):
duplicate = _mam_result(self.ONLY_ACTIVE, "MyAnonamouse", "https://tracker.example/t/1")
releases = self._search(monkeypatch, {self.ONLY_ACTIVE: [duplicate, dict(duplicate)]})
assert len(releases) == 1
class TestBuildSourceId:
"""source_id keys the release cache, so it must survive shared guids."""
def test_same_guid_from_two_indexer_entries_gets_two_ids(self):
guid = "https://tracker.example/torrent/555"
assert build_source_id({"guid": guid, "indexerId": 10}) != build_source_id(
{"guid": guid, "indexerId": 25}
)
def test_same_indexer_and_guid_is_stable(self):
result = {"guid": "https://tracker.example/torrent/555", "indexerId": 10}
assert build_source_id(result) == build_source_id(dict(result))
def test_falls_back_to_the_bare_guid_without_an_indexer_id(self):
guid = "https://tracker.example/torrent/555"
assert build_source_id({"guid": guid}) == guid
def test_falls_back_to_indexer_and_title_without_a_guid(self):
source_id = build_source_id({"indexerId": 10, "indexer": "MAM", "title": "Dune"})
assert source_id.startswith("10:MAM:")
class TestCollapseDuplicatesSetting:
"""One-row-per-release collapse, on by default, resolved by Prowlarr's priority."""
ONLY_ACTIVE = 10
FREELEECH = 25
def _search_collapsed(self, monkeypatch, priorities):
shared_guid = "https://tracker.example/torrent/555"
return TestIndexerAwareDeduplication()._search(
monkeypatch,
{
self.ONLY_ACTIVE: [_mam_result(self.ONLY_ACTIVE, "MyAnonamouse", shared_guid)],
self.FREELEECH: [
_mam_result(self.FREELEECH, "MyAnonamouse - Freeleech", shared_guid)
],
},
config_values={"PROWLARR_COLLAPSE_DUPLICATES": True},
priorities=priorities,
)
def test_collapse_keeps_the_better_prowlarr_priority(self, monkeypatch):
releases = self._search_collapsed(monkeypatch, {self.FREELEECH: 20, self.ONLY_ACTIVE: 24})
assert len(releases) == 1
assert releases[0].indexer == "MyAnonamouse - Freeleech"
def test_collapse_honours_the_reverse_priority(self, monkeypatch):
releases = self._search_collapsed(monkeypatch, {self.FREELEECH: 30, self.ONLY_ACTIVE: 24})
assert len(releases) == 1
assert releases[0].indexer == "MyAnonamouse"
def test_equal_priority_keeps_the_first_queried(self, monkeypatch):
releases = self._search_collapsed(monkeypatch, {self.FREELEECH: 25, self.ONLY_ACTIVE: 25})
assert len(releases) == 1
assert releases[0].indexer == "MyAnonamouse"
def _search_shared_guid(self, monkeypatch, config_values):
return TestIndexerAwareDeduplication()._search(
monkeypatch,
{
self.ONLY_ACTIVE: [
_mam_result(self.ONLY_ACTIVE, "MyAnonamouse", "https://tracker.example/t/9")
],
self.FREELEECH: [
_mam_result(self.FREELEECH, "MAM - Freeleech", "https://tracker.example/t/9")
],
},
config_values=config_values,
priorities={self.FREELEECH: 20, self.ONLY_ACTIVE: 24},
)
def test_collapse_is_on_when_the_setting_is_untouched(self, monkeypatch):
"""An upgrading user who sets nothing keeps the single row they had before #1137."""
releases = self._search_shared_guid(monkeypatch, None)
assert len(releases) == 1
assert releases[0].indexer == "MAM - Freeleech"
def test_opting_out_keeps_every_indexer_entry(self, monkeypatch):
releases = self._search_shared_guid(monkeypatch, {"PROWLARR_COLLAPSE_DUPLICATES": False})
assert len(releases) == 2
def test_the_settings_field_and_the_search_fallback_agree(self):
"""The field default is what governs in production; the search fallback only
applies to an unregistered key. They have to say the same thing.
"""
from shelfmark.release_sources.prowlarr.settings import prowlarr_config_settings
field = next(
f for f in prowlarr_config_settings() if f.key == "PROWLARR_COLLAPSE_DUPLICATES"
)
assert field.default is True
class TestBuildIndexerPriority:
"""The priority NUMBER from Prowlarr is the rank; the id is only the key."""
def test_reads_the_priority_number_per_indexer(self):
priority = _build_indexer_priority([{"id": 25, "priority": 20}, {"id": 10, "priority": 24}])
assert priority == {25: 20, 10: 24}
def test_a_lower_number_is_preferred(self):
priority = _build_indexer_priority([{"id": 25, "priority": 20}, {"id": 10, "priority": 24}])
assert priority[25] < priority[10]
def test_coerces_string_ids_and_priorities(self):
assert _build_indexer_priority([{"id": "25", "priority": "20"}]) == {25: 20}
def test_skips_records_without_a_usable_id_or_priority(self):
assert (
_build_indexer_priority([{"priority": 20}, {"id": "abc", "priority": 20}, {"id": 7}])
== {}
)
def test_empty_input_yields_no_preferences(self):
assert _build_indexer_priority([]) == {}
class TestDedupEdgeCases:
"""Malformed and partial results must never silently lose a row."""
def test_unidentifiable_results_are_all_kept(self, monkeypatch):
blank = {"indexerId": 10, "indexer": "MAM", "protocol": "torrent"}
releases = TestIndexerAwareDeduplication()._search(
monkeypatch, {10: [dict(blank), dict(blank)]}
)
assert len(releases) == 2
def test_dedup_key_is_none_when_nothing_identifies_the_result(self):
assert _result_dedup_key({"indexerId": 10}) is None
assert _result_dedup_key({"guid": " ", "title": " "}) is None
def test_dedup_falls_back_to_title_within_one_indexer(self):
first = {"indexerId": 10, "title": "Dune"}
second = {"indexerId": 10, "title": "Dune"}
assert _result_dedup_key(first) == _result_dedup_key(second)
def test_same_title_from_two_indexers_is_not_deduped(self):
assert _result_dedup_key({"indexerId": 10, "title": "Dune"}) != _result_dedup_key(
{"indexerId": 25, "title": "Dune"}
)
def test_identity_ignores_whitespace_only_fields(self):
assert _release_identity({"guid": " ", "downloadUrl": "https://x/1"}) == "https://x/1"
def test_identity_accepts_a_numeric_guid(self):
assert _release_identity({"guid": 12345}) == "12345"
def test_identity_is_none_without_a_strong_identifier(self):
assert _release_identity({"title": "Dune", "indexerId": 10}) is None
class TestCollapseEdgeCases:
"""Collapse discards rows, so it must only ever merge on a strong identifier."""
def test_unidentifiable_results_are_never_collapsed(self):
blank = {"indexerId": 10, "title": "Dune"}
kept = _collapse_duplicate_indexer_results([dict(blank), dict(blank)], {})
assert len(kept) == 2
def test_same_title_different_torrents_are_not_collapsed(self):
results = [
{"indexerId": 10, "guid": "guid-a", "title": "Dune"},
{"indexerId": 25, "guid": "guid-b", "title": "Dune"},
]
assert len(_collapse_duplicate_indexer_results(results, {})) == 2
def test_collapse_preserves_the_position_of_the_row_it_replaces(self):
results = [
{"indexerId": 10, "guid": "shared"},
{"indexerId": 99, "guid": "other"},
{"indexerId": 25, "guid": "shared"},
]
kept = _collapse_duplicate_indexer_results(results, {25: 0, 10: 1})
assert [r["indexerId"] for r in kept] == [25, 99]
def test_results_without_an_indexer_id_still_collapse_on_guid(self):
results = [{"guid": "shared"}, {"guid": "shared"}]
assert len(_collapse_duplicate_indexer_results(results, {})) == 1
class TestIndexerPrioritySorting:
"""Results are listed by the indexer priority configured in Prowlarr."""
ONLY_ACTIVE = 10
FREELEECH = 25
OTHER = 99
def _search(self, monkeypatch, priorities):
return TestIndexerAwareDeduplication()._search(
monkeypatch,
{
self.ONLY_ACTIVE: [_mam_result(self.ONLY_ACTIVE, "MyAnonamouse", "https://t/a")],
self.FREELEECH: [
_mam_result(self.FREELEECH, "MyAnonamouse - Freeleech", "https://t/b")
],
self.OTHER: [_mam_result(self.OTHER, "Some Other Tracker", "https://t/c")],
},
priorities=priorities,
)
def test_results_follow_the_priority_numbers(self, monkeypatch):
releases = self._search(
monkeypatch, {self.FREELEECH: 20, self.ONLY_ACTIVE: 24, self.OTHER: 50}
)
assert [r.extra["indexer_id"] for r in releases] == [
self.FREELEECH,
self.ONLY_ACTIVE,
self.OTHER,
]
def test_reversing_the_numbers_reverses_the_list(self, monkeypatch):
releases = self._search(
monkeypatch, {self.FREELEECH: 50, self.ONLY_ACTIVE: 24, self.OTHER: 20}
)
assert [r.extra["indexer_id"] for r in releases] == [
self.OTHER,
self.ONLY_ACTIVE,
self.FREELEECH,
]
def test_equal_priorities_keep_query_order(self, monkeypatch):
releases = self._search(
monkeypatch, {self.FREELEECH: 25, self.ONLY_ACTIVE: 25, self.OTHER: 25}
)
assert [r.extra["indexer_id"] for r in releases] == [
self.ONLY_ACTIVE,
self.FREELEECH,
self.OTHER,
]
class TestIndexerPrioritySortOption:
"""The UI needs the priority on the row to offer an explicit sort on it."""
def test_releases_carry_their_prowlarr_priority(self, monkeypatch):
releases = TestIndexerPrioritySorting()._search(monkeypatch, {10: 24, 25: 20, 99: 50})
by_indexer = {r.extra["indexer_id"]: r.extra.get("indexer_priority") for r in releases}
assert by_indexer == {10: 24, 25: 20, 99: 50}
def test_priority_is_absent_when_prowlarr_reports_none(self, monkeypatch):
releases = TestIndexerPrioritySorting()._search(monkeypatch, {})
assert all(r.extra.get("indexer_priority") == 25 for r in releases)
def test_sort_option_is_offered_lowest_first(self):
from shelfmark.release_sources import serialize_column_config
source = ProwlarrSource()
serialized = serialize_column_config(source.get_column_config())
options = {o["label"]: o for o in serialized["extra_sort_options"]}
assert options["Indexer priority"]["sort_key"] == "extra.indexer_priority"
assert options["Indexer priority"]["default_direction"] == "asc"
assert options["Peers"]["default_direction"] == "desc"
+130
View File
@@ -668,3 +668,133 @@ class TestExtractHashFromMagnet:
magnet = f"magnet:?xt=urn:btmh:{b32}&dn=test"
result = extract_hash_from_magnet(magnet)
assert result == digest.hex()
class TestTorrentFetchCache:
"""Tests for reusing a fetched .torrent across find_existing/add_download (#1111)."""
@staticmethod
def _valid_torrent():
info_dict = {
b"name": b"book.txt",
b"length": 100,
b"piece length": 16384,
b"pieces": b"\x00" * 20,
}
torrent_data = bencode_encode({b"info": info_dict})
info_hash = hashlib.sha1(bencode_encode(info_dict)).hexdigest().lower()
return torrent_data, info_hash
def test_repeated_url_reuses_fetched_torrent_data(self, monkeypatch):
"""The same download URL is fetched once per add attempt, not once per caller.
find_existing() and add_download() both resolve the request URL; private
tracker links behind Prowlarr's proxy can be rate-limited or single-use,
so the second fetch must be served from the cache.
"""
torrent_data, info_hash = self._valid_torrent()
response = MagicMock(status_code=200, content=torrent_data)
response.raise_for_status = MagicMock()
mock_get = MagicMock(return_value=response)
monkeypatch.setattr("shelfmark.download.clients.torrent_utils.requests.get", mock_get)
url = "https://prowlarr.example/26/download?apikey=secret&link=token"
first = extract_torrent_info(url, fetch_torrent=True)
second = extract_torrent_info(url, fetch_torrent=True)
mock_get.assert_called_once()
assert first.info_hash == info_hash
assert second.info_hash == info_hash
assert second.torrent_data == torrent_data
def test_failed_fetch_is_not_cached_and_records_reason(self, monkeypatch):
"""Fetch failures are retried on the next call and expose the reason."""
import requests as requests_module
mock_get = MagicMock(
side_effect=requests_module.exceptions.HTTPError(
"500 Server Error: Internal Server Error for url: https://prowlarr.example/26/download"
)
)
monkeypatch.setattr("shelfmark.download.clients.torrent_utils.requests.get", mock_get)
url = "https://prowlarr.example/26/download?apikey=secret&link=token"
first = extract_torrent_info(url, fetch_torrent=True)
second = extract_torrent_info(url, fetch_torrent=True)
assert mock_get.call_count == 2
assert first.fetch_error is not None
assert "500 Server Error" in first.fetch_error
assert first.info_hash is None
assert second.fetch_error is not None
def test_fetch_failure_still_falls_back_to_expected_hash(self, monkeypatch):
"""A known infohash keeps working when the prefetch fails."""
import requests as requests_module
mock_get = MagicMock(side_effect=requests_module.exceptions.ConnectionError("boom"))
monkeypatch.setattr("shelfmark.download.clients.torrent_utils.requests.get", mock_get)
known_hash = "3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0"
result = extract_torrent_info(
"https://tracker.example/download/book.torrent",
fetch_torrent=True,
expected_hash=known_hash,
)
assert result.info_hash == known_hash
assert result.fetch_error is not None
assert "boom" in result.fetch_error
def test_expected_hash_applies_to_cached_hashless_result(self, monkeypatch):
"""A cached fetch without a hash still honors a caller's expected_hash."""
response = MagicMock(status_code=200, content=b"<html>not a torrent</html>")
response.raise_for_status = MagicMock()
mock_get = MagicMock(return_value=response)
monkeypatch.setattr("shelfmark.download.clients.torrent_utils.requests.get", mock_get)
url = "https://tracker.example/download/book.torrent"
known_hash = "3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0"
first = extract_torrent_info(url, fetch_torrent=True)
second = extract_torrent_info(url, fetch_torrent=True, expected_hash=known_hash)
mock_get.assert_called_once()
assert first.info_hash is None
assert second.info_hash == known_hash
def test_cache_expires_after_ttl(self, monkeypatch):
"""Stale cache entries are refetched instead of reused."""
torrent_data, _ = self._valid_torrent()
response = MagicMock(status_code=200, content=torrent_data)
response.raise_for_status = MagicMock()
mock_get = MagicMock(return_value=response)
monkeypatch.setattr("shelfmark.download.clients.torrent_utils.requests.get", mock_get)
clock = {"now": 1000.0}
monkeypatch.setattr(
"shelfmark.download.clients.torrent_utils.time.monotonic",
lambda: clock["now"],
)
url = "https://tracker.example/download/book.torrent"
extract_torrent_info(url, fetch_torrent=True)
clock["now"] += 121.0
extract_torrent_info(url, fetch_torrent=True)
assert mock_get.call_count == 2
def test_magnet_redirect_result_is_reused(self, monkeypatch):
"""A URL that redirects to a magnet link is also only resolved once."""
magnet = "magnet:?xt=urn:btih:3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0&dn=test"
response = MagicMock(status_code=302, headers={"Location": magnet})
mock_get = MagicMock(return_value=response)
monkeypatch.setattr("shelfmark.download.clients.torrent_utils.requests.get", mock_get)
url = "https://tracker.example/download/book.torrent"
first = extract_torrent_info(url, fetch_torrent=True)
second = extract_torrent_info(url, fetch_torrent=True)
mock_get.assert_called_once()
assert first.is_magnet is True
assert second.magnet_url == magnet
assert second.info_hash == "3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0"
@@ -68,6 +68,50 @@ def create_mock_transmission_rpc_module():
return mock_module
def _transmission_client_with_labels(labels):
from shelfmark.download.clients.transmission import TransmissionClient
client = TransmissionClient.__new__(TransmissionClient)
client._client = MagicMock()
client._client.get_torrent.return_value = types.SimpleNamespace(labels=labels)
return client
def test_set_category_appends_to_existing_labels():
client = _transmission_client_with_labels(["books", "seeding"])
assert client.set_category("abc123", "imported") is True
client._client.change_torrent.assert_called_once_with(
ids="abc123",
labels=["books", "seeding", "imported"],
)
def test_set_category_adds_label_when_torrent_has_none():
client = _transmission_client_with_labels([])
assert client.set_category("abc123", "imported") is True
client._client.change_torrent.assert_called_once_with(
ids="abc123",
labels=["imported"],
)
def test_set_category_is_a_noop_when_label_already_present():
client = _transmission_client_with_labels(["books", "imported"])
assert client.set_category("abc123", "imported") is True
client._client.change_torrent.assert_not_called()
def test_set_category_does_not_clobber_labels_when_lookup_fails():
client = _transmission_client_with_labels([])
client._client.get_torrent.side_effect = ValueError("boom")
assert client.set_category("abc123", "imported") is False
client._client.change_torrent.assert_not_called()
class TestTransmissionClientIsConfigured:
"""Tests for TransmissionClient.is_configured()."""
+70 -36
View File
@@ -129,57 +129,76 @@ EOF
cat <<'HC' > /app/tor_healthcheck.sh
#!/bin/bash
# Function to dynamically wait for Tor bootstrap
wait_for_tor() {
echo "$(date): Waiting for Tor to finish bootstrapping..."
# Tor watchdog.
#
# Once the NAT rules are live every process in the container is pinned to Tor,
# so a Tor that has died or wedged takes the whole app down with it and cannot
# reliably be repaired in place. When Tor stops being usable, tear the container
# down instead: the configured Docker restart policy brings it back and tor.sh
# rebuilds torrc, supervisor and the iptables rules from scratch.
> /var/log/tor/notices.log 2>/dev/null || true
CHECK_INTERVAL=${TOR_CHECK_INTERVAL:-30}
# Strikes before the container is reset. 2 means "one retry": a single failed
# probe is tolerated, a second consecutive failure resets.
MAX_FAILURES=${TOR_MAX_FAILURES:-2}
NOTICES_LOG=${TOR_NOTICES_LOG:-/var/log/tor/notices.log}
TRANS_PORT=${TOR_TRANS_PORT:-9040}
# PID 1 is dumb-init, which tears the container down when it is signalled.
CONTAINER_PID=${TOR_CONTAINER_PID:-1}
sleep 10
tor_process_running() {
supervisorctl status tor 2>/dev/null | grep -q "RUNNING"
}
TIMEOUT=300
ELAPSED=0
while [ $ELAPSED -lt $TIMEOUT ]; do
if grep -q "Bootstrapped 100%" /var/log/tor/notices.log 2>/dev/null; then
echo "$(date): Tor bootstrap complete."
return 0
fi
sleep 5
ELAPSED=$((ELAPSED + 5))
# Show progress
CURRENT=$(tail -n 1 /var/log/tor/notices.log 2>/dev/null | grep -oP 'Bootstrapped \d+%' || echo "waiting...")
echo "$(date): Bootstrap progress: $CURRENT ($ELAPSED/${TIMEOUT}s)"
done
tor_has_bootstrapped() {
grep -q "Bootstrapped 100%" "$NOTICES_LOG" 2>/dev/null
}
echo "$(date): WARNING - Tor bootstrap timed out after ${TIMEOUT}s"
return 1
# Liveness probe for "Tor is still running but no longer usable". Deliberately a
# loopback check against Tor's own TransPort: probing the clear net from here
# would leak and defeat the point of the proxy.
tor_accepts_connections() {
timeout 5 bash -c "exec 3<>/dev/tcp/127.0.0.1/${TRANS_PORT}" 2>/dev/null
}
tor_is_healthy() {
supervisorctl status tor | grep -q "RUNNING" &&
grep -q "Bootstrapped 100%" /var/log/tor/notices.log 2>/dev/null
tor_process_running && tor_has_bootstrapped && tor_accepts_connections
}
reset_container() {
echo "$(date): Tor still unhealthy after ${MAX_FAILURES} checks - resetting container."
kill -TERM "$CONTAINER_PID" 2>/dev/null
exit 1
}
# Never police Tor before it has bootstrapped once. A first bootstrap can
# legitimately take minutes on a slow link, and mistaking that for a failure
# would reset the container into an endless restart loop.
echo "$(date): Waiting for initial Tor bootstrap before monitoring..."
while ! tor_has_bootstrapped; do
sleep "$CHECK_INTERVAL"
done
echo "$(date): Tor bootstrapped - watchdog active (every ${CHECK_INTERVAL}s, ${MAX_FAILURES} strikes)."
FAIL_COUNT=0
while true; do
if tor_is_healthy; then
if [ "$FAIL_COUNT" -ne 0 ]; then
echo "$(date): Tor recovered."
fi
FAIL_COUNT=0
else
FAIL_COUNT=$((FAIL_COUNT+1))
echo "$(date): Healthcheck failed (Count: $FAIL_COUNT)"
echo "$(date): Healthcheck failed (${FAIL_COUNT}/${MAX_FAILURES})"
if [ "$FAIL_COUNT" -ge "$MAX_FAILURES" ]; then
reset_container
fi
echo "$(date): Retrying in ${CHECK_INTERVAL}s before resetting..."
fi
# If failed 3 times in a row, restart Tor
if [ "$FAIL_COUNT" -ge 3 ]; then
echo "$(date): restart trigger - Restarting Tor..."
supervisorctl restart tor
FAIL_COUNT=0
# Wait for it to come back using the dynamic check
wait_for_tor
fi
sleep 30
sleep "$CHECK_INTERVAL"
done
HC
chmod +x /app/tor_healthcheck.sh
@@ -221,8 +240,23 @@ TOR_UID=$(id -u debian-tor)
# Allow loopback
iptables -t nat -A OUTPUT -o lo -j RETURN
# Allow Tor itself to reach the network
iptables -t nat -A OUTPUT -m owner --uid-owner "$TOR_UID" -j RETURN
# Allow Tor itself to reach the network.
#
# The owner match needs the xt_owner kernel module, which some NAS and embedded
# kernels (Synology DSM in particular) do not ship. There iptables rejects the
# rule with "Extension owner revision 0 not supported, missing kernel module?",
# and under `set -e` that aborted the whole script before any routing rule was
# installed, leaving the container in a restart loop. Treat the exemption as
# best-effort so those hosts keep working as they did before it was introduced.
if iptables -t nat -A OUTPUT -m owner --uid-owner "$TOR_UID" -j RETURN 2>/dev/null; then
echo "[✓] Tor process (uid $TOR_UID) exempted from transparent redirect."
else
echo "[!] Warning: this kernel has no iptables owner match (xt_owner module)."
echo "[!] Continuing without the Tor process exemption. Tor keeps using the"
echo "[!] connections it opened while bootstrapping, so traffic is still"
echo "[!] routed through Tor, but if Tor loses its guard relays it may need"
echo "[!] a container restart to recover."
fi
# For UDP DNS queries
iptables -t nat -A OUTPUT -p udp --dport 53 ! -d 127.0.0.1 -j DNAT --to-destination 127.0.0.1:53
Generated
+154 -146
View File
@@ -101,11 +101,11 @@ wheels = [
[[package]]
name = "certifi"
version = "2026.6.17"
version = "2026.7.22"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" }
sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" },
{ url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" },
]
[[package]]
@@ -143,43 +143,37 @@ wheels = [
[[package]]
name = "charset-normalizer"
version = "3.4.7"
version = "3.4.9"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" }
sdist = { url = "https://files.pythonhosted.org/packages/bd/2a/23f34ec9d04624958e137efdc394888716353190e75f25dd22c7a2c7a8aa/charset_normalizer-3.4.9.tar.gz", hash = "sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b", size = 152439, upload-time = "2026-07-07T14:34:58.454Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" },
{ url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" },
{ url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" },
{ url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" },
{ url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" },
{ url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" },
{ url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" },
{ url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" },
{ url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" },
{ url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" },
{ url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" },
{ url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" },
{ url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" },
{ url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" },
{ url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" },
{ url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" },
{ url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" },
{ url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" },
{ url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" },
{ url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" },
{ url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" },
{ url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" },
{ url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" },
{ url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" },
{ url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" },
{ url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" },
{ url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" },
{ url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" },
{ url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" },
{ url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" },
{ url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" },
{ url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" },
{ url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" },
{ url = "https://files.pythonhosted.org/packages/7e/8d/496817fa0944239ecae662dd57ea765cfeaec6a735f9f025d4b7b72e7143/charset_normalizer-3.4.9-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380", size = 317253, upload-time = "2026-07-07T14:33:54.994Z" },
{ url = "https://files.pythonhosted.org/packages/2b/f9/ef4a69ea338ad3c0deceea0f5f7d2380ae8b52132b06d652cb0d2cd86706/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a79d9f4d8001473a30c163556b3c3bfebec837495a412dde78b51672f6134f9", size = 215898, upload-time = "2026-07-07T14:33:56.334Z" },
{ url = "https://files.pythonhosted.org/packages/8c/e7/5ddfd76fc061eb52de219658a4aa431cbacadf0a0219c8854f00da50d289/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33bdcc2a32c0a0e861f60841a512c8acc658c87c2ac59d89e3a46dacf7d866e4", size = 236718, upload-time = "2026-07-07T14:33:57.9Z" },
{ url = "https://files.pythonhosted.org/packages/49/ba/768fa3f36048d81c477a0ce61f813bc1454d80917ccfe550abd9f44f5e24/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f840ed6d8ecba8255df8c42b87fadeda98ddfc6eeec05e2dc66e26d46dd6f58a", size = 232519, upload-time = "2026-07-07T14:33:59.811Z" },
{ url = "https://files.pythonhosted.org/packages/f4/c4/b3e049d2aa3766180c78507110543d9d50894cc97f57de543f1be521dcdc/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c25fe15c70c59eb7c5ce8c06a1f3fa1da0ecc5ea1e7a5922c40fd2fa9b0d5046", size = 223143, upload-time = "2026-07-07T14:34:01.517Z" },
{ url = "https://files.pythonhosted.org/packages/19/79/55c32d06d76ae4feafe053f061f3e3ab70bcf19f4007797ce8c3efda7830/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:f7fb7d750cfa0a070d2c24e831fd3481019a60dd317ea2b39acbcebc08b6ed81", size = 206742, upload-time = "2026-07-07T14:34:03.04Z" },
{ url = "https://files.pythonhosted.org/packages/10/e0/47c079dd82d217c807479cd59ffd30af56307ea31c108b75758970459ad3/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d1c96a7a18b9690a4d46df09e3e3382406ae3213727cd1019ebade1c4a81917", size = 219191, upload-time = "2026-07-07T14:34:04.657Z" },
{ url = "https://files.pythonhosted.org/packages/42/ab/b9bc2e77d6b44a7e46ef62ec5cac1c9a6ba7b9135a5d560f002696ec9995/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a4cfde78a9f2880208d16a93b795726a3017d5977e08d1e162a7a31322479c41", size = 218328, upload-time = "2026-07-07T14:34:06.115Z" },
{ url = "https://files.pythonhosted.org/packages/f1/78/c9c71d599f5aa2d42bcdd35cbbd46d7f535351a57e40ff7d8e5a7e219401/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4d6fcde76f94f5cb9e43e9e9a61f16dacefd228cbbf6f1a09bd9b219a92f1a1", size = 207406, upload-time = "2026-07-07T14:34:07.554Z" },
{ url = "https://files.pythonhosted.org/packages/f6/39/c914445c321a845097ce4f6ac7de9a18228a77b766272125a1ce00d851eb/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:898f0e9068ca27d37f8e83a5b962821df851532e6c4a7d615c1c033f9da6eedf", size = 225157, upload-time = "2026-07-07T14:34:09.061Z" },
{ url = "https://files.pythonhosted.org/packages/9b/f2/c0d4b8508565a36bc5c624e88ed297f5b0b1095011034d7f5b83a69908b5/charset_normalizer-3.4.9-cp314-cp314-win32.whl", hash = "sha256:c1c948747b03be832dceed96ca815cef7360de9aa19d37c730f8e3f6101aca48", size = 151095, upload-time = "2026-07-07T14:34:10.901Z" },
{ url = "https://files.pythonhosted.org/packages/49/fd/a1d26144398c67486422a72bf5812cda22cb4ccfcd95a290fb41ceb4b8e2/charset_normalizer-3.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:16b65ea0f2465b6fb52aa22de5eca612aa964ddfec00a912e26f4656cbef890b", size = 162796, upload-time = "2026-07-07T14:34:12.47Z" },
{ url = "https://files.pythonhosted.org/packages/20/95/d75e82f8ce9fd323ebf059c16c9aadefb22a1ecde13b7840b35835e4886c/charset_normalizer-3.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:40a126142a56b2dfc0aacbad1de8310cbf60da7656db0e6b16eebd48e3e93519", size = 153334, upload-time = "2026-07-07T14:34:14.044Z" },
{ url = "https://files.pythonhosted.org/packages/00/5e/17398df3a139985ba9d11ed072531986f408c8fca952835ef1ab1820c02b/charset_normalizer-3.4.9-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:609b3ba8fcc0fb5ab7af00719d0fb6ad0cb518e48e7712d12fd68f1327951198", size = 338848, upload-time = "2026-07-07T14:34:15.688Z" },
{ url = "https://files.pythonhosted.org/packages/cd/91/7253a32e86b7e1d1239b1b36ba6dd0f021a21107ab33054b53119cc083b9/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51447e9aa2684679af07ca5021c3db526e0284347ebf4ffcec1154c3350cfe32", size = 223022, upload-time = "2026-07-07T14:34:17.248Z" },
{ url = "https://files.pythonhosted.org/packages/cb/32/2e64bd2be10e89c61e57ebe6a93fd98ae88eb7ebe414b5121f22c96c69eb/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cc1b0fff8ead343dae06305f954eb8468ba0ec1a97881f42489d198e4ce3c632", size = 241590, upload-time = "2026-07-07T14:34:18.813Z" },
{ url = "https://files.pythonhosted.org/packages/3d/ef/d96ec496cfea0c21db43b0ad03891308b02388d054cc902cf0e5a1ad6a88/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa36ec09ef71d158186bc79e359ff5fdd6e7996fe8ab638f00d6b93139ba4fcf", size = 239584, upload-time = "2026-07-07T14:34:20.52Z" },
{ url = "https://files.pythonhosted.org/packages/d4/ce/9af95f7876194bd7a14e3dfe4a4de2e0bff02666a3910d72beafd06cc297/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df115d4d83168fdf2cae48ef1ff6d1cb4c466364e30861b37121de0f3bf1b990", size = 230224, upload-time = "2026-07-07T14:34:22.189Z" },
{ url = "https://files.pythonhosted.org/packages/52/94/af74dde74a3996bd959c350709bfe50e297823d70a8c1cbd54b838880863/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f86c6358749bd4fda175388691e3ba8c46e24c5347d0afd20f9b7edfc9faf07d", size = 212667, upload-time = "2026-07-07T14:34:23.857Z" },
{ url = "https://files.pythonhosted.org/packages/ee/f0/f1c4fe746c395922961b5916ed1d7d6e7d4c84851d19ed43cc89980ec953/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:32286a2c8d167e897177b673176c1e3e00d4057caf5d2b64eef9a3666b03018e", size = 227179, upload-time = "2026-07-07T14:34:25.586Z" },
{ url = "https://files.pythonhosted.org/packages/e4/56/6c745619ac397e8871e2bcd3cea1eec86b877488f33888b3aef5c3ed506e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:83aed2c10721ddd90f68140685391b50811a880af20654c59af6b6c66c40513c", size = 225372, upload-time = "2026-07-07T14:34:27.212Z" },
{ url = "https://files.pythonhosted.org/packages/78/ad/98aae8630ac71f16711968e38a5acfecce41b778bf2f0312851020f565a8/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cd6c3d4b783c556fa00bf540854e42f135e2f256abd29669fcd0da0f2dec79c2", size = 215222, upload-time = "2026-07-07T14:34:28.774Z" },
{ url = "https://files.pythonhosted.org/packages/f7/40/9593d54209765207a7f11073c06494c1721e4ca4a0a426c597679bf7f91e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ee2f2a527e3c1a6e6411eb4209642e138b544a2d72fe5d0d76daf77b24063534", size = 231958, upload-time = "2026-07-07T14:34:30.345Z" },
{ url = "https://files.pythonhosted.org/packages/b1/27/693ee5e8a18191eb38647360c51cd505013e2bd3b366aa43fd5344c21e3c/charset_normalizer-3.4.9-cp314-cp314t-win32.whl", hash = "sha256:0d861473f743244d349b50f850d10eb87aeb22bbdcc8e64f79273c94af5a8226", size = 155580, upload-time = "2026-07-07T14:34:31.884Z" },
{ url = "https://files.pythonhosted.org/packages/80/3f/bd97d3d9c613013d07cb7733d299385b41df37f0471310f5a73dc359f0b8/charset_normalizer-3.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:9b8e0f3107e2200b76f6054de99016eac3ee6762713587b36baaa7e4bd2ae177", size = 167620, upload-time = "2026-07-07T14:34:33.438Z" },
{ url = "https://files.pythonhosted.org/packages/3d/c6/eee9dca4439b1061f76373f06ea855678cc4a64c1c3c90b50e479edbb8eb/charset_normalizer-3.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:19ac87f93086ce37b86e098888555c4b4bc48102279bae3350098c0ed664b501", size = 158037, upload-time = "2026-07-07T14:34:35.018Z" },
{ url = "https://files.pythonhosted.org/packages/98/2b/f97f1c193fb855c345d678f5077d6926034db0722df74c8f057020e05a25/charset_normalizer-3.4.9-py3-none-any.whl", hash = "sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5", size = 64538, upload-time = "2026-07-07T14:34:56.993Z" },
]
[[package]]
@@ -360,11 +354,11 @@ wheels = [
[[package]]
name = "filelock"
version = "3.29.6"
version = "3.32.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e0/c8/35bdf04fb30755e2ed758f877edf3eb4a243c2463d3a258cc28b18b7a6e2/filelock-3.29.6.tar.gz", hash = "sha256:895c532ef3f4ef04972b9446a8c4e2931a5c399ff3c4be4c9369f2639b80f793", size = 70301, upload-time = "2026-07-06T23:08:08.577Z" }
sdist = { url = "https://files.pythonhosted.org/packages/c0/80/8232b582c4b318b817cf1274ba74976b07b34d35ef439b3eb948f98645a1/filelock-3.32.0.tar.gz", hash = "sha256:7be2ad23a14607ccc71808e68fe30848aeace7058ace17852f68e2a68e310402", size = 213757, upload-time = "2026-07-21T13:17:42.898Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/bb/49/7467c2946ccd9617f7da38187071bdc45bb9a95df51f4d63d6622432ce4e/filelock-3.29.6-py3-none-any.whl", hash = "sha256:14d5f5597d2e0c4dbd774cfb6d8132da1db44da83732aab679d54f7dcf97ab65", size = 45478, upload-time = "2026-07-06T23:08:07.197Z" },
{ url = "https://files.pythonhosted.org/packages/06/79/b4c714bef36bc4ec2beeae1e0c124f0223888cd8c6feb1cdc56038116920/filelock-3.32.0-py3-none-any.whl", hash = "sha256:d396bea984af47333ef05e50eae7eff88c84256de6112aea0ec48a233c064fe3", size = 97732, upload-time = "2026-07-21T13:17:41.55Z" },
]
[[package]]
@@ -416,7 +410,7 @@ wheels = [
[[package]]
name = "gevent"
version = "26.5.0"
version = "26.7.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi", marker = "platform_python_implementation == 'CPython' and sys_platform == 'win32'" },
@@ -424,26 +418,24 @@ dependencies = [
{ name = "zope-event" },
{ name = "zope-interface" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c4/cb/98aa3a299e2fc4a2372b5d124863e02965b64579ffc29fe54d0641e65b2f/gevent-26.5.0.tar.gz", hash = "sha256:1655eb04c1e20d71b2aa4a3c7528162dd58ff6cc46a037af1f01f534c80fefba", size = 6712354, upload-time = "2026-05-20T21:22:45.132Z" }
sdist = { url = "https://files.pythonhosted.org/packages/9f/5c/92002455a57cb3634383e2b822e3bccf409f43cde34528e46428971475cf/gevent-26.7.0.tar.gz", hash = "sha256:5b333a556e38a302b1b8c80525bef16d437e16f1e7767947789406841856a102", size = 6729213, upload-time = "2026-07-22T20:16:04.713Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/17/55/7d98d3888e7bb9ad4656420dec69232ecbbea48792aff9295d0ad7cf8435/gevent-26.5.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:75a0050e4b87f08ddee7e56f59e6014cd7fcdc3153046c09a847940515d12c85", size = 2968223, upload-time = "2026-05-20T20:13:17.223Z" },
{ url = "https://files.pythonhosted.org/packages/f8/b4/e8e116fcbcb9dc0bf3acc50037f86e1204c217c8ed5defde68be11b3aab6/gevent-26.5.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:fd1a0b83a04e19378d9466ae0ee2b5937cf1d7fbfdcb916b2aea82179a208574", size = 1793926, upload-time = "2026-05-20T21:17:34.321Z" },
{ url = "https://files.pythonhosted.org/packages/28/07/7b267e9754b661defb93542e97731a4df21f8a40dc0f6c853faa717cf124/gevent-26.5.0-cp314-cp314-manylinux_2_28_ppc64le.whl", hash = "sha256:4c964c15076e76391d523ec24202f579a2535f7e301a40efb1656ae046d3eb69", size = 1887632, upload-time = "2026-05-20T21:16:04.158Z" },
{ url = "https://files.pythonhosted.org/packages/5c/50/b47d29e99449bd13b557ffa451401dc13d397a9923f562ef90a4e8514502/gevent-26.5.0-cp314-cp314-manylinux_2_28_s390x.whl", hash = "sha256:45d5438d1c84da5df7e832434627624709543630977332bb4e2d05ecca362cc9", size = 1838688, upload-time = "2026-05-20T21:30:57.979Z" },
{ url = "https://files.pythonhosted.org/packages/8b/eb/5b54ccff11bc7d7bebd40a24571ccc115d5cdae4f6c32ab457b43b436e42/gevent-26.5.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:354f35924113abc954819216c2a6ee16751958c615681e0490946e31b437bd2f", size = 2120351, upload-time = "2026-05-20T20:35:32.699Z" },
{ url = "https://files.pythonhosted.org/packages/9c/70/30fd325c30e04b1e5174c61945e17421d53ddb2450366cc52cef234f8c4b/gevent-26.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a47cd2d32f6404212d374ad8014a3491d7477dcf0cc09c5a2308ad6d325fd663", size = 1806684, upload-time = "2026-05-20T21:16:43.87Z" },
{ url = "https://files.pythonhosted.org/packages/cd/e8/fbf911ac3f9524ecfaed174d100fde671904ab8db92ceaf07faaebd13386/gevent-26.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:032157cebdedb84f2f52cdd980f2f5f2623eed6a8f083aadf44b44c47f628642", size = 2146606, upload-time = "2026-05-20T20:43:32.216Z" },
{ url = "https://files.pythonhosted.org/packages/9e/4d/284fcbbfde66fd978c2980c1fbe0eabd586af6e4b728649e9cf459e8b38f/gevent-26.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:9c414935ba5fc88359110968851d3616f119082c937390d00a1c0f4f59be814f", size = 1722497, upload-time = "2026-05-20T20:16:44.274Z" },
{ url = "https://files.pythonhosted.org/packages/15/d2/9f66eb53434704402be0ba733bf3320bf589671a4b76fac52a7d6077e972/gevent-26.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:2a0f5993a04b95a35b3a118b1a58ba272833f9b547b774001dea29f90620882f", size = 1574249, upload-time = "2026-05-20T20:15:50.873Z" },
{ url = "https://files.pythonhosted.org/packages/dc/d5/b4c50adb761878e3c96642b9f79bf44cee3120f3df55cd40876f51d89866/gevent-26.5.0-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:2e117df896a2660c9ebd4e2b5afc02dfd6e2ddf9b495e787e67c72d105432b09", size = 2971993, upload-time = "2026-05-20T20:12:50.845Z" },
{ url = "https://files.pythonhosted.org/packages/03/83/71c2a945e80198422d1d93dbe67355f249fb456b451bf9201199d3ef6a1a/gevent-26.5.0-cp315-cp315-manylinux_2_28_aarch64.whl", hash = "sha256:af5ffe9c11ffb8a39b6bef2e8b722aa2043ae4980977915c6aa8c68b4bc26e46", size = 1796658, upload-time = "2026-05-20T21:17:35.968Z" },
{ url = "https://files.pythonhosted.org/packages/42/96/548ca77aed5cb9a44e855a6c23ebceeb3554a0ea9ca0c01c311878899a3e/gevent-26.5.0-cp315-cp315-manylinux_2_28_ppc64le.whl", hash = "sha256:7da34aef7e87c43dd3662e5785e79ed505c01399a7cb42876d2d8925969fd75f", size = 1891473, upload-time = "2026-05-20T21:16:05.657Z" },
{ url = "https://files.pythonhosted.org/packages/f6/4f/f48bd47d5287afb0fbcc56165f3ed47583f1803bad401653fe27e71ade2d/gevent-26.5.0-cp315-cp315-manylinux_2_28_s390x.whl", hash = "sha256:1c6293a7046bcc6f3d8972a74b19cd7a4cfd02d3881edf0fcf827aa514bd247b", size = 1841429, upload-time = "2026-05-20T21:30:59.907Z" },
{ url = "https://files.pythonhosted.org/packages/a0/72/1925215fc720d2561fa3ec8d4af5f098f8d0cbfa76a45fafed6e5ade7718/gevent-26.5.0-cp315-cp315-manylinux_2_28_x86_64.whl", hash = "sha256:d3bde0f140a275b2fa88e4b6516bda85551930e10bc2fd95e18c1b7d11cb780c", size = 2123895, upload-time = "2026-05-20T20:35:34.964Z" },
{ url = "https://files.pythonhosted.org/packages/83/59/0f584f6b1170c9a6abd9b70ccf5e9cc5ead34eabafabc0e21876ef0fe6f7/gevent-26.5.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:e29fb4b17d9958ec8cb7f6339a111b29bc23f2c2efbef86189d1248bb4862d17", size = 1809047, upload-time = "2026-05-20T21:16:45.977Z" },
{ url = "https://files.pythonhosted.org/packages/82/88/61e854bfd98ac22eac78a97fc6db10de0f9ace46514072b435c217168729/gevent-26.5.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:b2239df2f7570efa03736678f3f053bb1bdd22a8a16cd28a2feb7d32ea5f533f", size = 2150764, upload-time = "2026-05-20T20:43:33.781Z" },
{ url = "https://files.pythonhosted.org/packages/b9/f5/af048b97433d7f9a7df7f5510b2c46918b7d073dcfb3bf6d0ef0e5a83dcc/gevent-26.5.0-cp315-cp315-win_amd64.whl", hash = "sha256:aae214952fd38d27a42dc416bb70193962ec932384b63445d29bbb5817a1c042", size = 1722600, upload-time = "2026-05-20T20:19:56.81Z" },
{ url = "https://files.pythonhosted.org/packages/11/95/fb74a2299c6a2d78d9de12deaaac640ab5d2ef96a8e0f97a3ff84b9ca84b/gevent-26.5.0-cp315-cp315-win_arm64.whl", hash = "sha256:f7067564f139e33bf26a31ee3b13d168d76eb99a44b85ced626652b158baa80c", size = 1574406, upload-time = "2026-05-20T20:17:12.125Z" },
{ url = "https://files.pythonhosted.org/packages/c1/bb/ab60d496cbdc0293ebbd6c2070b34da0632bd7a2ca20163c17e18d2d2dc9/gevent-26.7.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:0e0e3bf7ae0f82dbc5c6be26b4781e86c97f1e28d516b7a9746ac8b04bcc6948", size = 2992503, upload-time = "2026-07-22T16:24:36.503Z" },
{ url = "https://files.pythonhosted.org/packages/5c/35/75f27c06a82a5b22600aaccbd9567d89bb4091be43e96c02981f10aff23d/gevent-26.7.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:740050b53048207b080a1e183a377c47809ad0b7b7b0cd7eab0dea1045f7e480", size = 1809173, upload-time = "2026-07-22T18:11:30.724Z" },
{ url = "https://files.pythonhosted.org/packages/5b/5f/a6b32b4db3fa76bd8a070f0f46f5306123bf6336e7a0ca0cd2f9b99473df/gevent-26.7.0-cp314-cp314-manylinux_2_28_ppc64le.whl", hash = "sha256:67983607eb6c7bafa362c5c43b69a27145b936c34a3d6441ed42413d62fae0a6", size = 1906630, upload-time = "2026-07-22T18:10:45.836Z" },
{ url = "https://files.pythonhosted.org/packages/e1/87/832495d8fcc05ff7432f038b7c4decbd5632425a2cd5da2ce73cb2d800c4/gevent-26.7.0-cp314-cp314-manylinux_2_28_s390x.whl", hash = "sha256:475848518d708e07d1987c3d94cb8ff53e2b3a69df32e39feda2779cafe400b0", size = 1855278, upload-time = "2026-07-22T18:29:11.517Z" },
{ url = "https://files.pythonhosted.org/packages/72/8b/2f36c0fa389fa2b7ceb5a8972b0e7da7bc770f9135315cf4246c607ca5fc/gevent-26.7.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:0f8ed457dd616bfe6682569f92730f9ab45aafb1aeca5e80eb2f6b9a2ce26d11", size = 2136155, upload-time = "2026-07-22T16:48:33.865Z" },
{ url = "https://files.pythonhosted.org/packages/b5/98/09f2cfaa23dbce48e3271e95b0d003f93acece6b5cfd40f4cebe3850d79b/gevent-26.7.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:15373c68cf1fa14114bec2f09b16e2c65374bd5309e897e0a28740b09ce329e0", size = 1822108, upload-time = "2026-07-22T18:07:15.397Z" },
{ url = "https://files.pythonhosted.org/packages/b0/d8/05a294165c17569f04284ad3c889684c8780544885b4cdf77b1432947d0c/gevent-26.7.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:73f3d53f2f390369e290c933b75bd87f1f2261f2f2f2175aa667c43ee3049bad", size = 2162814, upload-time = "2026-07-22T17:02:18.066Z" },
{ url = "https://files.pythonhosted.org/packages/59/89/58a545c4eda33e106d6887a0387adc2249abc14c779e3eb88bbfdf3768d6/gevent-26.7.0-cp314-cp314-win_amd64.whl", hash = "sha256:f11b558d544ad2249029ba023cd6519ec3a0eee54a3d027e6515c1eaa322422a", size = 1706971, upload-time = "2026-07-22T16:27:02.263Z" },
{ url = "https://files.pythonhosted.org/packages/bb/cd/413f293e54961e5c89c54235370e3603ec0f561e7ace8357980410efbf78/gevent-26.7.0-cp314-cp314-win_arm64.whl", hash = "sha256:3871f4ca59ec2328c3ef638a0fe01a28a825443a133368dc78eb5ceadcad7609", size = 1585078, upload-time = "2026-07-22T16:30:48.145Z" },
{ url = "https://files.pythonhosted.org/packages/21/3a/47f29f632aaa38aa12410f57f1732fc50bfd4d4006d2e7e022ce731cabc9/gevent-26.7.0-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:3e3d6e20a94239ad353b776e72b8ce18c35dbe4e98c279aef3932651553d8404", size = 2996208, upload-time = "2026-07-22T16:23:23.859Z" },
{ url = "https://files.pythonhosted.org/packages/b8/b3/4620f1ce81ecec9890229806c73f07dd022e40f76a552bd430391e7316c4/gevent-26.7.0-cp315-cp315-manylinux_2_28_aarch64.whl", hash = "sha256:ddbd3cc76b9bc69df651a216c2a62fc6415ad463b3ac9c6cbbbb8b7b8224af17", size = 1811545, upload-time = "2026-07-22T18:11:32.224Z" },
{ url = "https://files.pythonhosted.org/packages/97/fd/d285212ffd5585d511299e13e61d76262def8e826e9f20c92cb85df406f2/gevent-26.7.0-cp315-cp315-manylinux_2_28_ppc64le.whl", hash = "sha256:01ceab7e608dc1b9859d9511a0a29d7ce2e7d909ab19fddc860e70a2ed5b10ce", size = 1910418, upload-time = "2026-07-22T18:10:47.709Z" },
{ url = "https://files.pythonhosted.org/packages/2d/e0/c5d666e6065652918cfb6e6a3cf8f721d0e152c57cec217ad81792a1323b/gevent-26.7.0-cp315-cp315-manylinux_2_28_s390x.whl", hash = "sha256:2e6c917b2b8baeb6080797a6b25e35e1fd784319a05bb92b87c53546e5578eb2", size = 1857891, upload-time = "2026-07-22T18:29:13.13Z" },
{ url = "https://files.pythonhosted.org/packages/a1/67/e945ed458fa98b34572876bfd0d35fe4fa3f1159f43660b71d982b7cb63e/gevent-26.7.0-cp315-cp315-manylinux_2_28_x86_64.whl", hash = "sha256:df75a1748b26030f2f7f10042cc45640b22954d9d0dc6b4b6f0dbe0b6751a2d4", size = 2138121, upload-time = "2026-07-22T16:48:35.358Z" },
{ url = "https://files.pythonhosted.org/packages/99/55/622468fa1a3c4cf51f20e14813f5cc1592fe6e44a42ccca9195a0b18c769/gevent-26.7.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:ee1b389587e5d5c1eb19d0455b5b4d7a0fb5c5287af4e226ec66d9dfd2548107", size = 1825114, upload-time = "2026-07-22T18:07:16.667Z" },
{ url = "https://files.pythonhosted.org/packages/0f/7a/151a2afcacf487ca25faf8b1bdd6c5b4ace2f7c1e6b4eaffe0a5e6e1df61/gevent-26.7.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:c2918641ba756f46aa01ab9dd82d6dfceec403c77c2787298746b411dcf0288e", size = 2165990, upload-time = "2026-07-22T17:02:19.817Z" },
]
[[package]]
@@ -739,11 +731,11 @@ wheels = [
[[package]]
name = "platformdirs"
version = "4.10.0"
version = "4.11.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d7/47/e4501f49c178ae1d9f4a75073fda4204f52647993f075a9db4d14930e0c5/platformdirs-4.10.0.tar.gz", hash = "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7", size = 31224, upload-time = "2026-05-28T03:32:53.587Z" }
sdist = { url = "https://files.pythonhosted.org/packages/78/9b/560e4be8e26f6fd133a03630a8df0c663b9e8d61b4ade152b72005aec83b/platformdirs-4.11.0.tar.gz", hash = "sha256:0555d18370482847566ffabcaa53ad7c6c1c29f195989ae1ed634a05f76ea1e0", size = 31953, upload-time = "2026-07-21T13:09:36.565Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/81/e6/cd9575ac904136b3cbf7aa7ee819ef86eedb7274e46f230e94ea4342e729/platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a", size = 22743, upload-time = "2026-05-28T03:32:52.175Z" },
{ url = "https://files.pythonhosted.org/packages/7d/68/d8d58938dfb1370b266a1a729e6d77a985be23689a0496498ee17b2cbf90/platformdirs-4.11.0-py3-none-any.whl", hash = "sha256:360ccded2b7fce0af0ff80cc8f5942a1c5d99b0e856033acb030bfc634709e74", size = 23247, upload-time = "2026-07-21T13:09:35.422Z" },
]
[[package]]
@@ -757,26 +749,26 @@ wheels = [
[[package]]
name = "prek"
version = "0.4.8"
version = "0.4.11"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/8e/46/e436a6eb9fdb4d3fd08d0ab7fdba19fe03a9e994ec810de57869b853bd8e/prek-0.4.8.tar.gz", hash = "sha256:d15d8bef72ab7b02c7dc01458ac9e05b3131534492b5ce9bb11c4f6f636fa868", size = 494570, upload-time = "2026-07-04T12:05:10.941Z" }
sdist = { url = "https://files.pythonhosted.org/packages/c4/1a/73b6dae5ce7e997cb35a69bfe1d25a798e85fa3d2eabf95324563f461b30/prek-0.4.11.tar.gz", hash = "sha256:4a14cb9bbae850605ae3904fbdbb12f0e00c12455efaa2266da8fb8e5c0350d7", size = 516254, upload-time = "2026-07-24T17:05:35.107Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5c/78/b4149c8913ced2e42debb49e261c4788a1ce431e84226921c2e1a7ea8545/prek-0.4.8-py3-none-linux_armv6l.whl", hash = "sha256:1f8f8cdc65836b571824c965daebb81b449f7e4a43894c58621f5708d5a185ed", size = 5668955, upload-time = "2026-07-04T12:04:41.588Z" },
{ url = "https://files.pythonhosted.org/packages/76/5f/7f54a0087b6b2f1751aeb41266d9c15e66fd0055492814798ab818cd0414/prek-0.4.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:bce1798e96d9e3a6e6abf435da7107e81452f69edb3ca7c6f90a457355ea46e2", size = 6030947, upload-time = "2026-07-04T12:04:43.8Z" },
{ url = "https://files.pythonhosted.org/packages/6c/d6/f2829fc3902920c36b764a386fa303e71a8219dac25cb3827c575e84199a/prek-0.4.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:ab3a52db17254d701c3cebb7eea58c8230aa7c1959aacfd5b5f25de18edb15d1", size = 5572593, upload-time = "2026-07-04T12:04:45.763Z" },
{ url = "https://files.pythonhosted.org/packages/74/8c/c5589955bcd5e3e33b67d8bc3110818cecac82a38fd6bc8b5dfdc5de421c/prek-0.4.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:b3fcfd620523bbc3f51a21d7cd63449f659b9e2cf3582de12dd5949e23227b8f", size = 5847150, upload-time = "2026-07-04T12:04:47.419Z" },
{ url = "https://files.pythonhosted.org/packages/2d/9d/1f2dc91bdb79d2c4714b27eac9477a51490fba5b4731330dbbebc76bd345/prek-0.4.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:42e65bc8425e9d7f1691a13ca1da2e07807d1ba76c35740833354b945131689e", size = 5573738, upload-time = "2026-07-04T12:04:49.125Z" },
{ url = "https://files.pythonhosted.org/packages/81/29/69a7b58e16ecbc5f3989bf4b028018d11a82dcdd320b93d6588d72f32aa7/prek-0.4.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f578492a8e0c9bc6b4bf6dfbba8716f647d4cd0769bf10ad6cf336e3096fd392", size = 5981054, upload-time = "2026-07-04T12:04:50.842Z" },
{ url = "https://files.pythonhosted.org/packages/63/cc/9b9850a60c22ed18c7755ebd2d72c6eefb37fac58149d09f6adc4691c2cf/prek-0.4.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4335f9d5beb123a3884a7fe34f57c9f0828f4fbb7666beab4298833459b104f", size = 6751350, upload-time = "2026-07-04T12:04:52.529Z" },
{ url = "https://files.pythonhosted.org/packages/01/e5/c425aa7272b430630119e6757def3a2007555ba8cbeb2630e0448e7a8b7f/prek-0.4.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:18a8747df9c602e052881d3efb14dd7f7d62a59bd7277ae5171c9e7661d59d84", size = 6243881, upload-time = "2026-07-04T12:04:54.703Z" },
{ url = "https://files.pythonhosted.org/packages/1c/da/accd3ad07fd2891d3c2777eb42435439fdf11982c51d60f087c0b6b6e102/prek-0.4.8-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:4db639db481d5f854eff9b3d2108889e613b8c15868bcf6bdd777c7cee577436", size = 5848846, upload-time = "2026-07-04T12:04:56.402Z" },
{ url = "https://files.pythonhosted.org/packages/15/00/3477704635249f21f5f98ce444cd7690c2aa9dc8d146a045db88ef2cd8c5/prek-0.4.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:c3890a6f92316d2cf44eb50584e8d2b23a596dd70487022e61186a71a2ac0900", size = 5713942, upload-time = "2026-07-04T12:04:58.311Z" },
{ url = "https://files.pythonhosted.org/packages/fb/e6/3ca4fabaebeadc976d9a92d1d9130674265355ea3b728418bad61583b097/prek-0.4.8-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:fc7e15c24c591a37c6ffce5b25a021b16c299ac2649f183d812b67d665cd6551", size = 5554725, upload-time = "2026-07-04T12:04:59.96Z" },
{ url = "https://files.pythonhosted.org/packages/a5/46/2ab6aaaeff0cedb8955b2e4032071c8712382bdd423bb849718c3720180d/prek-0.4.8-py3-none-musllinux_1_1_i686.whl", hash = "sha256:36fe721704ff0c7624c1167639e23a5fe658bfd38c314f487219c9afd1eeb733", size = 5838595, upload-time = "2026-07-04T12:05:01.861Z" },
{ url = "https://files.pythonhosted.org/packages/ae/8b/91398f2b6cd1629d5d8ca8c85b08eca500814a374313b0193f4aaf6ab6c4/prek-0.4.8-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:162e544abc394a8124f3a4ad68efee116bad09440e679dbd1675177335c2a432", size = 6357222, upload-time = "2026-07-04T12:05:03.845Z" },
{ url = "https://files.pythonhosted.org/packages/b2/2a/ce5cbfaad36866134a21754640a05ecdba641fcd7ad15aa74cf3443f34f6/prek-0.4.8-py3-none-win32.whl", hash = "sha256:2602e46c8c5da7dfa69f60fcf88c2b57132ac623f49fb08bfb3094298c5f07e3", size = 5354388, upload-time = "2026-07-04T12:05:05.587Z" },
{ url = "https://files.pythonhosted.org/packages/df/03/3bc908bc5f7e430315553e47dfa055f19923a3888f9afe4da19f244b5cbf/prek-0.4.8-py3-none-win_amd64.whl", hash = "sha256:7cb22da60bee41b89c4978c0bea7126a3c0ccc003dae6748cf29b53947815edc", size = 5748221, upload-time = "2026-07-04T12:05:07.559Z" },
{ url = "https://files.pythonhosted.org/packages/dd/a7/4295e6d5f5028171dfeb115ad38ab76bf3fe0c8df91b70d73c79aa760a94/prek-0.4.8-py3-none-win_arm64.whl", hash = "sha256:da70057f577b15d4bd121bf9dd29ee205fd4b4d75a0cafba062e84d7e8b4378b", size = 5574425, upload-time = "2026-07-04T12:05:09.595Z" },
{ url = "https://files.pythonhosted.org/packages/8e/d7/a00b2de492a80e99b1698e72c1d196ac3ed544dc7ee0ada261ac066e78e0/prek-0.4.11-py3-none-linux_armv6l.whl", hash = "sha256:3830cb7cc47e837888b8b464ecb21355a69235cfacef0fd89101e17f09345d63", size = 5770511, upload-time = "2026-07-24T17:05:12.829Z" },
{ url = "https://files.pythonhosted.org/packages/c1/1e/f97c74defcd5d5645888cb99fcdd9b8b48cc7247cf31e64414d106d56d66/prek-0.4.11-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:45facadf9c2332b28e6ab2744312ae0275a93ab5a437da8bcc53a8c7260cb4b0", size = 6118049, upload-time = "2026-07-24T17:05:14.451Z" },
{ url = "https://files.pythonhosted.org/packages/d9/92/8367d26421ee6fe6019a63928fb0ed31179cd0d6199879c524f89ef4c95c/prek-0.4.11-py3-none-macosx_11_0_arm64.whl", hash = "sha256:2f8a194b1d00d24dff8baff691c99e2668339da2da3482b4ee99c1a0f2409378", size = 5601478, upload-time = "2026-07-24T17:05:15.81Z" },
{ url = "https://files.pythonhosted.org/packages/e1/6f/7617c9b87afaadede4167720aae7ef12d7e51167db903bc8fbac0cadef7b/prek-0.4.11-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:21d93e6d76bf3d7a9bb70c6ac86ef372adaee50068c45749e6b9e1c2ac4ec939", size = 5932071, upload-time = "2026-07-24T17:05:17.217Z" },
{ url = "https://files.pythonhosted.org/packages/ef/41/6796a4011b04212333259064aa885a71d03d9581ba9bff52db3ce58d1f06/prek-0.4.11-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7fb07cde2d2156efa6980122b3f13dc88f20c84b933c6205675d0f1e7be2cde8", size = 5677617, upload-time = "2026-07-24T17:05:18.658Z" },
{ url = "https://files.pythonhosted.org/packages/03/5f/3e339901f8460b6073313619b4fd9bf4135e48430695c53640b83ace88ea/prek-0.4.11-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6b7f5e446d2aca739bd18b380578e9c78bb4c086299abc3e167d51c47840c56f", size = 6106370, upload-time = "2026-07-24T17:05:20.219Z" },
{ url = "https://files.pythonhosted.org/packages/8e/4e/94e24b5c1910ec15692ecaf33d0d8ef0d02a02a8b5e40d3c976396880cba/prek-0.4.11-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b7059d640e595d098600e2d97af961f46292b4112670edbe632de84f6828389e", size = 6884342, upload-time = "2026-07-24T17:05:21.587Z" },
{ url = "https://files.pythonhosted.org/packages/a2/7c/fc0daa033dcafe74990c00af2da1e16922790b9c1b8da7e8eebf1123838b/prek-0.4.11-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:85a4df33998fcac878bce3b2c624e1caeb9609f3d562c640702c1234ed815daf", size = 6331365, upload-time = "2026-07-24T17:05:22.87Z" },
{ url = "https://files.pythonhosted.org/packages/d7/36/49f152b8f539930e9685cff0509695ce4054abcecc22f4c16c4e1e5c23d0/prek-0.4.11-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:866991f387527c5f880ce2cc3ddea9b33cc5b986881f8c7f92524cf0969c1350", size = 5939075, upload-time = "2026-07-24T17:05:24.249Z" },
{ url = "https://files.pythonhosted.org/packages/4f/fe/7b097af9161edae7ddedcc9f5cda4a5f31346a492ce3f92583d96c46f628/prek-0.4.11-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:247e5d8740e137ebdf24fa96f01a95b2d2f1892e956a1ab00c7b1474a59ebcc0", size = 5799029, upload-time = "2026-07-24T17:05:25.652Z" },
{ url = "https://files.pythonhosted.org/packages/49/63/dc955ff99e1002d3cd375b21467c87046bc41deddfce86d898240757b151/prek-0.4.11-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:603ba9f2fd9d666dddb3ae190a25a5c55091b843cde90ed52e0a1116f50d4062", size = 5651211, upload-time = "2026-07-24T17:05:27.027Z" },
{ url = "https://files.pythonhosted.org/packages/8f/b8/bf2139ec25eefb5afef43afac7620c5181f2c2661f220598beacb1771207/prek-0.4.11-py3-none-musllinux_1_1_i686.whl", hash = "sha256:f2682ece3c5fc7201106c4fdd84b0587ccea4b8a2ecefa3e94d3074d2841f1df", size = 5954784, upload-time = "2026-07-24T17:05:28.391Z" },
{ url = "https://files.pythonhosted.org/packages/fc/29/3fe5990aee1bd7c4d50a03358ad867c5e912d9510aafb83a359c7347e5fa/prek-0.4.11-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:22721d30394192931fecc80d6fc6dd47e8ddf8db8b9693805aba8ec0f50087ec", size = 6448916, upload-time = "2026-07-24T17:05:29.837Z" },
{ url = "https://files.pythonhosted.org/packages/0f/fb/abddacf43738302242ecd237236c7c80cd7c1d27545e16e803770aee76e9/prek-0.4.11-py3-none-win32.whl", hash = "sha256:8b093e7624522146049e994d5cf283d01d71632b638620b79ae0f96afeaa2624", size = 5483539, upload-time = "2026-07-24T17:05:31.228Z" },
{ url = "https://files.pythonhosted.org/packages/00/1e/c293f7a15cb93963c4be36a02e144b93f43906bc02644a3f04e5708e7453/prek-0.4.11-py3-none-win_amd64.whl", hash = "sha256:5a3d7c80b970b456e5f1bcec8382008ee1ae6a3f324a6b9bb4ff7e666ab0f3c4", size = 5861119, upload-time = "2026-07-24T17:05:32.479Z" },
{ url = "https://files.pythonhosted.org/packages/cd/0c/05fe6eb9d6a54d0e02dfa8cc5ad6f86869bf953419ec15909a32466a28ee/prek-0.4.11-py3-none-win_arm64.whl", hash = "sha256:e7b0df37ce05e45a14a9da39ab104691474d72f139bf4f6c860f754763a322cb", size = 5626386, upload-time = "2026-07-24T17:05:33.813Z" },
]
[[package]]
@@ -1127,25 +1119,25 @@ wheels = [
[[package]]
name = "qbittorrent-api"
version = "2026.6.1"
version = "2026.7.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "packaging" },
{ name = "requests" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ed/a8/ea2ab39fdb5ef4408ac71a3329af9646c8a47d54c35a9138fa4d355f22dc/qbittorrent_api-2026.6.1.tar.gz", hash = "sha256:57d74ff2e710286973673e1268878826e9c7e5f471e632d7a8f059ef20f83fd4", size = 1428879, upload-time = "2026-06-27T00:03:19.996Z" }
sdist = { url = "https://files.pythonhosted.org/packages/b4/49/73123503771d0610a27bf2e5c73995ddce15e8d8cac6babef8eef51097bb/qbittorrent_api-2026.7.0.tar.gz", hash = "sha256:9f0a6e33ee4dba290d155de3ad59c191de02e4d9639d424bf02ec4a00ecf3205", size = 1427366, upload-time = "2026-07-08T00:54:39.78Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3a/2f/d330c335e2ef3974ac940ee38e6325af4bea7515688496ae8d2d5b6aa49a/qbittorrent_api-2026.6.1-py3-none-any.whl", hash = "sha256:9e9e4b64383c463e716825e7b447b759c2c047d23e1399ed7a66d37f4aa9b994", size = 69432, upload-time = "2026-06-27T00:03:18.565Z" },
{ url = "https://files.pythonhosted.org/packages/40/79/e1ee523dd8b14a4e8b8ddd474a93803855b8170a10240a65997c21d69fe2/qbittorrent_api-2026.7.0-py3-none-any.whl", hash = "sha256:189ff96c2c08f999d82b3b4970c4d3e0da1c32ae9c36e9da2fdec7847e5f37a5", size = 69450, upload-time = "2026-07-08T00:54:38.377Z" },
]
[[package]]
name = "rarfile"
version = "4.2"
version = "4.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/26/3f/3118a797444e7e30e784921c4bfafb6500fb288a0c84cb8c32ed15853c16/rarfile-4.2.tar.gz", hash = "sha256:8e1c8e72d0845ad2b32a47ab11a719bc2e41165ec101fd4d3fe9e92aa3f469ef", size = 153476, upload-time = "2024-04-03T17:10:53.798Z" }
sdist = { url = "https://files.pythonhosted.org/packages/09/84/a00a5be12adbe5abb0094513ad6d3aefc80fe5dfa4a5604e22561d4b7d3b/rarfile-4.4.tar.gz", hash = "sha256:b7d29d671223cdc80e8b992df212d152ad88e6c961d4426c3fb2fefe8dd86fdf", size = 155437, upload-time = "2026-07-22T16:19:15.533Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/62/fc/ab37559419ca36dd8dd317c3a98395ed4dcee2beeb28bf6059b972906727/rarfile-4.2-py3-none-any.whl", hash = "sha256:8757e1e3757e32962e229cab2432efc1f15f210823cc96ccba0f6a39d17370c9", size = 29052, upload-time = "2024-04-03T17:10:52.632Z" },
{ url = "https://files.pythonhosted.org/packages/e2/06/7135da7d7d662767aee9aeebaa360f4ad29f08d9198cc1b1632428114acd/rarfile-4.4-py3-none-any.whl", hash = "sha256:befb813c283fbcdd46dda2bad090f8272c8bb1de246d04cd570e9d890294d119", size = 29932, upload-time = "2026-07-22T16:19:14.559Z" },
]
[[package]]
@@ -1205,27 +1197,27 @@ wheels = [
[[package]]
name = "ruff"
version = "0.15.20"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/43/dc/35b341fc554ba02f217fc10da57d1a75168cfbcf75b0ef2202176d4c4f2d/ruff-0.15.20.tar.gz", hash = "sha256:1416eb04349192646b54de98f146c4f59afe37d0decfc02c3cbbf396f3a28566", size = 4755489, upload-time = "2026-06-25T17:20:37.578Z" }
sdist = { url = "https://files.pythonhosted.org/packages/4d/94/1e5e4967626faf12fa56999cd6222dff6992ceb086ad7945756baf70c7a7/ruff-0.16.0.tar.gz", hash = "sha256:e460aafd5495ec89efaa6ced2e4a9a581116451e1c88b9d37ef497e0f8e93982", size = 4790557, upload-time = "2026-07-23T19:11:30.981Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/94/d9/2d5014f0253ba541d2061d9fa7193f48e941c8b21bb88a7ff9bbe0bd0596/ruff-0.15.20-py3-none-linux_armv6l.whl", hash = "sha256:00e188c53e499c3c1637f73c91dcf2fb56d576cab76ce1be50a27c4e80e37078", size = 10839665, upload-time = "2026-06-25T17:19:44.702Z" },
{ url = "https://files.pythonhosted.org/packages/c6/d3/ac1798ba64f670698867fcfc591d50e7e421bef137db564858f619a30fcf/ruff-0.15.20-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9ebd1fd9b9c95fc0bd7b2761aebec1f030013d2e193a2901b224af68fe47251b", size = 11208649, upload-time = "2026-06-25T17:19:48.787Z" },
{ url = "https://files.pythonhosted.org/packages/47/47/d3ac899991202095dfcf3d5176be4272642be3cf981a2f1a30f72a2afb95/ruff-0.15.20-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5b16cdd67ca108185cd36dce98c576350c03b1660a751de725fb049193a0632", size = 10622638, upload-time = "2026-06-25T17:19:51.354Z" },
{ url = "https://files.pythonhosted.org/packages/33/13/4e043fe30aa94d4ff5213a9881fc296d12960f5971b234a5263fdc225312/ruff-0.15.20-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3413bb3c3d2ca6a8208f1f4809cd2dca3c6de6d0b491c0e70847672bde6e6efd", size = 10984227, upload-time = "2026-06-25T17:19:54.044Z" },
{ url = "https://files.pythonhosted.org/packages/76/e6/92e7bf40388bc5800073b96564f56264f7e48bfd1a498f5ced6ae6d5a769/ruff-0.15.20-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd7ec42b3bb3da066488db093308a69c4ac5ee6d2af333a86ba6e2eb2e7dd44b", size = 10622882, upload-time = "2026-06-25T17:19:57.037Z" },
{ url = "https://files.pythonhosted.org/packages/13/7a/43460be3f24495a3aa46d4b16873e2c4941b3b5f0b00cf88c03b7b94b339/ruff-0.15.20-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e1a36ad0eb77fba9aabfb69ede54de6f376d04ac18ebea022847046d340a8267", size = 11474808, upload-time = "2026-06-25T17:20:00.357Z" },
{ url = "https://files.pythonhosted.org/packages/27/a0/f37077884873221c6b33b4ab49eb18f9f88e54a16a25a5bca59bef46dd66/ruff-0.15.20-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b6df3b1e4610432f0386dba04d853b5f08cbbc903410c6fcc02f620f05aff53c", size = 12293094, upload-time = "2026-06-25T17:20:03.446Z" },
{ url = "https://files.pythonhosted.org/packages/a6/74/165545b60256a9704c21ac0ec4a0d07933b320812f9584836c9f4aca4292/ruff-0.15.20-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e89f198a1ea6ef0d727c1cf16088bc91a6cb0ab947dedc966715691647186eae", size = 11526176, upload-time = "2026-06-25T17:20:06.301Z" },
{ url = "https://files.pythonhosted.org/packages/86/b1/a976a136d40ade83ce743578399865f57001003a409acadc0ecbb3051082/ruff-0.15.20-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:309809086c2acb67624950a3c8133e80f32d0d3e27106c0cd60ff26657c9f24b", size = 11520767, upload-time = "2026-06-25T17:20:09.191Z" },
{ url = "https://files.pythonhosted.org/packages/19/0f/f032696cb01c9b54c0263fa393474d7758f1cdc021a01b04e3cbc2500999/ruff-0.15.20-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2d2374caa2f2c2f9e2b7da0a50802cfb8b79f55a9b5e49379f564544fbf56487", size = 11500132, upload-time = "2026-06-25T17:20:13.602Z" },
{ url = "https://files.pythonhosted.org/packages/4b/f4/51b1a14bc69e8c224b15dab9cce8e99b425e0455d462caa2b3c9be2b6a8e/ruff-0.15.20-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a1ed17b65293e0c2f22fc387bc13198a5de94bf4429589b0ff6946b0feaf21a3", size = 10943828, upload-time = "2026-06-25T17:20:16.635Z" },
{ url = "https://files.pythonhosted.org/packages/71/4b/fe267640783cd02bf6c5cc290b1df1051be2ec294c678b5c15fe19e52343/ruff-0.15.20-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f701305e66b38ea6c91882490eb73459796808e4c6362a1b765255e0cdcd4053", size = 10645418, upload-time = "2026-06-25T17:20:19.4Z" },
{ url = "https://files.pythonhosted.org/packages/b0/c0/a65aa4ec2f5e87a1df32dc3ec1fede434fe3dfd5cbcf3b503cafc676ab54/ruff-0.15.20-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5b9c0c367ad8e5d0d5b5b8537864c469a0a0e55417aadfbeca41fa61333be9f4", size = 11211770, upload-time = "2026-06-25T17:20:22.033Z" },
{ url = "https://files.pythonhosted.org/packages/5a/a4/0caa331d954ae2723d729d351c989cb4ca8b6077d5c6c2cb6de75e98c041/ruff-0.15.20-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:01cc00dd58f0df339d0e902219dd53990ea99996a0344e5d9cc8d45d5307e460", size = 11618698, upload-time = "2026-06-25T17:20:25.259Z" },
{ url = "https://files.pythonhosted.org/packages/10/9b/5f14927848d2fd4aa891fd88d883788c5a7baba561c7874732364045708c/ruff-0.15.20-py3-none-win32.whl", hash = "sha256:ed65ef510e43a137207e0f01cfcf998aeddb1aeeda5c9d35023e910284d7cf21", size = 10857322, upload-time = "2026-06-25T17:20:28.612Z" },
{ url = "https://files.pythonhosted.org/packages/fa/f0/fe47c501f9dea92a26d788ff98bb5d92ed4cb4c88792c5c88af6b697dc8e/ruff-0.15.20-py3-none-win_amd64.whl", hash = "sha256:a525c81c70fb0380344dd1d8745d8cc1c890b7fc94a58d5a07bd8eb9557b8415", size = 11993274, upload-time = "2026-06-25T17:20:31.871Z" },
{ url = "https://files.pythonhosted.org/packages/d7/2b/9555445e1201d92b3195f45cdb153a0b68f24e0a4273f6e3d5ab46e212bb/ruff-0.15.20-py3-none-win_arm64.whl", hash = "sha256:2f5b2a6d614e8700388806a14996c40fab2c47b819ef57d790a34878858ed9ca", size = 11343498, upload-time = "2026-06-25T17:20:35.03Z" },
{ url = "https://files.pythonhosted.org/packages/4b/81/1c8818fee7ce1a04cd7d1b3172e0a8f8e4f1dc4feb7fc390e16daa8af323/ruff-0.16.0-py3-none-linux_armv6l.whl", hash = "sha256:e5115729eb08c585e5121978ba5d5b60caeae394ce21b9fb5e6cd33a1c6c9b1e", size = 10754633, upload-time = "2026-07-23T19:10:46.415Z" },
{ url = "https://files.pythonhosted.org/packages/23/df/beaf59c09d68db84304d555f188b276a77132a5d5b0b67a5c762aa143628/ruff-0.16.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3c954b1d580bfa035b41654f7858cc7e71d5fc3ac5b723dd62bd9133830ed522", size = 10969164, upload-time = "2026-07-23T19:10:50.271Z" },
{ url = "https://files.pythonhosted.org/packages/42/ce/741cd197496a1abbf51352710fd15ed995d2a2be87189c1da26a450d6e83/ruff-0.16.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e01c21d10eb1b29f47b7454e1f4056db9a3f0260c646aa88457c610291db9f81", size = 10488846, upload-time = "2026-07-23T19:10:52.639Z" },
{ url = "https://files.pythonhosted.org/packages/52/2a/a2db8e88cade358f5cdcb05674a917751074109315d014eb6352d9a893f7/ruff-0.16.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e364e5ed22ed8dc05082fd78e35308618260907ac2d3c1d637b2e682415b6c9", size = 10889729, upload-time = "2026-07-23T19:10:54.89Z" },
{ url = "https://files.pythonhosted.org/packages/42/65/62a771694ebd63029dc953e27dbad40e1588bd4860ff9fe881018fddaa49/ruff-0.16.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d327b8fc113a1d4421a04f3839d3752057c8dd1ee320223a6f3f52d04ada462a", size = 10568275, upload-time = "2026-07-23T19:10:56.993Z" },
{ url = "https://files.pythonhosted.org/packages/3f/e2/ced249fe8af5f086c5c58cc21cc3356d50f32f7401c5df87050c999620a7/ruff-0.16.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9b50c55e263103586b3dcf5f73d479eb8cb5fdb6098fec59a62891dab653717", size = 11385112, upload-time = "2026-07-23T19:10:59.615Z" },
{ url = "https://files.pythonhosted.org/packages/87/0b/05154977a8fd69eeb6c103271f55403bfd8711f5c0f8ed07489d95a504e7/ruff-0.16.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0ff4a79ce3ec0172f3241943835de1c4cb4e2dcd07f0f8c2d02603dbbbee4b17", size = 12207008, upload-time = "2026-07-23T19:11:02.154Z" },
{ url = "https://files.pythonhosted.org/packages/fb/29/98225831a3a1eab0e02f4acc6ca6559a98611dcc68b6965ff4b7234627c1/ruff-0.16.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e95c448fca1fb2a18372a9440926c5a6ee789639bb975c72e7ae6d0b04218ab4", size = 11650842, upload-time = "2026-07-23T19:11:04.557Z" },
{ url = "https://files.pythonhosted.org/packages/91/66/6bd3cf90500653d55dc0ffc8507aa8300bd49d0214b2e8cb4d3fef2943ba/ruff-0.16.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f11a8d11010301d0a398a2fdef67691feca7294da6aef55e2150e8fa2cd520b", size = 11400718, upload-time = "2026-07-23T19:11:09.233Z" },
{ url = "https://files.pythonhosted.org/packages/8e/a2/a54eb4eae05d66364050a5d3b8a9c5ef88196531b3cbe7109d873f87f819/ruff-0.16.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:48044c678e9cb8698246c99b14aaccfa6601dea7379eb48a6f8f73f7a6d86cd0", size = 11426177, upload-time = "2026-07-23T19:11:11.994Z" },
{ url = "https://files.pythonhosted.org/packages/1a/be/16e3eea4b2a478a496919f5e36f17c4559e54620bd3bbac5d6affa068006/ruff-0.16.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:7aa0959bad8eb8bef50340154fc9b58678dae31fa4293afa38b44b6e552c0213", size = 10856126, upload-time = "2026-07-23T19:11:14.221Z" },
{ url = "https://files.pythonhosted.org/packages/a2/84/252eb8b868a16eec7257c14f504f77537e734b2d69c762e639e588e304a3/ruff-0.16.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:28ea2b7df8ebf7f9da6b7d47b230ab48f387c0a29be3b474c4d0740e197bb9af", size = 10571208, upload-time = "2026-07-23T19:11:16.378Z" },
{ url = "https://files.pythonhosted.org/packages/21/09/817a482f542f7570cbb4554b26e896610c7114f539b1d9e2d2145bf6bef6/ruff-0.16.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:33a3dfac8c35f81498dea9181bccc2f4c4bc8f1521a1dd9406e77643e0f0fb09", size = 11063329, upload-time = "2026-07-23T19:11:19.173Z" },
{ url = "https://files.pythonhosted.org/packages/2e/23/9403c180ca1cb9b1f7335f5c3e5305c09d49ea5b345196682a36028bde4a/ruff-0.16.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a5237a0bda500d30d81b8e07a6973a5cbc772864cbf746ae2f4e8a2e01c9f4ed", size = 11489751, upload-time = "2026-07-23T19:11:21.74Z" },
{ url = "https://files.pythonhosted.org/packages/b2/1d/1b2ef7bcde851c78d7f17f1cca13fd6dc695fc4b3d6197941e72cae5b132/ruff-0.16.0-py3-none-win32.whl", hash = "sha256:7fab76fa065c873f41ff744347c6e77bcc3dfec4bcc754dc26b63d23c0f7f5fb", size = 10785885, upload-time = "2026-07-23T19:11:23.947Z" },
{ url = "https://files.pythonhosted.org/packages/b2/a3/d5e4ef7a56be3f928ffb90b94c25ba7d3cb9c7fe0736aeaaedf361770712/ruff-0.16.0-py3-none-win_amd64.whl", hash = "sha256:429c117f022bf481fabd9d551e7a3952b24c65e6ef44337ea09d90bebef14472", size = 11923141, upload-time = "2026-07-23T19:11:26.409Z" },
{ url = "https://files.pythonhosted.org/packages/cb/9a/8415f2657cbe200f41a4531ccededf135505a92d4a012229121f885b26f9/ruff-0.16.0-py3-none-win_arm64.whl", hash = "sha256:14296fedcd2705c77ab8235439278bbb38f285cf7da5528b00b3e330c3d4872d", size = 11273407, upload-time = "2026-07-23T19:11:28.705Z" },
]
[[package]]
@@ -1239,7 +1231,7 @@ wheels = [
[[package]]
name = "selenium"
version = "4.45.0"
version = "4.46.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
@@ -1249,14 +1241,14 @@ dependencies = [
{ name = "urllib3", extra = ["socks"] },
{ name = "websocket-client" },
]
sdist = { url = "https://files.pythonhosted.org/packages/86/48/486aa67320f27452e9f551b8608f1a59ce7091c8fe7ebc9f4eba274775d4/selenium-4.45.0.tar.gz", hash = "sha256:563f0c4102f112df1cda30d46ce6d177b2e4a7a3d4b0756902d5dc84d3a8a365", size = 1005503, upload-time = "2026-06-16T04:43:57.915Z" }
sdist = { url = "https://files.pythonhosted.org/packages/a0/53/053df98ef0c38535a74ea732217eb2dbfddc7ff5442a47a4b315ba577f85/selenium-4.46.0.tar.gz", hash = "sha256:54f7e1a4df5f7508ac8c38ce2ea584db1b27083dd79962b22524219219df5cbe", size = 1006000, upload-time = "2026-07-11T00:38:41.11Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e4/8a/6ff6beb9c7c6cc642f628df9328a8b6637f86602eff8d28e70b5d4e8bca7/selenium-4.45.0-py3-none-any.whl", hash = "sha256:1fd9d0dc08192b2f8100e264ed720f83b05d2dd3a7feff673df04e0c7580df4b", size = 9536616, upload-time = "2026-06-16T04:43:55.968Z" },
{ url = "https://files.pythonhosted.org/packages/30/f4/365c723a37c7e9ba6a6a19bf0c9e407137703f56a72c6201d0e4cb1432cb/selenium-4.46.0-py3-none-any.whl", hash = "sha256:f03e864770a21ab32009961c9d015cb2c6275eba45c926c272e02d90af423aad", size = 9428587, upload-time = "2026-07-11T00:38:38.84Z" },
]
[[package]]
name = "seleniumbase"
version = "4.50.5"
version = "4.51.8"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "attrs" },
@@ -1320,18 +1312,18 @@ dependencies = [
{ name = "wheel" },
{ name = "wsproto" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1c/84/fadfc7c543e3bed55acd7350ffc2e0a9f24bae37eb792732611eb8dc1cc2/seleniumbase-4.50.5.tar.gz", hash = "sha256:6a510cfd319c879745a5314eb9b253433b414beeaeb1b7f2e9fc0d05a93f9cd1", size = 667241, upload-time = "2026-07-03T14:34:24.662Z" }
sdist = { url = "https://files.pythonhosted.org/packages/54/92/dac65663cbab903d7d2be831f94587df7fa907ca905c0215ae3a8ab96792/seleniumbase-4.51.8.tar.gz", hash = "sha256:92d71030b9ebca772924b5caed7c44c865a5e149a539b2d733664a4e5bfd05db", size = 670520, upload-time = "2026-07-26T04:51:09.923Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/10/df/ccf0aabfccbbd59141ddbe1f446056524c4b96cf5a7fa511e0859b4da580/seleniumbase-4.50.5-py3-none-any.whl", hash = "sha256:a78887c52e458402eaee07d3abc97a557f79c78c550fec4f864b8901458cc2c6", size = 672979, upload-time = "2026-07-03T14:34:21.262Z" },
{ url = "https://files.pythonhosted.org/packages/f1/7c/6af2efd2f1edda88a0c4d83138586b9014bd155870bad90c4836200eb048/seleniumbase-4.51.8-py3-none-any.whl", hash = "sha256:1c240f6d6fe173c0e51828187a048f5907b1e3ff8c23a4d487f5c65e3730cb41", size = 676369, upload-time = "2026-07-26T04:51:07.017Z" },
]
[[package]]
name = "setuptools"
version = "82.0.1"
version = "83.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/4f/db/cfac1baf10650ab4d1c111714410d2fbb77ac5a616db26775db562c8fab2/setuptools-82.0.1.tar.gz", hash = "sha256:7d872682c5d01cfde07da7bccc7b65469d3dca203318515ada1de5eda35efbf9", size = 1152316, upload-time = "2026-03-09T12:47:17.221Z" }
sdist = { url = "https://files.pythonhosted.org/packages/34/26/f5d29e25ffdb535afef2d35cdb55b325298f96debd670da4c325e08d70f4/setuptools-83.0.0.tar.gz", hash = "sha256:025bccbbf0fa05b6192bc64ae1e7b16e001fd6d6d4d5de03c97b1c1ade523bef", size = 1154254, upload-time = "2026-07-04T15:31:22.699Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9d/76/f789f7a86709c6b087c5a2f52f911838cad707cc613162401badc665acfe/setuptools-82.0.1-py3-none-any.whl", hash = "sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb", size = 1006223, upload-time = "2026-03-09T12:47:15.026Z" },
{ url = "https://files.pythonhosted.org/packages/5d/40/e1e72872c6354b306daef1703549e8e83b4d43cfea356311bf722a043752/setuptools-83.0.0-py3-none-any.whl", hash = "sha256:29b23c360f22f414dc7336bb39178cc7bcbf6021ed2733cde173f09dba19abb3", size = 1008090, upload-time = "2026-07-04T15:31:20.885Z" },
]
[[package]]
@@ -1398,10 +1390,10 @@ requires-dist = [
{ name = "python-socketio" },
{ name = "python-xlib", marker = "extra == 'browser'" },
{ name = "pyvirtualdisplay", marker = "extra == 'browser'" },
{ name = "qbittorrent-api" },
{ name = "qbittorrent-api", specifier = ">=2026.5.3" },
{ name = "rarfile" },
{ name = "requests", extras = ["socks"] },
{ name = "seleniumbase", marker = "extra == 'browser'", specifier = "==4.50.5" },
{ name = "seleniumbase", marker = "extra == 'browser'", specifier = "==4.51.8" },
{ name = "tqdm" },
{ name = "transmission-rpc" },
]
@@ -1414,7 +1406,7 @@ dev = [
{ name = "pytest" },
{ name = "pytest-cov" },
{ name = "pytest-xdist", specifier = ">=3.8.0" },
{ name = "ruff", specifier = "==0.15.20" },
{ name = "ruff", specifier = "==0.16.0" },
{ name = "vulture", specifier = ">=2.14" },
]
@@ -1459,11 +1451,11 @@ wheels = [
[[package]]
name = "soupsieve"
version = "2.8.4"
version = "2.9.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/47/2c/0a5f6f8ee0d5589e48c7640213ed5175d52cf540a06725b628cc1a45d6ce/soupsieve-2.8.4.tar.gz", hash = "sha256:e121fd02e975c695e4e9e8774a5ee35d74714b59307868dcc5319ad2d9e3328e", size = 121110, upload-time = "2026-05-24T13:55:57.154Z" }
sdist = { url = "https://files.pythonhosted.org/packages/d9/38/e12680bbe6b4f8f3d17adcaf38d26850aa756c85cf4a80e79fc12a018fe8/soupsieve-2.9.1.tar.gz", hash = "sha256:c33e6605bbc71dd628b00c632d58ae607c22bade247e52553928f83bbb75b4ba", size = 122261, upload-time = "2026-07-21T16:57:17.452Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5e/f5/0c41cb68dcae6b7de4fac4188a3a9589e21fb31df21ea3a2e888db95e6c9/soupsieve-2.8.4-py3-none-any.whl", hash = "sha256:e7e6b0769c8f51ed59acab6e994b00621096cfb1c640a7509295987388fbaf65", size = 37304, upload-time = "2026-05-24T13:55:55.406Z" },
{ url = "https://files.pythonhosted.org/packages/0f/2c/437fe806897c2d6cfdc3ee43a18da8bf8e568530a4ae9bac781541ca9896/soupsieve-2.9.1-py3-none-any.whl", hash = "sha256:4f4477399246b7a0c720a88ca2454b11cd6bb9ae4c9d170140786e916776c14c", size = 37404, upload-time = "2026-07-21T16:57:16.421Z" },
]
[[package]]
@@ -1480,14 +1472,14 @@ wheels = [
[[package]]
name = "tqdm"
version = "4.68.3"
version = "4.70.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/87/d7/0535a28b1f5f24f6612fb3ff1e89fb1a8d160fee0f976e0aa6803862134b/tqdm-4.68.3.tar.gz", hash = "sha256:00dfa48452b6b6cfae3dd9885636c23d3422d1ec97c66d96818cbd5e0821d482", size = 170596, upload-time = "2026-06-17T07:36:52.105Z" }
sdist = { url = "https://files.pythonhosted.org/packages/21/3b/6c24bec5be5e743ffd99576daa5cc077722fc7d5bbc00bd133fa0c698dc6/tqdm-4.70.0.tar.gz", hash = "sha256:55b0b0dbd97462d06ebee91e4dac24ed4d4702be82b24f07e6c1d27e08cea220", size = 795438, upload-time = "2026-07-27T11:33:15.271Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d8/8e/bb97bb0c71802080bfc8952937d174e49cfc50de5c951dd47b2496f0dcdb/tqdm-4.68.3-py3-none-any.whl", hash = "sha256:39832cc2def2789a6f29df83f172db7416cea70052c0907a57801c5f2fdccb03", size = 78337, upload-time = "2026-06-17T07:36:50.132Z" },
{ url = "https://files.pythonhosted.org/packages/f9/1c/01bfd571a64e7f270e6bab5e33777debe0edc56759233ce84f27dec92d14/tqdm-4.70.0-py3-none-any.whl", hash = "sha256:7f585706bfddbdebf89daac705b2dfcc16890130727d3197ca62c732b4310953", size = 80184, upload-time = "2026-07-27T11:33:13.167Z" },
]
[[package]]
@@ -1586,29 +1578,45 @@ wheels = [
[[package]]
name = "websockets"
version = "16.0"
version = "16.1.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" }
sdist = { url = "https://files.pythonhosted.org/packages/21/f7/bc3a25c5ec26ce62ce487690becc2f3710bbc7b33338f005ad390db0b986/websockets-16.1.1.tar.gz", hash = "sha256:db234eda965dcce15df96bb9709f587cd87d4d52aaf0e80e2f34ec04c7670c57", size = 182204, upload-time = "2026-07-17T22:51:05.858Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" },
{ url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" },
{ url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" },
{ url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" },
{ url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" },
{ url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" },
{ url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" },
{ url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" },
{ url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" },
{ url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" },
{ url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" },
{ url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" },
{ url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" },
{ url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" },
{ url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" },
{ url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" },
{ url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" },
{ url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" },
{ url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" },
{ url = "https://files.pythonhosted.org/packages/73/a2/ba78a164eeea4620df4a4df4bd2ed6017438c4655cc0f36f2c0bc0432355/websockets-16.1.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:443aefe96b7fdb132e2a70806cca1f2af49bb3f28e47abcd7c2e9dcf4d8fa1b8", size = 179635, upload-time = "2026-07-17T22:50:05.001Z" },
{ url = "https://files.pythonhosted.org/packages/b9/08/d26d7a7628cd4ac34cbbdb63ac80914ca842ed8e42938c40a53567806df3/websockets-16.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6456ff333092d509127d75a638cb411afae8ff17f092635015d1902efec8a293", size = 177320, upload-time = "2026-07-17T22:50:06.427Z" },
{ url = "https://files.pythonhosted.org/packages/0f/45/ebec83e6269536aa5932533c67b0af5c781f3e73fdbcd68672dcf43f4f44/websockets-16.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fce6c48559c86d1ac3632ccb1bebc7d5442fbe79bd9bb0e40379ee54be2a4051", size = 177544, upload-time = "2026-07-17T22:50:07.834Z" },
{ url = "https://files.pythonhosted.org/packages/c9/d5/abc614d2297f6c1c3e01e61260364457a47c25cc1cf6a879038902bc6aa8/websockets-16.1.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:92b820d345f7a3fc7b8163949ee92df910f290c3fc517b3d5301c78065adafe1", size = 187270, upload-time = "2026-07-17T22:50:09.275Z" },
{ url = "https://files.pythonhosted.org/packages/52/71/4c99af3b87dff1b2927981f6876607d4acb45338c665242168d3982f7758/websockets-16.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a606d9c24035242a3e256e9d5b77ed9cd6bccfcb7cf993e5ca3c0f6f68fb6a7", size = 188509, upload-time = "2026-07-17T22:50:10.722Z" },
{ url = "https://files.pythonhosted.org/packages/9b/b4/5c8ca14b0df7eb84ed0524165c5359150210140817a3312aee57bf62a1cf/websockets-16.1.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:414e596c75f74e0994084694189d7dc9229fb278e33064d6784b73ffbba3ca31", size = 189882, upload-time = "2026-07-17T22:50:12.293Z" },
{ url = "https://files.pythonhosted.org/packages/25/c1/bedfba9e70557129cb8083748d167bdcc01483dedf0f0df143676df05cbe/websockets-16.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:536676848fc5961aca9d20389951f59169508f765637a172403dc5434d722fa0", size = 189114, upload-time = "2026-07-17T22:50:13.789Z" },
{ url = "https://files.pythonhosted.org/packages/df/09/aa835b2787835aebd839114be5de51b797cb480b63ba42b26d34dfe147cb/websockets-16.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:97fd3a0e8b53efa41970ac1dff3d8cf0d2884cadeb4caaf95db7ad1526926ee3", size = 187861, upload-time = "2026-07-17T22:50:15.179Z" },
{ url = "https://files.pythonhosted.org/packages/20/26/f6408330694dbc9830857d9d23bc14ac4f6875127a480cfdda8d5ca21198/websockets-16.1.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7b1b19636af86a3c7995d4d028dbe376f39b4bf31541146f9c123582a6c94562", size = 185286, upload-time = "2026-07-17T22:50:16.741Z" },
{ url = "https://files.pythonhosted.org/packages/17/9a/e0675e70dd8a80762cf35bb18799d3f290a4890ffe6439bc51d222796083/websockets-16.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:41c8e77f17294c0ac18008a7309b99b34ee72247ef10b6dff4c3f8b5ac29896b", size = 187935, upload-time = "2026-07-17T22:50:18.213Z" },
{ url = "https://files.pythonhosted.org/packages/33/c1/3234cfb86afde01b81e9bddcc6e534c440975d60a13991259e833069ab3e/websockets-16.1.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:9f63bcef7f4b02b06b35fc01c93b96c43b5e88e1e8868676caacf493d5a31f3a", size = 186444, upload-time = "2026-07-17T22:50:19.67Z" },
{ url = "https://files.pythonhosted.org/packages/89/87/9c15206e1d778923d8daa9657de07aa62ea815e13448319c98458c37b281/websockets-16.1.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dab9eb87869da2d6ed3af3f3adf28414baae6ec9d4df355ffc18889132f3436c", size = 188409, upload-time = "2026-07-17T22:50:21.28Z" },
{ url = "https://files.pythonhosted.org/packages/f2/00/cf5de5c67676de2d3eef8b2a518f168f6796595447a5b7161ba0d012915c/websockets-16.1.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:43e3a9fdd7cbf7ba6040c31fae0faf84ca1474fef777c4e37912f1540f854499", size = 185958, upload-time = "2026-07-17T22:50:22.719Z" },
{ url = "https://files.pythonhosted.org/packages/62/c0/731b6ddede2e4136912ec4cff2cffbda35af73546be4762c3d7bd3bd79af/websockets-16.1.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:056ae37939ed7e9974f364f5864e76e49182622d8f9751ac1903c0d09b013985", size = 186911, upload-time = "2026-07-17T22:50:24.108Z" },
{ url = "https://files.pythonhosted.org/packages/8c/7f/39c634472c4469a24a7c09cecddffb08fac6d0e74f73881a94ee8a40a196/websockets-16.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a0eadbbf2c30f01efa58e1f110eb6fa293261f6b0b1aa38f7f48707107690af9", size = 187204, upload-time = "2026-07-17T22:50:25.548Z" },
{ url = "https://files.pythonhosted.org/packages/26/89/9667c256c256dafcc62d21328ce7a40067da857969b68ee9af375b0aaf72/websockets-16.1.1-cp314-cp314-win32.whl", hash = "sha256:195c978b065fa40910582464f99d6b15c8b314c68e0546549a55ed83f4735328", size = 179603, upload-time = "2026-07-17T22:50:27.086Z" },
{ url = "https://files.pythonhosted.org/packages/bd/dd/1c099d6c0fc5deb6b46ccdbb6981fdb4b12c917869cb3952408409dc18db/websockets-16.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:4e8d01cc3bcae7bbf8167f944aeafefed590fae5693552bba9794a9df68371cc", size = 179948, upload-time = "2026-07-17T22:50:28.521Z" },
{ url = "https://files.pythonhosted.org/packages/35/25/9956b2d5e0529d5d23924f21bba1440d4c5c88a562e4f08550871ffa97a7/websockets-16.1.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0ffd3031ea8bda8d61762e84220186105ba3b748b3c8da2ae4f7816fac03e573", size = 179963, upload-time = "2026-07-17T22:50:29.982Z" },
{ url = "https://files.pythonhosted.org/packages/17/06/55ffc976c488b6aee9ea05761ff7c4e88e7c1fd82818c8ca7b556ad2f90c/websockets-16.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:84a2cef8deffbd9ab8ee0ea546a2a6a7030c28f44e6cdd4547dbfeb489eb8999", size = 177497, upload-time = "2026-07-17T22:50:31.396Z" },
{ url = "https://files.pythonhosted.org/packages/0c/e8/f7dac2e980bacc92bdc26cebae4ae4d50cae5380732c50980598fc0bbae4/websockets-16.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3df13f73af9b3b38ab1195eb299ecb67a4330c911c97ae04043ff74085728abe", size = 177698, upload-time = "2026-07-17T22:50:32.829Z" },
{ url = "https://files.pythonhosted.org/packages/b2/39/26762f734113e22da2b942c3aca85798e0c0405d64c256549540ff31e5a1/websockets-16.1.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:23253dd5bcae3f9aaee0a1d30967a8dbd52e5d3cff93a2e5b84df57b77d4750d", size = 187561, upload-time = "2026-07-17T22:50:34.24Z" },
{ url = "https://files.pythonhosted.org/packages/11/94/c3f330851806b9b02138b774d593478323e73c99238681b4b93efe64e02d/websockets-16.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c1c5705e314449e3308872fe084b8571ce078ee4fc55a98a769bdefe5917392", size = 188732, upload-time = "2026-07-17T22:50:36.088Z" },
{ url = "https://files.pythonhosted.org/packages/d1/f2/eb2c450f052de334ae33cf200ece6e87b0e14d186807074e4eb1cd2cdea2/websockets-16.1.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:69e52d175a0a7d1e13b4b67ad41c560b7d98e8c6f6126eb0bda496c784faf8c7", size = 190872, upload-time = "2026-07-17T22:50:38.008Z" },
{ url = "https://files.pythonhosted.org/packages/70/31/2ac8cecf3a74f7fed9132129fc3d90b3998a1554570c11a69b2a8c20332d/websockets-16.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1f79c89b5eb034d1722938a891916582f8f7f503f58ca22518a63c3f2cd18499", size = 189305, upload-time = "2026-07-17T22:50:39.53Z" },
{ url = "https://files.pythonhosted.org/packages/6a/cf/8ab19650d3c0d4562c92e70ab47c257c4aa5c6a713ed87fe63766b31fefc/websockets-16.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:39f2a024af5c345ffe8fcf1ee18c049c024c94df393bb09b044a6917c77bde43", size = 188033, upload-time = "2026-07-17T22:50:40.912Z" },
{ url = "https://files.pythonhosted.org/packages/66/d7/a49a38a6127a4acb134fb1912b215d900cc657605cff32445bf519f3acc4/websockets-16.1.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:952303a7318d4cbe1011400839bb2051c9f84fa0a35923267f5daba34b15d458", size = 185748, upload-time = "2026-07-17T22:50:42.559Z" },
{ url = "https://files.pythonhosted.org/packages/95/3e/ad1fa40388c7f2e0bb2c7930d0090b6c5498594bd1cdaec18864df3d9e97/websockets-16.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:249116b4a76063d930a46391ad56e135c286e4562a18309029fc2c73f4ed4c62", size = 188285, upload-time = "2026-07-17T22:50:43.974Z" },
{ url = "https://files.pythonhosted.org/packages/35/b8/d5db28ca264b9104f82196f92dc8843e35fd391f763d42e4ad358f5bc97e/websockets-16.1.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:61922544a0587a13fd3f53e4c0e5e606510c7b0d9d22c8444e5fae22a06b38cb", size = 186777, upload-time = "2026-07-17T22:50:45.474Z" },
{ url = "https://files.pythonhosted.org/packages/42/9c/726cb39d0cc43ae848dce4aa2acb04eecc6738b1264ec6d700bf6bcfb9f8/websockets-16.1.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:46dcaa042cd1de6c59e7d9269fa63ff7572b6df40510600b678f0826b3c7af51", size = 188682, upload-time = "2026-07-17T22:50:46.973Z" },
{ url = "https://files.pythonhosted.org/packages/be/c7/1168704de8c2dd483edabe4a22cbe4465dd8be8dd95561d214f9fe092871/websockets-16.1.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:38565aca3e01ea8734e578fb2118dade0ecb0250533f29e22b8d1a7a196cf4d0", size = 186377, upload-time = "2026-07-17T22:50:48.413Z" },
{ url = "https://files.pythonhosted.org/packages/ca/40/f9ff2d630ffce4e7dfea0b2288e1caf9ebbf9ff8a9ec9396136ce8b94935/websockets-16.1.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:42f599f4d48c7e1a3338fdaac3acd075be3b3cf02d4b274f3bf2767aedd3d217", size = 187148, upload-time = "2026-07-17T22:50:49.845Z" },
{ url = "https://files.pythonhosted.org/packages/b5/71/e177c8299f78d7cbe2d14df228643c10c70c0e86e108e092056bbcc16e46/websockets-16.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dcc04fedf83effaeb9cce98abc9469bb1b42ef85f03e01c8c1f4438ef7555737", size = 187578, upload-time = "2026-07-17T22:50:51.619Z" },
{ url = "https://files.pythonhosted.org/packages/49/b2/b6987faf330f5af5c787a2610124c2e8403d51724f9001ec4fff6311fe7a/websockets-16.1.1-cp314-cp314t-win32.whl", hash = "sha256:8483c2096363120eea8b07c06ae7304d520f686665fffd4811fad423930a65d7", size = 179729, upload-time = "2026-07-17T22:50:53.269Z" },
{ url = "https://files.pythonhosted.org/packages/a2/6e/fbac6ed878dd362fbad7d415fa4f84d38e3e33fed8cde45c64e783acf826/websockets-16.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:bcce07e23e5769375158f5efdcdafa8d5cd014b93c6683865b840ed65b96f231", size = 180072, upload-time = "2026-07-17T22:50:54.969Z" },
{ url = "https://files.pythonhosted.org/packages/be/4d/2d0d67834092e354d2b0498f014a41249a89556bc406cf86f3e1557bb463/websockets-16.1.1-py3-none-any.whl", hash = "sha256:6abbd3e82c731c8e531714466acd5d87b5e88ac3243465337ba71d68e23ae7e3", size = 173814, upload-time = "2026-07-17T22:51:04.184Z" },
]
[[package]]