Compare commits

...
179 Commits
Author SHA1 Message Date
Alex 4881adc19f Fix tor error retry (#941) 2026-05-01 19:30:32 +01:00
Alex ee54033d23 Fix entrypoint handling on Unraid (#939) 2026-05-01 18:41:43 +01:00
Alex 3554d01c81 Change path default for audiobooks + description fixes (#933) 2026-04-30 18:20:05 +01:00
Alex 9dd445f2af Revert image size streaming (#932) 2026-04-30 17:07:43 +01:00
Alex 4e41b1a8ec fix: hardcover author search (#929)
- Re-adds author search suggestions for Hardcover
- Correctly routes author queries to correct ID or best match
author-filtered book query.
2026-04-29 19:10:37 +01:00
Alex c7619488cd fix: re-add cwa db immutable (#928)
Fixes #926
2026-04-29 18:34:28 +01:00
Alex eba04115ae Fix internal bypasser and /home directory (#919)
- Fixed internal bypasser startup with newer Chromium/SeleniumBase by
isolating the browser helper from Gunicorn/gevent, serialising helper
failures cleanly, and cleaning up orphan processes after a failure
- Stopped using /app as runtime home state, now moved to /home/shelfmark
or /tmp/shelfmark/home as fallback.
- Added tests
2026-04-26 15:01:54 +01:00
dependabot[bot] 26bba25777 Bump the python-deps group across 1 directory with 6 updates (#900)
Bumps the python-deps group with 5 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [authlib](https://github.com/authlib/authlib) | `1.6.9` | `1.7.0` |
| [seleniumbase](https://github.com/seleniumbase/SeleniumBase) |
`4.47.9` | `4.48.2` |
| [basedpyright](https://github.com/detachhead/basedpyright) | `1.39.0`
| `1.39.3` |
| [prek](https://github.com/j178/prek) | `0.3.8` | `0.3.10` |
| [ruff](https://github.com/astral-sh/ruff) | `0.15.10` | `0.15.11` |


Updates `authlib` from 1.6.9 to 1.7.0
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/authlib/authlib/releases">authlib's
releases</a>.</em></p>
<blockquote>
<h2>v1.7.0</h2>
<h2>What's Changed</h2>
<ul>
<li>Authorization and token endpoints request empty scope parameter
management by <a
href="https://github.com/azmeuk"><code>@​azmeuk</code></a> in <a
href="https://redirect.github.com/authlib/authlib/pull/847">authlib/authlib#847</a></li>
<li>Support from Python 3.10 to 3.14 by <a
href="https://github.com/azmeuk"><code>@​azmeuk</code></a> in <a
href="https://redirect.github.com/authlib/authlib/pull/850">authlib/authlib#850</a></li>
<li>Allow composition of AuthorizationServerMetadata by <a
href="https://github.com/azmeuk"><code>@​azmeuk</code></a> in <a
href="https://redirect.github.com/authlib/authlib/pull/853">authlib/authlib#853</a></li>
<li>Make require_oauth parenthesis optional by <a
href="https://github.com/azmeuk"><code>@​azmeuk</code></a> in <a
href="https://redirect.github.com/authlib/authlib/pull/855">authlib/authlib#855</a></li>
<li>Fix <code>expires_at</code> behavior when its value is 0 by <a
href="https://github.com/azmeuk"><code>@​azmeuk</code></a> in <a
href="https://redirect.github.com/authlib/authlib/pull/854">authlib/authlib#854</a></li>
<li>Migration to joserfc by <a
href="https://github.com/lepture"><code>@​lepture</code></a> in <a
href="https://redirect.github.com/authlib/authlib/pull/852">authlib/authlib#852</a></li>
<li>RP-initiated logout by <a
href="https://github.com/frohrlich"><code>@​frohrlich</code></a> in <a
href="https://redirect.github.com/authlib/authlib/pull/849">authlib/authlib#849</a></li>
<li>Fix <code>get_jwt_config</code> by <a
href="https://github.com/lepture"><code>@​lepture</code></a> in <a
href="https://redirect.github.com/authlib/authlib/pull/858">authlib/authlib#858</a></li>
<li>chore(ci): Update PyPy version from 3.10 to 3.11 by <a
href="https://github.com/cclauss"><code>@​cclauss</code></a> in <a
href="https://redirect.github.com/authlib/authlib/pull/863">authlib/authlib#863</a></li>
<li>fix: remove &quot;none&quot; from default authlib.jose.jwt
algorithms by <a
href="https://github.com/lepture"><code>@​lepture</code></a> in <a
href="https://redirect.github.com/authlib/authlib/pull/860">authlib/authlib#860</a></li>
<li>fix: normalize resolve_client_public_key method by <a
href="https://github.com/lepture"><code>@​lepture</code></a> in <a
href="https://redirect.github.com/authlib/authlib/pull/861">authlib/authlib#861</a></li>
<li>Implement rfc9700 PKCE downgrade countermeasure by <a
href="https://github.com/azmeuk"><code>@​azmeuk</code></a> in <a
href="https://redirect.github.com/authlib/authlib/pull/864">authlib/authlib#864</a></li>
<li>Use correct syntax for tox.requires in tox.ini by <a
href="https://github.com/alex-ball"><code>@​alex-ball</code></a> in <a
href="https://redirect.github.com/authlib/authlib/pull/868">authlib/authlib#868</a></li>
<li>Set client session User-Agent when fetching server metadata and JWKs
by <a href="https://github.com/alex-ball"><code>@​alex-ball</code></a>
in <a
href="https://redirect.github.com/authlib/authlib/pull/867">authlib/authlib#867</a></li>
<li>fix: use the real application object for Flask by <a
href="https://github.com/nblock"><code>@​nblock</code></a> in <a
href="https://redirect.github.com/authlib/authlib/pull/869">authlib/authlib#869</a></li>
<li>Accept the issuer URL as a valid audience by <a
href="https://github.com/azmeuk"><code>@​azmeuk</code></a> in <a
href="https://redirect.github.com/authlib/authlib/pull/865">authlib/authlib#865</a></li>
<li>Don't nest InvalidTokenError extra attribute by <a
href="https://github.com/azmeuk"><code>@​azmeuk</code></a> in <a
href="https://redirect.github.com/authlib/authlib/pull/872">authlib/authlib#872</a></li>
<li>Documentation overhaul by <a
href="https://github.com/azmeuk"><code>@​azmeuk</code></a> in <a
href="https://redirect.github.com/authlib/authlib/pull/875">authlib/authlib#875</a></li>
<li>Update README.md docs.authlib.org/en/latest =&gt;
docs.authlib.org/en/stable by <a
href="https://github.com/guillett"><code>@​guillett</code></a> in <a
href="https://redirect.github.com/authlib/authlib/pull/876">authlib/authlib#876</a></li>
<li>Merge release/1.6 branch by <a
href="https://github.com/lepture"><code>@​lepture</code></a> in <a
href="https://redirect.github.com/authlib/authlib/pull/877">authlib/authlib#877</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a href="https://github.com/frohrlich"><code>@​frohrlich</code></a>
made their first contribution in <a
href="https://redirect.github.com/authlib/authlib/pull/849">authlib/authlib#849</a></li>
<li><a href="https://github.com/cclauss"><code>@​cclauss</code></a> made
their first contribution in <a
href="https://redirect.github.com/authlib/authlib/pull/863">authlib/authlib#863</a></li>
<li><a href="https://github.com/alex-ball"><code>@​alex-ball</code></a>
made their first contribution in <a
href="https://redirect.github.com/authlib/authlib/pull/868">authlib/authlib#868</a></li>
<li><a href="https://github.com/nblock"><code>@​nblock</code></a> made
their first contribution in <a
href="https://redirect.github.com/authlib/authlib/pull/869">authlib/authlib#869</a></li>
<li><a href="https://github.com/guillett"><code>@​guillett</code></a>
made their first contribution in <a
href="https://redirect.github.com/authlib/authlib/pull/876">authlib/authlib#876</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/authlib/authlib/compare/v1.6.10...v1.7.0">https://github.com/authlib/authlib/compare/v1.6.10...v1.7.0</a></p>
<h2>v1.6.11</h2>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/authlib/authlib/compare/v1.6.10...v1.6.11">https://github.com/authlib/authlib/compare/v1.6.10...v1.6.11</a></p>
<ul>
<li>Fix CSRF issue with starlette client</li>
</ul>
<h2>v1.6.10</h2>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/authlib/authlib/compare/v1.6.9...v1.6.10">https://github.com/authlib/authlib/compare/v1.6.9...v1.6.10</a></p>
<ul>
<li>Fix redirecting to unvalidated <code>redirect_uri</code> on
<code>UnsupportedResponseTypeError</code>.</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/authlib/authlib/commit/5d2e603ec5f10bd2c4bf20e2495c076370d65b74"><code>5d2e603</code></a>
chore: release 1.7.0</li>
<li><a
href="https://github.com/authlib/authlib/commit/767f08bb80ad6635beb7b54ad98ee9494b84bc26"><code>767f08b</code></a>
fix: CSRF issue with starlette client</li>
<li><a
href="https://github.com/authlib/authlib/commit/e9aaef33933717e1bb5cf77a2cc1ceeb4c948620"><code>e9aaef3</code></a>
Merge pull request <a
href="https://redirect.github.com/authlib/authlib/issues/877">#877</a>
from authlib/merge/1.6</li>
<li><a
href="https://github.com/authlib/authlib/commit/3c8ec9a8895ce3f7c588a9c3bf8e48cdf0c5b2c3"><code>3c8ec9a</code></a>
Merge branch 'main' into merge/1.6</li>
<li><a
href="https://github.com/authlib/authlib/commit/ef09aebbba4439dedb22bd15777d1b3458b6f0ab"><code>ef09aeb</code></a>
chore: release 1.6.10</li>
<li><a
href="https://github.com/authlib/authlib/commit/3be08468201a7766a93012ce149ea12822cab096"><code>3be0846</code></a>
fix: redirecting to unvalidated redirect_uri on
UnsupportedResponseTypeError</li>
<li><a
href="https://github.com/authlib/authlib/commit/4cf6f9792c56a5d598d1525af6384380cdccc4ec"><code>4cf6f97</code></a>
Merge pull request <a
href="https://redirect.github.com/authlib/authlib/issues/876">#876</a>
from guillett/patch-1</li>
<li><a
href="https://github.com/authlib/authlib/commit/23f67b440ca4e2283139b6a57b3c979dbb3c4a50"><code>23f67b4</code></a>
Update README.md docs.authlib.org/en/latest =&gt;
docs.authlib.org/en/stable</li>
<li><a
href="https://github.com/authlib/authlib/commit/10401635d06f59aa282d3367b38dba73574a9127"><code>1040163</code></a>
chore: prek autoupdate</li>
<li><a
href="https://github.com/authlib/authlib/commit/491209fb393c829cb38aebecd614f612191286b2"><code>491209f</code></a>
Merge pull request <a
href="https://redirect.github.com/authlib/authlib/issues/875">#875</a>
from azmeuk/doc</li>
<li>Additional commits viewable in <a
href="https://github.com/authlib/authlib/compare/v1.6.9...v1.7.0">compare
view</a></li>
</ul>
</details>
<br />

Updates `seleniumbase` from 4.47.9 to 4.48.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>Fix unzip in powershell</h2>
<ul>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/2bc7763221c6a0726010caf49476a2f5e87413d7">Fix
unzip in powershell for paths that contain spaces</a></li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/b0518dc2bbd3bf28f659a310cec12ef6e07cb4f9">Refresh
Python dependencies</a></li>
</ul>
<h2>What's Changed</h2>
<ul>
<li>Fix unzip in powershell by <a
href="https://github.com/mdmintz"><code>@​mdmintz</code></a> in <a
href="https://redirect.github.com/seleniumbase/SeleniumBase/pull/4330">seleniumbase/SeleniumBase#4330</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/seleniumbase/SeleniumBase/compare/v4.48.1...v4.48.2">https://github.com/seleniumbase/SeleniumBase/compare/v4.48.1...v4.48.2</a></p>
<h2>4.48.1 - Browser config updates</h2>
<h2>Browser config updates</h2>
<ul>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/bfb94e2fbdceea6e34f9f56297ee44eac9045c25">Update
default config for Chrome and Edge</a></li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/77ea8fcb8c7bc355aa3b9e1a225b5545a90e4431">Refresh
Python dependencies</a></li>
</ul>
<h2>What's Changed</h2>
<ul>
<li>Browser config updates by <a
href="https://github.com/mdmintz"><code>@​mdmintz</code></a> in <a
href="https://redirect.github.com/seleniumbase/SeleniumBase/pull/4323">seleniumbase/SeleniumBase#4323</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/seleniumbase/SeleniumBase/compare/v4.48.0...v4.48.1">https://github.com/seleniumbase/SeleniumBase/compare/v4.48.0...v4.48.1</a></p>
<h2>4.48.0 - Selenium and CDP updates</h2>
<h2>Selenium and CDP updates</h2>
<ul>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/8123a0e674f507629264b178afa4ba9585776056">Update
CDP Mode</a>
--&gt; <code>solve_captcha()</code> now supports &quot;Friendly
Captcha&quot;
--&gt; <code>nest-asyncio</code> is patched from within to avoid
deprecation warnings on Python 3.14</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/657c7231e62ab19deef514ac002995211b7ab814">Refresh
Python dependencies</a>
--&gt; Includes a <code>selenium</code> upgrade</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/9cc818b48e0cea0718421eff2dbb6ca8f924aed2">Add
an example that bypasses &quot;Friendly Captcha&quot;</a></li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/afef6b86e848cdd699f03fe2fa4078c9893ff10d">Add
Stealthy Playwright examples for BrowserScan</a></li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/184f079229055b845c514596ef08e284995e9823">Warn
when await is missing from async method calls</a></li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/7abc5e4f0f746bf979730a514fc2854ddb5f10fd">Support
&quot;Friendly Captcha&quot; solving in async mode</a></li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/7d0f56963af0baf69b09c1975b6b0c95b2e6855f">Add
async example of solving &quot;Friendly Captcha&quot;</a></li>
</ul>
<h2>What's Changed</h2>
<ul>
<li>Selenium and CDP updates by <a
href="https://github.com/mdmintz"><code>@​mdmintz</code></a> in <a
href="https://redirect.github.com/seleniumbase/SeleniumBase/pull/4319">seleniumbase/SeleniumBase#4319</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/seleniumbase/SeleniumBase/compare/v4.47.9...v4.48.0">https://github.com/seleniumbase/SeleniumBase/compare/v4.47.9...v4.48.0</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/a9605aea13b4498a8b60065a23201dae80413316"><code>a9605ae</code></a>
Merge pull request <a
href="https://redirect.github.com/seleniumbase/SeleniumBase/issues/4330">#4330</a>
from seleniumbase/fix-unzip-in-powershell</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/3a38776ea74d463b4f605753962137401d1ce847"><code>3a38776</code></a>
Version 4.48.2</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/b0518dc2bbd3bf28f659a310cec12ef6e07cb4f9"><code>b0518dc</code></a>
Refresh Python dependencies</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/2bc7763221c6a0726010caf49476a2f5e87413d7"><code>2bc7763</code></a>
Fix unzip in powershell for paths that contain spaces</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/a0aceea5080a14342fdf90919383f407d314f618"><code>a0aceea</code></a>
Merge pull request <a
href="https://redirect.github.com/seleniumbase/SeleniumBase/issues/4323">#4323</a>
from seleniumbase/browser-config-updates</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/9dc951b431e0faab1c586e70adf9ba92a26ad721"><code>9dc951b</code></a>
Version 4.48.1</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/77ea8fcb8c7bc355aa3b9e1a225b5545a90e4431"><code>77ea8fc</code></a>
Refresh Python dependencies</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/bfb94e2fbdceea6e34f9f56297ee44eac9045c25"><code>bfb94e2</code></a>
Update default config for Chrome and Edge</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/a381c354bb1cbef92466d75cadfccd7b2f8a6cb1"><code>a381c35</code></a>
Update documentation in migration examples</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/377c3d218e5e688ddbb04aae9ebd71685e6725bf"><code>377c3d2</code></a>
Merge pull request <a
href="https://redirect.github.com/seleniumbase/SeleniumBase/issues/4319">#4319</a>
from seleniumbase/selenium-and-cdp-updates</li>
<li>Additional commits viewable in <a
href="https://github.com/seleniumbase/SeleniumBase/compare/v4.47.9...v4.48.2">compare
view</a></li>
</ul>
</details>
<br />

Updates `basedpyright` from 1.39.0 to 1.39.3
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/DetachHead/basedpyright/commit/5f6672701c5b6a739563894256418845239be2a2"><code>5f66727</code></a>
1.39.3</li>
<li><a
href="https://github.com/DetachHead/basedpyright/commit/d8741dfc88be16e11c66cceff78e6e2634ddf49b"><code>d8741df</code></a>
hopefully fix docs deployment job</li>
<li><a
href="https://github.com/DetachHead/basedpyright/commit/ceb200c192bb53629b4c62fdc082ec27b108f316"><code>ceb200c</code></a>
1.39.2</li>
<li><a
href="https://github.com/DetachHead/basedpyright/commit/eb7a92cacd2697acb4e36e2f3147333588c88fa7"><code>eb7a92c</code></a>
try to fix browser-basedpyright being published with nothing in it</li>
<li><a
href="https://github.com/DetachHead/basedpyright/commit/dec5306e9aabbce3f9b1b9ec283981d92dd43d80"><code>dec5306</code></a>
update package-lock.json</li>
<li><a
href="https://github.com/DetachHead/basedpyright/commit/6db43de5dda601e3cd83998c669c4ecbbfde26f6"><code>6db43de</code></a>
1.39.1</li>
<li><a
href="https://github.com/DetachHead/basedpyright/commit/335e48619fa223fe3be0b6560d746a10934509c8"><code>335e486</code></a>
fix failing tests from merge</li>
<li><a
href="https://github.com/DetachHead/basedpyright/commit/bbe71b686e84ebbff7699d35dab3376d411bb99d"><code>bbe71b6</code></a>
delete newly added github action from upstream that we don't use</li>
<li><a
href="https://github.com/DetachHead/basedpyright/commit/8f2261891e8ceb278963e84658ebaf054b88e559"><code>8f22618</code></a>
update prettierignore</li>
<li><a
href="https://github.com/DetachHead/basedpyright/commit/ff1f5a4af835b14927545c8890b303f48553acac"><code>ff1f5a4</code></a>
fix compile errors from merge</li>
<li>Additional commits viewable in <a
href="https://github.com/detachhead/basedpyright/compare/v1.39.0...v1.39.3">compare
view</a></li>
</ul>
</details>
<br />

Updates `prek` from 0.3.8 to 0.3.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.3.10</h2>
<h2>Release Notes</h2>
<p>Released on 2026-04-21.</p>
<h3>Enhancements</h3>
<ul>
<li>Disallow rev for non-remote repos in schema (<a
href="https://redirect.github.com/j178/prek/pull/1964">#1964</a>)</li>
<li>Hide up-to-date output in non-verbose mode (<a
href="https://redirect.github.com/j178/prek/pull/1942">#1942</a>)</li>
<li>Improve merge conflict marker detection (<a
href="https://redirect.github.com/j178/prek/pull/1937">#1937</a>)</li>
<li>Keep finished hooks visible (<a
href="https://redirect.github.com/j178/prek/pull/1967">#1967</a>)</li>
<li>Preserve frozen comment spacing in auto-update (<a
href="https://redirect.github.com/j178/prek/pull/1945">#1945</a>)</li>
<li>Reimplement <code>@j178/prek</code> npm package (<a
href="https://redirect.github.com/j178/prek/pull/1973">#1973</a>)</li>
</ul>
<h3>Bug fixes</h3>
<ul>
<li>Prefer stable Rust toolchains (<a
href="https://redirect.github.com/j178/prek/pull/1974">#1974</a>)</li>
</ul>
<h3>Documentation</h3>
<ul>
<li>Add <code>SKILL.md</code> for prek (<a
href="https://redirect.github.com/j178/prek/pull/1950">#1950</a>)</li>
<li>Document <code>gh skill install j178/prek prek</code> to install
prek skill for agents (<a
href="https://redirect.github.com/j178/prek/pull/1951">#1951</a>)</li>
<li>Improve compatibility and migration docs (<a
href="https://redirect.github.com/j178/prek/pull/1940">#1940</a>)</li>
</ul>
<h3>Other changes</h3>
<ul>
<li>Sync latest identify tags (<a
href="https://redirect.github.com/j178/prek/pull/1947">#1947</a>)</li>
</ul>
<h3>Contributors</h3>
<ul>
<li><a
href="https://github.com/github-actions"><code>@​github-actions</code></a></li>
<li><a
href="https://github.com/renovate"><code>@​renovate</code></a></li>
<li><a href="https://github.com/j178"><code>@​j178</code></a></li>
</ul>
<h2>Install prek 0.3.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.3.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.3.10/prek-installer.ps1
| iex&quot;
</code></pre>
<h3>Install prebuilt binaries via Homebrew</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.3.10</h2>
<p>Released on 2026-04-21.</p>
<h3>Enhancements</h3>
<ul>
<li>Disallow rev for non-remote repos in schema (<a
href="https://redirect.github.com/j178/prek/pull/1964">#1964</a>)</li>
<li>Hide up-to-date output in non-verbose mode (<a
href="https://redirect.github.com/j178/prek/pull/1942">#1942</a>)</li>
<li>Improve merge conflict marker detection (<a
href="https://redirect.github.com/j178/prek/pull/1937">#1937</a>)</li>
<li>Keep finished hooks visible (<a
href="https://redirect.github.com/j178/prek/pull/1967">#1967</a>)</li>
<li>Preserve frozen comment spacing in auto-update (<a
href="https://redirect.github.com/j178/prek/pull/1945">#1945</a>)</li>
<li>Reimplement <code>@j178/prek</code> npm package (<a
href="https://redirect.github.com/j178/prek/pull/1973">#1973</a>)</li>
</ul>
<h3>Bug fixes</h3>
<ul>
<li>Prefer stable Rust toolchains (<a
href="https://redirect.github.com/j178/prek/pull/1974">#1974</a>)</li>
</ul>
<h3>Documentation</h3>
<ul>
<li>Add <code>SKILL.md</code> for prek (<a
href="https://redirect.github.com/j178/prek/pull/1950">#1950</a>)</li>
<li>Document <code>gh skill install j178/prek prek</code> to install
prek skill for agents (<a
href="https://redirect.github.com/j178/prek/pull/1951">#1951</a>)</li>
<li>Improve compatibility and migration docs (<a
href="https://redirect.github.com/j178/prek/pull/1940">#1940</a>)</li>
</ul>
<h3>Other changes</h3>
<ul>
<li>Sync latest identify tags (<a
href="https://redirect.github.com/j178/prek/pull/1947">#1947</a>)</li>
</ul>
<h3>Contributors</h3>
<ul>
<li><a
href="https://github.com/github-actions"><code>@​github-actions</code></a></li>
<li><a
href="https://github.com/renovate"><code>@​renovate</code></a></li>
<li><a href="https://github.com/j178"><code>@​j178</code></a></li>
</ul>
<h2>0.3.9</h2>
<p>Released on 2026-04-13.</p>
<h3>Highlight</h3>
<p><code>prek auto-update</code> is now stricter about pinned revisions
and more useful in CI.
It now keeps <code>rev</code> and <code># frozen:</code> comments in
sync, can detect
<a href="https://docs.zizmor.sh/audits/#impostor-commit">impostor
commits</a> when validating pinned SHAs,
and lets you use <code>prek auto-update --check</code> to fail on both
available updates and frozen-ref
mismatches without rewriting the config.</p>
<p>Examples:</p>
<pre lang="console"><code>$ prek auto-update
# updates revs and repairs stale `# frozen:` comments
&lt;/tr&gt;&lt;/table&gt; 
</code></pre>
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/j178/prek/commit/1981c51f599ed9db4a428eae13e4a8baf0cb0427"><code>1981c51</code></a>
Bump version to 0.3.10</li>
<li><a
href="https://github.com/j178/prek/commit/ba745f6f407d43e70d2155c7929004a7701ca7e8"><code>ba745f6</code></a>
Prefer stable Rust toolchains (<a
href="https://redirect.github.com/j178/prek/issues/1974">#1974</a>)</li>
<li><a
href="https://github.com/j178/prek/commit/b6c591dbbc4d9917e62b4b6c1dd65419cffeb464"><code>b6c591d</code></a>
Reimplement <code>@j178/prek</code> npm package (<a
href="https://redirect.github.com/j178/prek/issues/1973">#1973</a>)</li>
<li><a
href="https://github.com/j178/prek/commit/fba1c85b1f2ce244e4b491ccaa0edffbdee01348"><code>fba1c85</code></a>
Keep finished hooks visible (<a
href="https://redirect.github.com/j178/prek/issues/1967">#1967</a>)</li>
<li><a
href="https://github.com/j178/prek/commit/bce57a3415d688a9b9d893018348570b381023e3"><code>bce57a3</code></a>
Disallow rev for non-remote repos in schema (<a
href="https://redirect.github.com/j178/prek/issues/1964">#1964</a>)</li>
<li><a
href="https://github.com/j178/prek/commit/afac10cf3ae355f6e2948c62ec25c54536bea40d"><code>afac10c</code></a>
Split <code>auto_update.rs</code> into modules (<a
href="https://redirect.github.com/j178/prek/issues/1962">#1962</a>)</li>
<li><a
href="https://github.com/j178/prek/commit/c3b9f115dabff92fde388c5edb091a10c00fb18f"><code>c3b9f11</code></a>
Update GitHub Actions (major) (<a
href="https://redirect.github.com/j178/prek/issues/1961">#1961</a>)</li>
<li><a
href="https://github.com/j178/prek/commit/6ca0443ecca17e8bc6df972740f445dd9795b4bd"><code>6ca0443</code></a>
Update Rust crate annotate-snippets to v0.12.15 (<a
href="https://redirect.github.com/j178/prek/issues/1955">#1955</a>)</li>
<li><a
href="https://github.com/j178/prek/commit/776277e104a751634711821c10f871a95b5bcd70"><code>776277e</code></a>
Update Rust crate tokio to v1.51.1 (<a
href="https://redirect.github.com/j178/prek/issues/1957">#1957</a>)</li>
<li><a
href="https://github.com/j178/prek/commit/c2b445a722db49f6b0dfeee763c19944bc77e757"><code>c2b445a</code></a>
Update Rust crate toml_edit to v0.25.11 (<a
href="https://redirect.github.com/j178/prek/issues/1958">#1958</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/j178/prek/compare/v0.3.8...v0.3.10">compare
view</a></li>
</ul>
</details>
<br />

Updates `pytest` from 9.0.2 to 9.0.3
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/pytest-dev/pytest/releases">pytest's
releases</a>.</em></p>
<blockquote>
<h2>9.0.3</h2>
<h1>pytest 9.0.3 (2026-04-07)</h1>
<h2>Bug fixes</h2>
<ul>
<li>
<p><a
href="https://redirect.github.com/pytest-dev/pytest/issues/12444">#12444</a>:
Fixed <code>pytest.approx</code> which now correctly takes into account
<code>~collections.abc.Mapping</code> keys order to compare them.</p>
</li>
<li>
<p><a
href="https://redirect.github.com/pytest-dev/pytest/issues/13634">#13634</a>:
Blocking a <code>conftest.py</code> file using the <code>-p no:</code>
option is now explicitly disallowed.</p>
<p>Previously this resulted in an internal assertion failure during
plugin loading.</p>
<p>Pytest now raises a clear <code>UsageError</code> explaining that
conftest files are not plugins and cannot be disabled via
<code>-p</code>.</p>
</li>
<li>
<p><a
href="https://redirect.github.com/pytest-dev/pytest/issues/13734">#13734</a>:
Fixed crash when a test raises an exceptiongroup with
<code>__tracebackhide__ = True</code>.</p>
</li>
<li>
<p><a
href="https://redirect.github.com/pytest-dev/pytest/issues/14195">#14195</a>:
Fixed an issue where non-string messages passed to <!-- raw HTML omitted
-->unittest.TestCase.subTest()<!-- raw HTML omitted --> were not
printed.</p>
</li>
<li>
<p><a
href="https://redirect.github.com/pytest-dev/pytest/issues/14343">#14343</a>:
Fixed use of insecure temporary directory (CVE-2025-71176).</p>
</li>
</ul>
<h2>Improved documentation</h2>
<ul>
<li><a
href="https://redirect.github.com/pytest-dev/pytest/issues/13388">#13388</a>:
Clarified documentation for <code>-p</code> vs
<code>PYTEST_PLUGINS</code> plugin loading and fixed an incorrect
<code>-p</code> example.</li>
<li><a
href="https://redirect.github.com/pytest-dev/pytest/issues/13731">#13731</a>:
Clarified that capture fixtures (e.g. <code>capsys</code> and
<code>capfd</code>) take precedence over the <code>-s</code> /
<code>--capture=no</code> command-line options in <code>Accessing
captured output from a test function
&lt;accessing-captured-output&gt;</code>.</li>
<li><a
href="https://redirect.github.com/pytest-dev/pytest/issues/14088">#14088</a>:
Clarified that the default <code>pytest_collection</code> hook sets
<code>session.items</code> before it calls
<code>pytest_collection_finish</code>, not after.</li>
<li><a
href="https://redirect.github.com/pytest-dev/pytest/issues/14255">#14255</a>:
TOML integer log levels must be quoted: Updating reference
documentation.</li>
</ul>
<h2>Contributor-facing changes</h2>
<ul>
<li>
<p><a
href="https://redirect.github.com/pytest-dev/pytest/issues/12689">#12689</a>:
The test reports are now published to Codecov from GitHub Actions.
The test statistics is visible <a
href="https://app.codecov.io/gh/pytest-dev/pytest/tests">on the web
interface</a>.</p>
<p>-- by <code>aleguy02</code></p>
</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/pytest-dev/pytest/commit/a7d58d7a21b78581e636bbbdea13c66ad1657c1e"><code>a7d58d7</code></a>
Prepare release version 9.0.3</li>
<li><a
href="https://github.com/pytest-dev/pytest/commit/089d98199c253d8f89a040243bc4f2aa6cd5ab22"><code>089d981</code></a>
Merge pull request <a
href="https://redirect.github.com/pytest-dev/pytest/issues/14366">#14366</a>
from bluetech/revert-14193-backport</li>
<li><a
href="https://github.com/pytest-dev/pytest/commit/8127eaf4ab7f6b2fdd0dc1b38343ec97aeef05ac"><code>8127eaf</code></a>
Revert &quot;Fix: assertrepr_compare respects dict insertion order (<a
href="https://redirect.github.com/pytest-dev/pytest/issues/14050">#14050</a>)
(<a
href="https://redirect.github.com/pytest-dev/pytest/issues/14193">#14193</a>)&quot;</li>
<li><a
href="https://github.com/pytest-dev/pytest/commit/99a7e6029e7a6e8d53e5df114b1346e035370241"><code>99a7e60</code></a>
Merge pull request <a
href="https://redirect.github.com/pytest-dev/pytest/issues/14363">#14363</a>
from pytest-dev/patchback/backports/9.0.x/95d8423bd...</li>
<li><a
href="https://github.com/pytest-dev/pytest/commit/ddee02a578da30dd43aedc39c1c1f1aaadfcee95"><code>ddee02a</code></a>
Merge pull request <a
href="https://redirect.github.com/pytest-dev/pytest/issues/14343">#14343</a>
from bluetech/cve-2025-71176-simple</li>
<li><a
href="https://github.com/pytest-dev/pytest/commit/74eac6916fee34726cb194f16c516e96fbd29619"><code>74eac69</code></a>
doc: Update training info (<a
href="https://redirect.github.com/pytest-dev/pytest/issues/14298">#14298</a>)
(<a
href="https://redirect.github.com/pytest-dev/pytest/issues/14301">#14301</a>)</li>
<li><a
href="https://github.com/pytest-dev/pytest/commit/f92dee777cfdb77d1c43633d02766ddf1f07c869"><code>f92dee7</code></a>
Merge pull request <a
href="https://redirect.github.com/pytest-dev/pytest/issues/14267">#14267</a>
from pytest-dev/patchback/backports/9.0.x/d6fa26c62...</li>
<li><a
href="https://github.com/pytest-dev/pytest/commit/7ee58acc8777c31ac6cf388d01addf5a414a7439"><code>7ee58ac</code></a>
Merge pull request <a
href="https://redirect.github.com/pytest-dev/pytest/issues/12378">#12378</a>
from Pierre-Sassoulas/fix-implicit-str-concat-and-d...</li>
<li><a
href="https://github.com/pytest-dev/pytest/commit/37da870d37e3a2f5177cae075c7b9ae279432bf8"><code>37da870</code></a>
Merge pull request <a
href="https://redirect.github.com/pytest-dev/pytest/issues/14259">#14259</a>
from mitre88/patch-4 (<a
href="https://redirect.github.com/pytest-dev/pytest/issues/14268">#14268</a>)</li>
<li><a
href="https://github.com/pytest-dev/pytest/commit/c34bfa3b7acb65b594707c714f1d8461b0304eed"><code>c34bfa3</code></a>
Add explanation for string context diffs (<a
href="https://redirect.github.com/pytest-dev/pytest/issues/14257">#14257</a>)
(<a
href="https://redirect.github.com/pytest-dev/pytest/issues/14266">#14266</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/pytest-dev/pytest/compare/9.0.2...9.0.3">compare
view</a></li>
</ul>
</details>
<br />

Updates `ruff` from 0.15.10 to 0.15.11
<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.11</h2>
<h2>Release Notes</h2>
<p>Released on 2026-04-16.</p>
<h3>Preview features</h3>
<ul>
<li>[<code>ruff</code>] Ignore <code>RUF029</code> when function is
decorated with <code>asynccontextmanager</code> (<a
href="https://redirect.github.com/astral-sh/ruff/pull/24642">#24642</a>)</li>
<li>[<code>airflow</code>] Implement
<code>airflow-xcom-pull-in-template-string</code> (<code>AIR201</code>)
(<a
href="https://redirect.github.com/astral-sh/ruff/pull/23583">#23583</a>)</li>
<li>[<code>flake8-bandit</code>] Fix <code>S103</code> false positives
and negatives in mask analysis (<a
href="https://redirect.github.com/astral-sh/ruff/pull/24424">#24424</a>)</li>
</ul>
<h3>Bug fixes</h3>
<ul>
<li>[<code>flake8-async</code>] Omit overridden methods for
<code>ASYNC109</code> (<a
href="https://redirect.github.com/astral-sh/ruff/pull/24648">#24648</a>)</li>
</ul>
<h3>Documentation</h3>
<ul>
<li>[<code>flake8-async</code>] Add override mention to
<code>ASYNC109</code> docs (<a
href="https://redirect.github.com/astral-sh/ruff/pull/24666">#24666</a>)</li>
<li>Update Neovim config examples to use <code>vim.lsp.config</code> (<a
href="https://redirect.github.com/astral-sh/ruff/pull/24577">#24577</a>)</li>
</ul>
<h3>Contributors</h3>
<ul>
<li><a
href="https://github.com/augustelalande"><code>@​augustelalande</code></a></li>
<li><a
href="https://github.com/anishgirianish"><code>@​anishgirianish</code></a></li>
<li><a
href="https://github.com/benberryallwood"><code>@​benberryallwood</code></a></li>
<li><a
href="https://github.com/charliermarsh"><code>@​charliermarsh</code></a></li>
<li><a href="https://github.com/Dev-iL"><code>@​Dev-iL</code></a></li>
</ul>
<h2>Install ruff 0.15.11</h2>
<h3>Install prebuilt binaries via shell script</h3>
<pre lang="sh"><code>curl --proto '=https' --tlsv1.2 -LsSf
https://releases.astral.sh/github/ruff/releases/download/0.15.11/ruff-installer.sh
| sh
</code></pre>
<h3>Install prebuilt binaries via powershell script</h3>
<pre lang="sh"><code>powershell -ExecutionPolicy Bypass -c &quot;irm
https://releases.astral.sh/github/ruff/releases/download/0.15.11/ruff-installer.ps1
| iex&quot;
</code></pre>
<h2>Download ruff 0.15.11</h2>
<table>
<thead>
<tr>
<th>File</th>
<th>Platform</th>
<th>Checksum</th>
</tr>
</thead>
<tbody>
<tr>
<td><a
href="https://releases.astral.sh/github/ruff/releases/download/0.15.11/ruff-aarch64-apple-darwin.tar.gz">ruff-aarch64-apple-darwin.tar.gz</a></td>
<td>Apple Silicon macOS</td>
<td><a
href="https://releases.astral.sh/github/ruff/releases/download/0.15.11/ruff-aarch64-apple-darwin.tar.gz.sha256">checksum</a></td>
</tr>
<tr>
<td><a
href="https://releases.astral.sh/github/ruff/releases/download/0.15.11/ruff-x86_64-apple-darwin.tar.gz">ruff-x86_64-apple-darwin.tar.gz</a></td>
<td>Intel macOS</td>
<td><a
href="https://releases.astral.sh/github/ruff/releases/download/0.15.11/ruff-x86_64-apple-darwin.tar.gz.sha256">checksum</a></td>
</tr>
<tr>
<td><a
href="https://releases.astral.sh/github/ruff/releases/download/0.15.11/ruff-aarch64-pc-windows-msvc.zip">ruff-aarch64-pc-windows-msvc.zip</a></td>
<td>ARM64 Windows</td>
<td><a
href="https://releases.astral.sh/github/ruff/releases/download/0.15.11/ruff-aarch64-pc-windows-msvc.zip.sha256">checksum</a></td>
</tr>
<tr>
<td><a
href="https://releases.astral.sh/github/ruff/releases/download/0.15.11/ruff-i686-pc-windows-msvc.zip">ruff-i686-pc-windows-msvc.zip</a></td>
<td>x86 Windows</td>
<td><a
href="https://releases.astral.sh/github/ruff/releases/download/0.15.11/ruff-i686-pc-windows-msvc.zip.sha256">checksum</a></td>
</tr>
</tbody>
</table>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md">ruff's
changelog</a>.</em></p>
<blockquote>
<h2>0.15.11</h2>
<p>Released on 2026-04-16.</p>
<h3>Preview features</h3>
<ul>
<li>[<code>ruff</code>] Ignore <code>RUF029</code> when function is
decorated with <code>asynccontextmanager</code> (<a
href="https://redirect.github.com/astral-sh/ruff/pull/24642">#24642</a>)</li>
<li>[<code>airflow</code>] Implement
<code>airflow-xcom-pull-in-template-string</code> (<code>AIR201</code>)
(<a
href="https://redirect.github.com/astral-sh/ruff/pull/23583">#23583</a>)</li>
<li>[<code>flake8-bandit</code>] Fix <code>S103</code> false positives
and negatives in mask analysis (<a
href="https://redirect.github.com/astral-sh/ruff/pull/24424">#24424</a>)</li>
</ul>
<h3>Bug fixes</h3>
<ul>
<li>[<code>flake8-async</code>] Omit overridden methods for
<code>ASYNC109</code> (<a
href="https://redirect.github.com/astral-sh/ruff/pull/24648">#24648</a>)</li>
</ul>
<h3>Documentation</h3>
<ul>
<li>[<code>flake8-async</code>] Add override mention to
<code>ASYNC109</code> docs (<a
href="https://redirect.github.com/astral-sh/ruff/pull/24666">#24666</a>)</li>
<li>Update Neovim config examples to use <code>vim.lsp.config</code> (<a
href="https://redirect.github.com/astral-sh/ruff/pull/24577">#24577</a>)</li>
</ul>
<h3>Contributors</h3>
<ul>
<li><a
href="https://github.com/augustelalande"><code>@​augustelalande</code></a></li>
<li><a
href="https://github.com/anishgirianish"><code>@​anishgirianish</code></a></li>
<li><a
href="https://github.com/benberryallwood"><code>@​benberryallwood</code></a></li>
<li><a
href="https://github.com/charliermarsh"><code>@​charliermarsh</code></a></li>
<li><a href="https://github.com/Dev-iL"><code>@​Dev-iL</code></a></li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/astral-sh/ruff/commit/53554b1cfe837f2eb992a81794480699478f1116"><code>53554b1</code></a>
Bump 0.15.11 (<a
href="https://redirect.github.com/astral-sh/ruff/issues/24678">#24678</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/08c56c83cffbb1025cbf5bdede6c6d8be591cf47"><code>08c56c8</code></a>
Factor out the <code>mdtest</code> crate (<a
href="https://redirect.github.com/astral-sh/ruff/issues/24616">#24616</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/725fbb736d2a999971449b61190b914abd26102a"><code>725fbb7</code></a>
[ty] Use partially qualified names when reporting diagnostics regarding
bad c...</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/ddd6a30ff5fa27694dc1c50d0749885a1519d0a7"><code>ddd6a30</code></a>
[ty] Do not suggest argument completion when at value of keyword
argument (<a
href="https://redirect.github.com/astral-sh/ruff/issues/2">#2</a>...</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/9282e61d482a36da08d66bb8271afeef50b3bc45"><code>9282e61</code></a>
Disallow <a
href="https://github.com/disjoint"><code>@​disjoint</code></a>_base on
TypedDicts and Protocols (<a
href="https://redirect.github.com/astral-sh/ruff/issues/24671">#24671</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/e9986d8e3008eefe2e387312c4dc8b9c60f6f362"><code>e9986d8</code></a>
[ty] Reject using properties with <code>Never</code> setters or deleters
(<a
href="https://redirect.github.com/astral-sh/ruff/issues/24510">#24510</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/9cf212ff82f7b66b4a275ad6a9b1564aee1fa4a8"><code>9cf212f</code></a>
[ty] Normalize property setter and deleter wrappers (<a
href="https://redirect.github.com/astral-sh/ruff/issues/24509">#24509</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/12a1589de4d7120cf99441ee4c14871bdc20968d"><code>12a1589</code></a>
Add override mention to ASYNC109 docs (<a
href="https://redirect.github.com/astral-sh/ruff/issues/24666">#24666</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/dccb03d010f4442ed60624f8d2ba932706abaabb"><code>dccb03d</code></a>
[ty] Avoid panicking on overloaded <code>Callable</code> type context
(<a
href="https://redirect.github.com/astral-sh/ruff/issues/24661">#24661</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/61f9a0a5763fb068cd2f26c0ee9d63a277fb26c2"><code>61f9a0a</code></a>
[ty] Sync vendored typeshed stubs (<a
href="https://redirect.github.com/astral-sh/ruff/issues/24646">#24646</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/astral-sh/ruff/compare/0.15.10...0.15.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-04-25 13:12:35 +01:00
dependabot[bot] 795887ae45 Bump the npm-deps group in /src/frontend with 8 updates (#914)
Bumps the npm-deps group in /src/frontend with 8 updates:

| Package | From | To |
| --- | --- | --- |
|
[@tailwindcss/vite](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/@tailwindcss-vite)
| `4.2.2` | `4.2.4` |
|
[react-router-dom](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router-dom)
| `7.14.1` | `7.14.2` |
| [knip](https://github.com/webpro-nl/knip/tree/HEAD/packages/knip) |
`6.4.1` | `6.6.2` |
| [oxfmt](https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt) |
`0.45.0` | `0.46.0` |
| [oxlint](https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint) |
`1.60.0` | `1.61.0` |
|
[tailwindcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/tailwindcss)
| `4.2.2` | `4.2.4` |
| [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) |
`8.0.8` | `8.0.10` |
|
[vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest)
| `4.1.4` | `4.1.5` |

Updates `@tailwindcss/vite` from 4.2.2 to 4.2.4
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/tailwindlabs/tailwindcss/releases"><code>@​tailwindcss/vite</code>'s
releases</a>.</em></p>
<blockquote>
<h2>v4.2.4</h2>
<h3>Fixed</h3>
<ul>
<li>Ensure imports in <code>@import</code> and <code>@plugin</code>
still resolve correctly when using Vite aliases in
<code>@tailwindcss/vite</code> (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/19947">#19947</a>)</li>
</ul>
<h2>v4.2.3</h2>
<h3>Fixed</h3>
<ul>
<li>Canonicalization: improve canonicalizations for
<code>tracking-*</code> utilities by preferring non-negative utilities
(e.g. <code>-tracking-tighter</code> → <code>tracking-wider</code>) (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/19827">#19827</a>)</li>
<li>Fix crash due to invalid characters in candidate (exceeding valid
unicode code point range) (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/19829">#19829</a>)</li>
<li>Ensure query params in imports are considered unique resources when
using <code>@tailwindcss/webpack</code> (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/19723">#19723</a>)</li>
<li>Canonicalization: collapse arbitrary values into shorthand utilities
(e.g. <code>px-[1.2rem] py-[1.2rem]</code> → <code>p-[1.2rem]</code>)
(<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/19837">#19837</a>)</li>
<li>Canonicalization: collapse <code>border-{t,b}-*</code> into
<code>border-y-*</code>, <code>border-{l,r}-*</code> into
<code>border-x-*</code>, and <code>border-{t,r,b,l}-*</code> into
<code>border-*</code> (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/19842">#19842</a>)</li>
<li>Canonicalization: collapse <code>scroll-m{t,b}-*</code> into
<code>scroll-my-*</code>, <code>scroll-m{l,r}-*</code> into
<code>scroll-mx-*</code>, and <code>scroll-m{t,r,b,l}-*</code> into
<code>scroll-m-*</code> (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/19842">#19842</a>)</li>
<li>Canonicalization: collapse <code>scroll-p{t,b}-*</code> into
<code>scroll-py-*</code>, <code>scroll-p{l,r}-*</code> into
<code>scroll-px-*</code>, and <code>scroll-p{t,r,b,l}-*</code> into
<code>scroll-p-*</code> (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/19842">#19842</a>)</li>
<li>Canonicalization: collapse <code>overflow-{x,y}-*</code> into
<code>overflow-*</code> (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/19842">#19842</a>)</li>
<li>Canonicalization: collapse <code>overscroll-{x,y}-*</code> into
<code>overscroll-*</code> (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/19842">#19842</a>)</li>
<li>Read from <code>--placeholder-color</code> instead of
<code>--background-color</code> for <code>placeholder-*</code> utilities
(<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/19843">#19843</a>)</li>
<li>Upgrade: ensure files are not emptied out when killing the upgrade
process while it's running (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/19846">#19846</a>)</li>
<li>Upgrade: use <code>config.content</code> when migrating from
Tailwind CSS v3 to Tailwind CSS v4 (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/19846">#19846</a>)</li>
<li>Upgrade: never migrate files that are ignored by git (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/19846">#19846</a>)</li>
<li>Add <code>.env</code> and <code>.env.*</code> to default ignored
content files (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/19846">#19846</a>)</li>
<li>Canonicalization: migrate <code>overflow-ellipsis</code> into
<code>text-ellipsis</code> (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/19849">#19849</a>)</li>
<li>Canonicalization: migrate <code>start-full</code> →
<code>inset-s-full</code>, <code>start-auto</code> →
<code>inset-s-auto</code>, <code>start-px</code> →
<code>inset-s-px</code>, and <code>start-&lt;number&gt;</code> →
<code>inset-s-&lt;number&gt;</code> as well as negative versions (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/19849">#19849</a>)</li>
<li>Canonicalization: migrate <code>end-full</code> →
<code>inset-e-full</code>, <code>end-auto</code> →
<code>inset-e-auto</code>, <code>end-px</code> →
<code>inset-e-px</code>, and <code>end-&lt;number&gt;</code> →
<code>inset-e-&lt;number&gt;</code> as well as negative versions (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/19849">#19849</a>)</li>
<li>Canonicalization: move the <code>-</code> sign inside the arbitrary
value <code>-left-[9rem]</code> → <code>left-[-9rem]</code> (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/19858">#19858</a>)</li>
<li>Canonicalization: move the <code>-</code> sign outside the arbitrary
value <code>ml-[calc(-1*var(--width))]</code> →
<code>-ml-(--width)</code> (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/19858">#19858</a>)</li>
<li>Improve performance when scanning JSONL / NDJSON files (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/19862">#19862</a>)</li>
<li>Support <code>NODE_PATH</code> environment variable in standalone
CLI (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/19617">#19617</a>)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md"><code>@​tailwindcss/vite</code>'s
changelog</a>.</em></p>
<blockquote>
<h2>[4.2.4] - 2026-04-21</h2>
<h3>Fixed</h3>
<ul>
<li>Ensure imports in <code>@import</code> and <code>@plugin</code>
still resolve correctly when using Vite aliases in
<code>@tailwindcss/vite</code> (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/19947">#19947</a>)</li>
</ul>
<h2>[4.2.3] - 2026-04-20</h2>
<h3>Fixed</h3>
<ul>
<li>Canonicalization: improve canonicalizations for
<code>tracking-*</code> utilities by preferring non-negative utilities
(e.g. <code>-tracking-tighter</code> → <code>tracking-wider</code>) (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/19827">#19827</a>)</li>
<li>Fix crash due to invalid characters in candidate (exceeding valid
unicode code point range) (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/19829">#19829</a>)</li>
<li>Ensure query params in imports are considered unique resources when
using <code>@tailwindcss/webpack</code> (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/19723">#19723</a>)</li>
<li>Canonicalization: collapse arbitrary values into shorthand utilities
(e.g. <code>px-[1.2rem] py-[1.2rem]</code> → <code>p-[1.2rem]</code>)
(<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/19837">#19837</a>)</li>
<li>Canonicalization: collapse <code>border-{t,b}-*</code> into
<code>border-y-*</code>, <code>border-{l,r}-*</code> into
<code>border-x-*</code>, and <code>border-{t,r,b,l}-*</code> into
<code>border-*</code> (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/19842">#19842</a>)</li>
<li>Canonicalization: collapse <code>scroll-m{t,b}-*</code> into
<code>scroll-my-*</code>, <code>scroll-m{l,r}-*</code> into
<code>scroll-mx-*</code>, and <code>scroll-m{t,r,b,l}-*</code> into
<code>scroll-m-*</code> (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/19842">#19842</a>)</li>
<li>Canonicalization: collapse <code>scroll-p{t,b}-*</code> into
<code>scroll-py-*</code>, <code>scroll-p{l,r}-*</code> into
<code>scroll-px-*</code>, and <code>scroll-p{t,r,b,l}-*</code> into
<code>scroll-p-*</code> (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/19842">#19842</a>)</li>
<li>Canonicalization: collapse <code>overflow-{x,y}-*</code> into
<code>overflow-*</code> (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/19842">#19842</a>)</li>
<li>Canonicalization: collapse <code>overscroll-{x,y}-*</code> into
<code>overscroll-*</code> (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/19842">#19842</a>)</li>
<li>Read from <code>--placeholder-color</code> instead of
<code>--background-color</code> for <code>placeholder-*</code> utilities
(<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/19843">#19843</a>)</li>
<li>Upgrade: ensure files are not emptied out when killing the upgrade
process while it's running (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/19846">#19846</a>)</li>
<li>Upgrade: use <code>config.content</code> when migrating from
Tailwind CSS v3 to Tailwind CSS v4 (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/19846">#19846</a>)</li>
<li>Upgrade: never migrate files that are ignored by git (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/19846">#19846</a>)</li>
<li>Add <code>.env</code> and <code>.env.*</code> to default ignored
content files (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/19846">#19846</a>)</li>
<li>Canonicalization: migrate <code>overflow-ellipsis</code> into
<code>text-ellipsis</code> (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/19849">#19849</a>)</li>
<li>Canonicalization: migrate <code>start-full</code> →
<code>inset-s-full</code>, <code>start-auto</code> →
<code>inset-s-auto</code>, <code>start-px</code> →
<code>inset-s-px</code>, and <code>start-&lt;number&gt;</code> →
<code>inset-s-&lt;number&gt;</code> as well as negative versions (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/19849">#19849</a>)</li>
<li>Canonicalization: migrate <code>end-full</code> →
<code>inset-e-full</code>, <code>end-auto</code> →
<code>inset-e-auto</code>, <code>end-px</code> →
<code>inset-e-px</code>, and <code>end-&lt;number&gt;</code> →
<code>inset-e-&lt;number&gt;</code> as well as negative versions (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/19849">#19849</a>)</li>
<li>Canonicalization: move the <code>-</code> sign inside the arbitrary
value <code>-left-[9rem]</code> → <code>left-[-9rem]</code> (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/19858">#19858</a>)</li>
<li>Canonicalization: move the <code>-</code> sign outside the arbitrary
value <code>ml-[calc(-1*var(--width))]</code> →
<code>-ml-(--width)</code> (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/19858">#19858</a>)</li>
<li>Improve performance when scanning JSONL / NDJSON files (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/19862">#19862</a>)</li>
<li>Support <code>NODE_PATH</code> environment variable in standalone
CLI (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/19617">#19617</a>)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/tailwindlabs/tailwindcss/commit/69ad7cc5ec091125842aba783c5bfce536e9c234"><code>69ad7cc</code></a>
4.2.4 (<a
href="https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/@tailwindcss-vite/issues/19948">#19948</a>)</li>
<li><a
href="https://github.com/tailwindlabs/tailwindcss/commit/685c19e266a6b75842747acab6fe7076e82df62d"><code>685c19e</code></a>
Fix issue around resolving paths in <code>@tailwindcss/vite</code> (<a
href="https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/@tailwindcss-vite/issues/19947">#19947</a>)</li>
<li><a
href="https://github.com/tailwindlabs/tailwindcss/commit/2e3fa490a535a8341fd2853d75098f0d4dbcc825"><code>2e3fa49</code></a>
4.2.3 (<a
href="https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/@tailwindcss-vite/issues/19944">#19944</a>)</li>
<li><a
href="https://github.com/tailwindlabs/tailwindcss/commit/5cb1efdf416a6d37012d31cc1306c41cb35bbdc1"><code>5cb1efd</code></a>
fix(vite): resolve tsconfig paths in CSS and JS resolvers (<a
href="https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/@tailwindcss-vite/issues/19803">#19803</a>)</li>
<li>See full diff in <a
href="https://github.com/tailwindlabs/tailwindcss/commits/v4.2.4/packages/@tailwindcss-vite">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/~GitHub%20Actions">GitHub Actions</a>, a new
releaser for <code>@​tailwindcss/vite</code> since your current
version.</p>
</details>
<br />

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

Updates `knip` from 6.4.1 to 6.6.2
<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.6.2</h2>
<ul>
<li>Don't track typeof value refs at top of exported type alias (resolve
<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1697">#1697</a>)
(1a9904832878f5e8e4b633bdb08fb327baa17ed6)</li>
<li>Treat Props as used when Astro.props is referenced (resolve <a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1629">#1629</a>)
(46cb33871b236249cf9b09679988d530a61a545f)</li>
<li>Update dependencies (dcf53157452b996f970d91d3149c4b7bb4f45edd)</li>
</ul>
<h2>Release 6.6.1</h2>
<ul>
<li>Fix website papercuts (<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1696">#1696</a>)
(c6d9c436d4c0b85f8aae34bab1197e3c2b5eb139) - thanks <a
href="https://github.com/skoeva"><code>@​skoeva</code></a>!</li>
<li>fix(rslib): resolve entry points from rslib config (<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1695">#1695</a>)
(af83d68d9856b3666af5111338f25dc2b4897cc7) - thanks <a
href="https://github.com/rpereira-anchor"><code>@​rpereira-anchor</code></a>!</li>
<li>Update react-email plugin for react-email@6 (<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1694">#1694</a>)
(200db55064811eba4a168598615417d36d77e3aa) - thanks <a
href="https://github.com/krystofspiller"><code>@​krystofspiller</code></a>!</li>
<li>Introduce Manifest wrapper with scriptNames + getMajor helpers
(6fded2f00f4e9cf8328231f9e30280d62c85e95d)</li>
</ul>
<h2>Release 6.6.0</h2>
<ul>
<li>picocolors → styleText
(e7abd6bb1a42c8997ae79555e55b7d9d19f56a48)</li>
<li><code>@​nodelib/fs</code>.walk → fdir
(6f781b84f9776d211c6fd1b0d9b280457701051a)</li>
<li>Improve performance, but only slightly
(c2c0323efd1a327936c4cdd65d335c913eb0e0ef)</li>
<li>Fix package-entry hint for bare specifier in main (resolve <a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1693">#1693</a>)
(2e8cf477dada77b93c54a3717d4c21f3107f5f23)</li>
<li>Extract vite.resolve.alias from Astro config AST (resolve <a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1692">#1692</a>)
(fab587198659cfc095e97699cf3a751034aa2160)</li>
<li>Add plugin-contributed source-map rules
(e11c2b1c02c7bf390f791e675fcdc3c7ec57a007)</li>
<li>Add <code>@sveltejs/package</code> plugin (resolve <a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1690">#1690</a>)
(8b2d402c9fff1a18e13512bfc08b5c3f9af891df)</li>
<li>Windows ❤️ (91964e428b44750ed0d03e3acf1560ba3ec1c202)</li>
</ul>
<h2>Release 6.5.0</h2>
<ul>
<li>Drop project-extension-redundant config hint (resolve <a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1683">#1683</a>)
(f86092949d6dbd041fd621876da674fd3eac7534)</li>
<li>Add instructions to .agents/PLUGINS.md
(e2943ed8fb6d2c0ab4cf12ff04d10bd5ab9fd5f7)</li>
<li>Add a plugin for react-email (<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1684">#1684</a>)
(d8ae4d3ccb810b9a9211fd43e9d1e7d7b704fcaf) - thanks <a
href="https://github.com/xaqrox"><code>@​xaqrox</code></a>!</li>
<li>Replace <code>fast-glob</code> with <code>tinyglobby</code> (<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1462">#1462</a>)
(9f6b4c8aa6857fea40e66d008c905c948af10939) - thanks <a
href="https://github.com/gameroman"><code>@​gameroman</code></a>!</li>
<li>Added plugin for Serverless framework (<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1478">#1478</a>)
(f885f1ded52427d984c67e7172e3141eb4e5ee29) - thanks <a
href="https://github.com/BenCrinion-IW"><code>@​BenCrinion-IW</code></a>!</li>
<li>Add args to Prettier plugin to resolve --config CLI flag (<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1685">#1685</a>)
(f4658c84e714afd8a2233b72be6169da1ac3723e) - thanks <a
href="https://github.com/xaqrox"><code>@​xaqrox</code></a>!</li>
<li>fix(util): tolerate JSONC and array extends in findRootDirsBase (<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1681">#1681</a>)
(f7e5464a6f70e9024a341fdb923766b5ff53a831) - thanks <a
href="https://github.com/Hoffs"><code>@​Hoffs</code></a>!</li>
<li>Format (15bd7e7a56b470096cfee1690263d5d13ddb8fb6)</li>
<li>Inherit outDir/rootDir from tsconfig project references (resolve <a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1680">#1680</a>)
(b89b4f716f4c117b0106e9a212e9e5c46aa85035)</li>
<li>tsc → tsgo (a6e09ca1b65936b3790a6c3628a4646f030d18b8)</li>
<li>Update dependencies (4cb05c96a2ae7c790d29ee76ff13288c5bbb97a2)</li>
<li>Housekeep (28c56cb8bf68031b1c8e9bee75b18ee7274ec981)</li>
<li>Test test test (7eb4ab3a8a6635dcaf756bb2b4a88e1298615994)</li>
<li>Add pino plugin with <code>transportCall</code> visitor (resolve <a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1480">#1480</a>)
(53a033e4ddc5036c6a4a0e55c0abc42c5c64e4f5)</li>
<li>Add signal to projects using knip
(dbedd665c1f8d735030600d3f68ef1825d9a2668)</li>
<li>Tune logos (3148f4d0485875370634b9b53c1b3aa7f6eafcc3)</li>
<li>Tweak <code>knip-run</code> tool response
(42940381a947c46996ae4055e8789f6ea39cfca4)</li>
<li>Add <code>workspace</code> option to <code>knip-run</code> tool
(64c4aaea89e3abb41d8695ebffc5538878520b21)</li>
<li>Remove old lingering <code>experimentalTags</code>
(e503d108e5535800ac6467f4d92c7dd6a9e90037)</li>
<li>Fix compiler type (resolve <a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1689">#1689</a>)
(e7a69adb5e584eb6e5af9b4007820afcbcf27a08)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/webpro-nl/knip/commit/2527ddddc25355407e298485435e7baf42998c94"><code>2527ddd</code></a>
Release knip@6.6.2</li>
<li><a
href="https://github.com/webpro-nl/knip/commit/dcf53157452b996f970d91d3149c4b7bb4f45edd"><code>dcf5315</code></a>
Update dependencies</li>
<li><a
href="https://github.com/webpro-nl/knip/commit/46cb33871b236249cf9b09679988d530a61a545f"><code>46cb338</code></a>
Treat Props as used when Astro.props is referenced (resolve <a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1629">#1629</a>)</li>
<li><a
href="https://github.com/webpro-nl/knip/commit/1a9904832878f5e8e4b633bdb08fb327baa17ed6"><code>1a99048</code></a>
Don't track typeof value refs at top of exported type alias (resolve <a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1697">#1697</a>)</li>
<li><a
href="https://github.com/webpro-nl/knip/commit/2b1b2d4ae4ca67ab42a5324deee463b61d3989f4"><code>2b1b2d4</code></a>
Release knip@6.6.1</li>
<li><a
href="https://github.com/webpro-nl/knip/commit/6fded2f00f4e9cf8328231f9e30280d62c85e95d"><code>6fded2f</code></a>
Introduce Manifest wrapper with scriptNames + getMajor helpers</li>
<li><a
href="https://github.com/webpro-nl/knip/commit/200db55064811eba4a168598615417d36d77e3aa"><code>200db55</code></a>
Update react-email plugin for react-email@6 (<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1694">#1694</a>)</li>
<li><a
href="https://github.com/webpro-nl/knip/commit/af83d68d9856b3666af5111338f25dc2b4897cc7"><code>af83d68</code></a>
fix(rslib): resolve entry points from rslib config (<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1695">#1695</a>)</li>
<li><a
href="https://github.com/webpro-nl/knip/commit/38a17d22c1bdc080fe38227dfbde04b902865e78"><code>38a17d2</code></a>
Release knip@6.6.0</li>
<li><a
href="https://github.com/webpro-nl/knip/commit/91964e428b44750ed0d03e3acf1560ba3ec1c202"><code>91964e4</code></a>
Windows ❤️</li>
<li>Additional commits viewable in <a
href="https://github.com/webpro-nl/knip/commits/knip@6.6.2/packages/knip">compare
view</a></li>
</ul>
</details>
<br />

Updates `oxfmt` from 0.45.0 to 0.46.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>
<h1>Changelog</h1>
<p>All notable changes to this package will be documented in this
file.</p>
<p>The format is based on <a
href="https://keepachangelog.com/en/1.0.0">Keep a Changelog</a>.</p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/oxc-project/oxc/commit/f5bd1dd35f220976fb04b815b2a1d0dfbea82ccf"><code>f5bd1dd</code></a>
release(apps): oxlint v1.61.0 &amp;&amp; oxfmt v0.46.0 (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt/issues/21583">#21583</a>)</li>
<li>See full diff in <a
href="https://github.com/oxc-project/oxc/commits/oxfmt_v0.46.0/npm/oxfmt">compare
view</a></li>
</ul>
</details>
<br />

Updates `oxlint` from 1.60.0 to 1.61.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.61.0] - 2026-04-20</h2>
<h3>🚀 Features</h3>
<ul>
<li>38d8090 linter/jest: Implemented jest <code>version</code> settings
in config file. (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/21522">#21522</a>)
(Said Atrahouch)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/oxc-project/oxc/commit/f5bd1dd35f220976fb04b815b2a1d0dfbea82ccf"><code>f5bd1dd</code></a>
release(apps): oxlint v1.61.0 &amp;&amp; oxfmt v0.46.0 (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/21583">#21583</a>)</li>
<li><a
href="https://github.com/oxc-project/oxc/commit/38d809096e4fa1e6a46cf326a08845348b8b7d27"><code>38d8090</code></a>
feat(linter/jest): Implemented jest <code>version</code> settings in
config file. (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/21522">#21522</a>)</li>
<li>See full diff in <a
href="https://github.com/oxc-project/oxc/commits/oxlint_v1.61.0/npm/oxlint">compare
view</a></li>
</ul>
</details>
<br />

Updates `tailwindcss` from 4.2.2 to 4.2.4
<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.2.4</h2>
<h3>Fixed</h3>
<ul>
<li>Ensure imports in <code>@import</code> and <code>@plugin</code>
still resolve correctly when using Vite aliases in
<code>@tailwindcss/vite</code> (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/19947">#19947</a>)</li>
</ul>
<h2>v4.2.3</h2>
<h3>Fixed</h3>
<ul>
<li>Canonicalization: improve canonicalizations for
<code>tracking-*</code> utilities by preferring non-negative utilities
(e.g. <code>-tracking-tighter</code> → <code>tracking-wider</code>) (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/19827">#19827</a>)</li>
<li>Fix crash due to invalid characters in candidate (exceeding valid
unicode code point range) (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/19829">#19829</a>)</li>
<li>Ensure query params in imports are considered unique resources when
using <code>@tailwindcss/webpack</code> (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/19723">#19723</a>)</li>
<li>Canonicalization: collapse arbitrary values into shorthand utilities
(e.g. <code>px-[1.2rem] py-[1.2rem]</code> → <code>p-[1.2rem]</code>)
(<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/19837">#19837</a>)</li>
<li>Canonicalization: collapse <code>border-{t,b}-*</code> into
<code>border-y-*</code>, <code>border-{l,r}-*</code> into
<code>border-x-*</code>, and <code>border-{t,r,b,l}-*</code> into
<code>border-*</code> (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/19842">#19842</a>)</li>
<li>Canonicalization: collapse <code>scroll-m{t,b}-*</code> into
<code>scroll-my-*</code>, <code>scroll-m{l,r}-*</code> into
<code>scroll-mx-*</code>, and <code>scroll-m{t,r,b,l}-*</code> into
<code>scroll-m-*</code> (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/19842">#19842</a>)</li>
<li>Canonicalization: collapse <code>scroll-p{t,b}-*</code> into
<code>scroll-py-*</code>, <code>scroll-p{l,r}-*</code> into
<code>scroll-px-*</code>, and <code>scroll-p{t,r,b,l}-*</code> into
<code>scroll-p-*</code> (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/19842">#19842</a>)</li>
<li>Canonicalization: collapse <code>overflow-{x,y}-*</code> into
<code>overflow-*</code> (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/19842">#19842</a>)</li>
<li>Canonicalization: collapse <code>overscroll-{x,y}-*</code> into
<code>overscroll-*</code> (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/19842">#19842</a>)</li>
<li>Read from <code>--placeholder-color</code> instead of
<code>--background-color</code> for <code>placeholder-*</code> utilities
(<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/19843">#19843</a>)</li>
<li>Upgrade: ensure files are not emptied out when killing the upgrade
process while it's running (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/19846">#19846</a>)</li>
<li>Upgrade: use <code>config.content</code> when migrating from
Tailwind CSS v3 to Tailwind CSS v4 (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/19846">#19846</a>)</li>
<li>Upgrade: never migrate files that are ignored by git (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/19846">#19846</a>)</li>
<li>Add <code>.env</code> and <code>.env.*</code> to default ignored
content files (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/19846">#19846</a>)</li>
<li>Canonicalization: migrate <code>overflow-ellipsis</code> into
<code>text-ellipsis</code> (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/19849">#19849</a>)</li>
<li>Canonicalization: migrate <code>start-full</code> →
<code>inset-s-full</code>, <code>start-auto</code> →
<code>inset-s-auto</code>, <code>start-px</code> →
<code>inset-s-px</code>, and <code>start-&lt;number&gt;</code> →
<code>inset-s-&lt;number&gt;</code> as well as negative versions (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/19849">#19849</a>)</li>
<li>Canonicalization: migrate <code>end-full</code> →
<code>inset-e-full</code>, <code>end-auto</code> →
<code>inset-e-auto</code>, <code>end-px</code> →
<code>inset-e-px</code>, and <code>end-&lt;number&gt;</code> →
<code>inset-e-&lt;number&gt;</code> as well as negative versions (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/19849">#19849</a>)</li>
<li>Canonicalization: move the <code>-</code> sign inside the arbitrary
value <code>-left-[9rem]</code> → <code>left-[-9rem]</code> (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/19858">#19858</a>)</li>
<li>Canonicalization: move the <code>-</code> sign outside the arbitrary
value <code>ml-[calc(-1*var(--width))]</code> →
<code>-ml-(--width)</code> (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/19858">#19858</a>)</li>
<li>Improve performance when scanning JSONL / NDJSON files (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/19862">#19862</a>)</li>
<li>Support <code>NODE_PATH</code> environment variable in standalone
CLI (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/19617">#19617</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.2.4] - 2026-04-21</h2>
<h3>Fixed</h3>
<ul>
<li>Ensure imports in <code>@import</code> and <code>@plugin</code>
still resolve correctly when using Vite aliases in
<code>@tailwindcss/vite</code> (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/19947">#19947</a>)</li>
</ul>
<h2>[4.2.3] - 2026-04-20</h2>
<h3>Fixed</h3>
<ul>
<li>Canonicalization: improve canonicalizations for
<code>tracking-*</code> utilities by preferring non-negative utilities
(e.g. <code>-tracking-tighter</code> → <code>tracking-wider</code>) (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/19827">#19827</a>)</li>
<li>Fix crash due to invalid characters in candidate (exceeding valid
unicode code point range) (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/19829">#19829</a>)</li>
<li>Ensure query params in imports are considered unique resources when
using <code>@tailwindcss/webpack</code> (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/19723">#19723</a>)</li>
<li>Canonicalization: collapse arbitrary values into shorthand utilities
(e.g. <code>px-[1.2rem] py-[1.2rem]</code> → <code>p-[1.2rem]</code>)
(<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/19837">#19837</a>)</li>
<li>Canonicalization: collapse <code>border-{t,b}-*</code> into
<code>border-y-*</code>, <code>border-{l,r}-*</code> into
<code>border-x-*</code>, and <code>border-{t,r,b,l}-*</code> into
<code>border-*</code> (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/19842">#19842</a>)</li>
<li>Canonicalization: collapse <code>scroll-m{t,b}-*</code> into
<code>scroll-my-*</code>, <code>scroll-m{l,r}-*</code> into
<code>scroll-mx-*</code>, and <code>scroll-m{t,r,b,l}-*</code> into
<code>scroll-m-*</code> (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/19842">#19842</a>)</li>
<li>Canonicalization: collapse <code>scroll-p{t,b}-*</code> into
<code>scroll-py-*</code>, <code>scroll-p{l,r}-*</code> into
<code>scroll-px-*</code>, and <code>scroll-p{t,r,b,l}-*</code> into
<code>scroll-p-*</code> (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/19842">#19842</a>)</li>
<li>Canonicalization: collapse <code>overflow-{x,y}-*</code> into
<code>overflow-*</code> (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/19842">#19842</a>)</li>
<li>Canonicalization: collapse <code>overscroll-{x,y}-*</code> into
<code>overscroll-*</code> (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/19842">#19842</a>)</li>
<li>Read from <code>--placeholder-color</code> instead of
<code>--background-color</code> for <code>placeholder-*</code> utilities
(<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/19843">#19843</a>)</li>
<li>Upgrade: ensure files are not emptied out when killing the upgrade
process while it's running (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/19846">#19846</a>)</li>
<li>Upgrade: use <code>config.content</code> when migrating from
Tailwind CSS v3 to Tailwind CSS v4 (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/19846">#19846</a>)</li>
<li>Upgrade: never migrate files that are ignored by git (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/19846">#19846</a>)</li>
<li>Add <code>.env</code> and <code>.env.*</code> to default ignored
content files (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/19846">#19846</a>)</li>
<li>Canonicalization: migrate <code>overflow-ellipsis</code> into
<code>text-ellipsis</code> (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/19849">#19849</a>)</li>
<li>Canonicalization: migrate <code>start-full</code> →
<code>inset-s-full</code>, <code>start-auto</code> →
<code>inset-s-auto</code>, <code>start-px</code> →
<code>inset-s-px</code>, and <code>start-&lt;number&gt;</code> →
<code>inset-s-&lt;number&gt;</code> as well as negative versions (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/19849">#19849</a>)</li>
<li>Canonicalization: migrate <code>end-full</code> →
<code>inset-e-full</code>, <code>end-auto</code> →
<code>inset-e-auto</code>, <code>end-px</code> →
<code>inset-e-px</code>, and <code>end-&lt;number&gt;</code> →
<code>inset-e-&lt;number&gt;</code> as well as negative versions (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/19849">#19849</a>)</li>
<li>Canonicalization: move the <code>-</code> sign inside the arbitrary
value <code>-left-[9rem]</code> → <code>left-[-9rem]</code> (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/19858">#19858</a>)</li>
<li>Canonicalization: move the <code>-</code> sign outside the arbitrary
value <code>ml-[calc(-1*var(--width))]</code> →
<code>-ml-(--width)</code> (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/19858">#19858</a>)</li>
<li>Improve performance when scanning JSONL / NDJSON files (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/19862">#19862</a>)</li>
<li>Support <code>NODE_PATH</code> environment variable in standalone
CLI (<a
href="https://redirect.github.com/tailwindlabs/tailwindcss/pull/19617">#19617</a>)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/tailwindlabs/tailwindcss/commit/69ad7cc5ec091125842aba783c5bfce536e9c234"><code>69ad7cc</code></a>
4.2.4 (<a
href="https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/tailwindcss/issues/19948">#19948</a>)</li>
<li><a
href="https://github.com/tailwindlabs/tailwindcss/commit/2e3fa490a535a8341fd2853d75098f0d4dbcc825"><code>2e3fa49</code></a>
4.2.3 (<a
href="https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/tailwindcss/issues/19944">#19944</a>)</li>
<li><a
href="https://github.com/tailwindlabs/tailwindcss/commit/df6209ab8b957e56e6e00042a3e4080c14a072ea"><code>df6209a</code></a>
Canonicalize negative arbitrary values (<a
href="https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/tailwindcss/issues/19858">#19858</a>)</li>
<li><a
href="https://github.com/tailwindlabs/tailwindcss/commit/52fd421cc941aad650db769f04b77d08c56951df"><code>52fd421</code></a>
Small refactor of canonicalization tests (<a
href="https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/tailwindcss/issues/19851">#19851</a>)</li>
<li><a
href="https://github.com/tailwindlabs/tailwindcss/commit/c385fd36bcce707a02c229ebeb66ea88e12eb041"><code>c385fd3</code></a>
use <code>test.each</code> instead of manual loop</li>
<li><a
href="https://github.com/tailwindlabs/tailwindcss/commit/0d6e038889f49a809a4077ef7badc7d43184cd42"><code>0d6e038</code></a>
fix index in test name</li>
<li><a
href="https://github.com/tailwindlabs/tailwindcss/commit/88a2d22c2fdb65c5f8c3b2f68db9713f8bf2e656"><code>88a2d22</code></a>
Add more canonicalization rules for deprecated utilities (<a
href="https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/tailwindcss/issues/19849">#19849</a>)</li>
<li><a
href="https://github.com/tailwindlabs/tailwindcss/commit/2c1ef9eb2564f2c83df970eb9f68c19d340c02e5"><code>2c1ef9e</code></a>
Use <code>--placeholder-color</code> instead of
<code>--background-color</code> for <code>placeholder-*</code>...</li>
<li><a
href="https://github.com/tailwindlabs/tailwindcss/commit/28d526859dfd1efe204c8f037c3fe472d9b5d08e"><code>28d5268</code></a>
Collapse more utilities by expanding their declarations (<a
href="https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/tailwindcss/issues/19842">#19842</a>)</li>
<li><a
href="https://github.com/tailwindlabs/tailwindcss/commit/b55d96002ce4516c5158ad3565229267b948c1ac"><code>b55d960</code></a>
fix(canonicalize): collapse arbitrary values into shorthand utilities
(<a
href="https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/tailwindcss/issues/19837">#19837</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/tailwindlabs/tailwindcss/commits/v4.2.4/packages/tailwindcss">compare
view</a></li>
</ul>
</details>
<br />

Updates `vite` from 8.0.8 to 8.0.10
<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.0.10</h2>
<p>Please refer to <a
href="https://github.com/vitejs/vite/blob/v8.0.10/packages/vite/CHANGELOG.md">CHANGELOG.md</a>
for details.</p>
<h2>v8.0.9</h2>
<p>Please refer to <a
href="https://github.com/vitejs/vite/blob/v8.0.9/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.0.9...v8.0.10">8.0.10</a>
(2026-04-23)<!-- raw HTML omitted --></h2>
<h3>Features</h3>
<ul>
<li>update rolldown to 1.0.0-rc.17 (<a
href="https://redirect.github.com/vitejs/vite/issues/22299">#22299</a>)
(<a
href="https://github.com/vitejs/vite/commit/a4d06d9015167d30fe8ac63d1ce2edc146cdca31">a4d06d9</a>)</li>
</ul>
<h3>Bug Fixes</h3>
<ul>
<li><code>hmrClient.logger.debug</code> and
<code>hmrClient.logger.error</code> looked different from other HMR logs
(<a
href="https://redirect.github.com/vitejs/vite/issues/22147">#22147</a>)
(<a
href="https://github.com/vitejs/vite/commit/a4d828f2d5ed85440bc0774eab342e6f9a5e5f62">a4d828f</a>)</li>
<li><strong>css:</strong> show filename in CSS minification warnings for
<code>.css?inline</code> (<a
href="https://redirect.github.com/vitejs/vite/issues/22292">#22292</a>)
(<a
href="https://github.com/vitejs/vite/commit/83f0a785a2ae48d6761fb69f4b0523a24ae9342c">83f0a78</a>)</li>
<li><strong>optimizer:</strong> allow user transform.target to override
default in optimizeDeps (<a
href="https://redirect.github.com/vitejs/vite/issues/22273">#22273</a>)
(<a
href="https://github.com/vitejs/vite/commit/5c7cec69b637544ab16009d8758df7dbbf7f2674">5c7cec6</a>)</li>
<li>remove format sniffing module resolution from JS resolver (<a
href="https://redirect.github.com/vitejs/vite/issues/22297">#22297</a>)
(<a
href="https://github.com/vitejs/vite/commit/b8a21cc821c1434ac9d2b85ec53005df9edc306b">b8a21cc</a>)</li>
</ul>
<h3>Code Refactoring</h3>
<ul>
<li>enable some typecheck rules (<a
href="https://redirect.github.com/vitejs/vite/issues/22278">#22278</a>)
(<a
href="https://github.com/vitejs/vite/commit/943751801f70057ae94f9092e349c8f3fd9ccdf2">9437518</a>)</li>
<li>typecheck client directory (<a
href="https://redirect.github.com/vitejs/vite/issues/22284">#22284</a>)
(<a
href="https://github.com/vitejs/vite/commit/40a0847276502b33a3942b3cfab04b20218f3543">40a0847</a>)</li>
</ul>
<h2><!-- raw HTML omitted --><a
href="https://github.com/vitejs/vite/compare/v8.0.8...v8.0.9">8.0.9</a>
(2026-04-20)<!-- raw HTML omitted --></h2>
<h3>Features</h3>
<ul>
<li>update rolldown to 1.0.0-rc.16 (<a
href="https://redirect.github.com/vitejs/vite/issues/22248">#22248</a>)
(<a
href="https://github.com/vitejs/vite/commit/2947edd57ceb64a0b4dc43269743e8e44e68c09b">2947edd</a>)</li>
</ul>
<h3>Bug Fixes</h3>
<ul>
<li>allow binding when strictPort is set but wildcard port is in use (<a
href="https://redirect.github.com/vitejs/vite/issues/22150">#22150</a>)
(<a
href="https://github.com/vitejs/vite/commit/dfc8aa5057dd8ec2b1223980d1e2eeb946ac3384">dfc8aa5</a>)</li>
<li><strong>build:</strong> emptyOutDir should happen for watch rebuilds
(<a
href="https://redirect.github.com/vitejs/vite/issues/22207">#22207</a>)
(<a
href="https://github.com/vitejs/vite/commit/ee522672bb374c7ff95a347f14732491121b1cd6">ee52267</a>)</li>
<li><strong>bundled-dev:</strong> reject requests to HMR patch files in
non potentially trustworthy origins (<a
href="https://redirect.github.com/vitejs/vite/issues/22269">#22269</a>)
(<a
href="https://github.com/vitejs/vite/commit/868f1411a6f474baa4417f2d6524692dd452f760">868f141</a>)</li>
<li><strong>css:</strong> use unique key for cssEntriesMap to prevent
same-basename collision (<a
href="https://redirect.github.com/vitejs/vite/issues/22039">#22039</a>)
(<a
href="https://github.com/vitejs/vite/commit/374bb5d597fcd0485e929565c698d8ed219136f8">374bb5d</a>)</li>
<li><strong>deps:</strong> update all non-major dependencies (<a
href="https://redirect.github.com/vitejs/vite/issues/22219">#22219</a>)
(<a
href="https://github.com/vitejs/vite/commit/4cd0d6760edd5fb0841abe86538de3c225e880a1">4cd0d67</a>)</li>
<li><strong>deps:</strong> update all non-major dependencies (<a
href="https://redirect.github.com/vitejs/vite/issues/22268">#22268</a>)
(<a
href="https://github.com/vitejs/vite/commit/c28e9c12a849f80e6fdc93f42283ad2863ab9dbc">c28e9c1</a>)</li>
<li>detect Deno workspace root (fix <a
href="https://redirect.github.com/vitejs/vite/issues/22237">#22237</a>)
(<a
href="https://redirect.github.com/vitejs/vite/issues/22238">#22238</a>)
(<a
href="https://github.com/vitejs/vite/commit/1b793c0e1726467fffd06ffad9bc81c61a840188">1b793c0</a>)</li>
<li><strong>dev:</strong> handle errors in <code>watchChange</code> hook
(<a
href="https://redirect.github.com/vitejs/vite/issues/22188">#22188</a>)
(<a
href="https://github.com/vitejs/vite/commit/fc08bdab9bba871b03689f2f6997c3a4ba4351da">fc08bda</a>)</li>
<li><strong>optimizer:</strong> handle more chars that will be sanitized
(<a
href="https://redirect.github.com/vitejs/vite/issues/22208">#22208</a>)
(<a
href="https://github.com/vitejs/vite/commit/3f24533ac4845ed22547279d1721bd82a35345e3">3f24533</a>)</li>
<li>skip fallback sourcemap generation for <code>?raw</code> imports (<a
href="https://redirect.github.com/vitejs/vite/issues/22148">#22148</a>)
(<a
href="https://github.com/vitejs/vite/commit/3ec9cdaac7936ca32d0956c4cb1eb6e172945996">3ec9cda</a>)</li>
</ul>
<h3>Documentation</h3>
<ul>
<li>align the descriptions in READMEs (<a
href="https://redirect.github.com/vitejs/vite/issues/22231">#22231</a>)
(<a
href="https://github.com/vitejs/vite/commit/44c42b97639bb6ad777e66d752b2829cccb9a27a">44c42b9</a>)</li>
<li>fix reuses wording in dev environment comment (<a
href="https://redirect.github.com/vitejs/vite/issues/22173">#22173</a>)
(<a
href="https://github.com/vitejs/vite/commit/9163412fdfec7fb1656529713326a5b5c5e986ea">9163412</a>)</li>
<li>fix wording in sass error comment (<a
href="https://redirect.github.com/vitejs/vite/issues/22214">#22214</a>)
(<a
href="https://github.com/vitejs/vite/commit/bc5c6a7a498845dff20dc410c395355b79a4b753">bc5c6a7</a>)</li>
<li>update build CLI defaults (<a
href="https://redirect.github.com/vitejs/vite/issues/22261">#22261</a>)
(<a
href="https://github.com/vitejs/vite/commit/605bb97994678a1bb70a8de9a85c29d5f5d48c5a">605bb97</a>)</li>
</ul>
<h3>Miscellaneous Chores</h3>
<ul>
<li><strong>deps:</strong> update dependency dotenv-expand to v13 (<a
href="https://redirect.github.com/vitejs/vite/issues/22271">#22271</a>)
(<a
href="https://github.com/vitejs/vite/commit/0a3887da18812cacb254c616e4dd35631e776fda">0a3887d</a>)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/vitejs/vite/commit/32c29780404c353f5a7c5ba4d06fc5e676741714"><code>32c2978</code></a>
release: v8.0.10</li>
<li><a
href="https://github.com/vitejs/vite/commit/a4d06d9015167d30fe8ac63d1ce2edc146cdca31"><code>a4d06d9</code></a>
feat: update rolldown to 1.0.0-rc.17 (<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/22299">#22299</a>)</li>
<li><a
href="https://github.com/vitejs/vite/commit/a4d828f2d5ed85440bc0774eab342e6f9a5e5f62"><code>a4d828f</code></a>
fix: <code>hmrClient.logger.debug</code> and
<code>hmrClient.logger.error</code> looked different f...</li>
<li><a
href="https://github.com/vitejs/vite/commit/83f0a785a2ae48d6761fb69f4b0523a24ae9342c"><code>83f0a78</code></a>
fix(css): show filename in CSS minification warnings for
<code>.css?inline</code> (<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/22292">#22292</a>)</li>
<li><a
href="https://github.com/vitejs/vite/commit/b8a21cc821c1434ac9d2b85ec53005df9edc306b"><code>b8a21cc</code></a>
fix: remove format sniffing module resolution from JS resolver (<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/22297">#22297</a>)</li>
<li><a
href="https://github.com/vitejs/vite/commit/40a0847276502b33a3942b3cfab04b20218f3543"><code>40a0847</code></a>
refactor: typecheck client directory (<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/22284">#22284</a>)</li>
<li><a
href="https://github.com/vitejs/vite/commit/5c7cec69b637544ab16009d8758df7dbbf7f2674"><code>5c7cec6</code></a>
fix(optimizer): allow user transform.target to override default in
optimizeDe...</li>
<li><a
href="https://github.com/vitejs/vite/commit/943751801f70057ae94f9092e349c8f3fd9ccdf2"><code>9437518</code></a>
refactor: enable some typecheck rules (<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/22278">#22278</a>)</li>
<li><a
href="https://github.com/vitejs/vite/commit/ce729f5fa1a5adca373b2adcb0e1b18099164a14"><code>ce729f5</code></a>
release: v8.0.9</li>
<li><a
href="https://github.com/vitejs/vite/commit/605bb97994678a1bb70a8de9a85c29d5f5d48c5a"><code>605bb97</code></a>
docs: update build CLI defaults (<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/22261">#22261</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/vitejs/vite/commits/v8.0.10/packages/vite">compare
view</a></li>
</ul>
</details>
<br />

Updates `vitest` from 4.1.4 to 4.1.5
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/vitest-dev/vitest/releases">vitest's
releases</a>.</em></p>
<blockquote>
<h2>v4.1.5</h2>
<h3>   🚀 Experimental Features</h3>
<ul>
<li><strong>coverage</strong>: Istanbul to support
<code>instrumenter</code> option  -  by <a
href="https://github.com/BartWaardenburg"><code>@​BartWaardenburg</code></a>
and <a
href="https://github.com/AriPerkkio"><code>@​AriPerkkio</code></a> in <a
href="https://redirect.github.com/vitest-dev/vitest/issues/10119">vitest-dev/vitest#10119</a>
<a href="https://github.com/vitest-dev/vitest/commit/0e0ff41c7"><!-- raw
HTML omitted -->(0e0ff)<!-- raw HTML omitted --></a></li>
</ul>
<h3>   🐞 Bug Fixes</h3>
<ul>
<li>--project negation excludes browser instances  -  by <a
href="https://github.com/felamaslen"><code>@​felamaslen</code></a> in <a
href="https://redirect.github.com/vitest-dev/vitest/issues/10131">vitest-dev/vitest#10131</a>
<a href="https://github.com/vitest-dev/vitest/commit/9423dc084"><!-- raw
HTML omitted -->(9423d)<!-- raw HTML omitted --></a></li>
<li>Project color label on html reporter  -  by <a
href="https://github.com/hi-ogawa"><code>@​hi-ogawa</code></a> in <a
href="https://redirect.github.com/vitest-dev/vitest/issues/10142">vitest-dev/vitest#10142</a>
<a href="https://github.com/vitest-dev/vitest/commit/596f73986"><!-- raw
HTML omitted -->(596f7)<!-- raw HTML omitted --></a></li>
<li>Fix <code>vi.defineHelper</code> called as object method  -  by <a
href="https://github.com/hi-ogawa"><code>@​hi-ogawa</code></a> in <a
href="https://redirect.github.com/vitest-dev/vitest/issues/10163">vitest-dev/vitest#10163</a>
<a href="https://github.com/vitest-dev/vitest/commit/122c25b5b"><!-- raw
HTML omitted -->(122c2)<!-- raw HTML omitted --></a></li>
<li>Alias <code>agent</code> reporter to <code>minimal</code>  -  by <a
href="https://github.com/sheremet-va"><code>@​sheremet-va</code></a> in
<a
href="https://redirect.github.com/vitest-dev/vitest/issues/10157">vitest-dev/vitest#10157</a>
<a href="https://github.com/vitest-dev/vitest/commit/663b99fe3"><!-- raw
HTML omitted -->(663b9)<!-- raw HTML omitted --></a></li>
<li>Respect diff config options in soft assertions  -  by <a
href="https://github.com/Copilot"><code>@​Copilot</code></a>,
<strong>sheremet-va</strong> and <a
href="https://github.com/sheremet-va"><code>@​sheremet-va</code></a> in
<a
href="https://redirect.github.com/vitest-dev/vitest/issues/8696">vitest-dev/vitest#8696</a>
<a href="https://github.com/vitest-dev/vitest/commit/9787dedad"><!-- raw
HTML omitted -->(9787d)<!-- raw HTML omitted --></a></li>
<li>Respect diff config options in soft assertions &quot;  -  by <a
href="https://github.com/sheremet-va"><code>@​sheremet-va</code></a> in
<a
href="https://redirect.github.com/vitest-dev/vitest/issues/8696">vitest-dev/vitest#8696</a>
<a href="https://github.com/vitest-dev/vitest/commit/7dc6d54fd"><!-- raw
HTML omitted -->(7dc6d)<!-- raw HTML omitted --></a></li>
<li><strong>ast-collect</strong>: Recognize _<em>vi_import</em> prefix
in static test discovery  -  by <a
href="https://github.com/Yejneshwar"><code>@​Yejneshwar</code></a> in <a
href="https://redirect.github.com/vitest-dev/vitest/issues/10129">vitest-dev/vitest#10129</a>
<a href="https://github.com/vitest-dev/vitest/commit/325463ab2"><!-- raw
HTML omitted -->(32546)<!-- raw HTML omitted --></a></li>
<li><strong>coverage</strong>: Descriptive error message when reports
directory is removed during test run  -  by <a
href="https://github.com/DaveT1991"><code>@​DaveT1991</code></a> and <a
href="https://github.com/AriPerkkio"><code>@​AriPerkkio</code></a> in <a
href="https://redirect.github.com/vitest-dev/vitest/issues/10117">vitest-dev/vitest#10117</a>
<a href="https://github.com/vitest-dev/vitest/commit/1413382e1"><!-- raw
HTML omitted -->(14133)<!-- raw HTML omitted --></a></li>
<li><strong>snapshot</strong>: Increase default snapshot max output
length  -  by <a
href="https://github.com/hi-ogawa"><code>@​hi-ogawa</code></a> and
<strong>Codex</strong> in <a
href="https://redirect.github.com/vitest-dev/vitest/issues/10150">vitest-dev/vitest#10150</a>
<a href="https://github.com/vitest-dev/vitest/commit/21e66ff63"><!-- raw
HTML omitted -->(21e66)<!-- raw HTML omitted --></a></li>
<li><strong>ui</strong>: Fix jsx/tsx syntax highlight  -  by <a
href="https://github.com/hi-ogawa"><code>@​hi-ogawa</code></a> in <a
href="https://redirect.github.com/vitest-dev/vitest/issues/10152">vitest-dev/vitest#10152</a>
<a href="https://github.com/vitest-dev/vitest/commit/f1b1f6c7b"><!-- raw
HTML omitted -->(f1b1f)<!-- raw HTML omitted --></a></li>
<li><strong>web-worker</strong>: Support MessagePort objects referenced
inside postMessage data  -  by <a
href="https://github.com/whitphx"><code>@​whitphx</code></a> and
<strong>Claude Opus 4.6 (1M context)</strong> in <a
href="https://redirect.github.com/vitest-dev/vitest/issues/9927">vitest-dev/vitest#9927</a>
and <a
href="https://redirect.github.com/vitest-dev/vitest/issues/10124">vitest-dev/vitest#10124</a>
<a href="https://github.com/vitest-dev/vitest/commit/7ad7d39af"><!-- raw
HTML omitted -->(7ad7d)<!-- raw HTML omitted --></a></li>
<li><strong>api</strong>: Make test-specification options writable  - 
by <a
href="https://github.com/sheremet-va"><code>@​sheremet-va</code></a> in
<a
href="https://redirect.github.com/vitest-dev/vitest/issues/10154">vitest-dev/vitest#10154</a>
<a href="https://github.com/vitest-dev/vitest/commit/6abd557b7"><!-- raw
HTML omitted -->(6abd5)<!-- raw HTML omitted --></a></li>
</ul>
<h5>    <a
href="https://github.com/vitest-dev/vitest/compare/v4.1.4...v4.1.5">View
changes on GitHub</a></h5>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/vitest-dev/vitest/commit/e399846850fedf10b8228cbe46a419628998acd9"><code>e399846</code></a>
chore: release v4.1.5</li>
<li><a
href="https://github.com/vitest-dev/vitest/commit/7dc6d54fd9dda0fe6fee2fb6451d0611a9ecb6e7"><code>7dc6d54</code></a>
Revert &quot;fix: respect diff config options in soft assertions (<a
href="https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest/issues/8696">#8696</a>)&quot;</li>
<li><a
href="https://github.com/vitest-dev/vitest/commit/9787dedade9896a6d3eeed7739177d6c583a68a7"><code>9787ded</code></a>
fix: respect diff config options in soft assertions (<a
href="https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest/issues/8696">#8696</a>)</li>
<li><a
href="https://github.com/vitest-dev/vitest/commit/325463ab292c45c3ef27aa21ec7da380c307052c"><code>325463a</code></a>
fix(ast-collect): recognize _<em>vi_import</em> prefix in static test
discovery (<a
href="https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest/issues/10">#10</a>...</li>
<li><a
href="https://github.com/vitest-dev/vitest/commit/0e0ff41c7e86d6e2bf581f074dc216805d10d371"><code>0e0ff41</code></a>
feat(coverage): istanbul to support <code>instrumenter</code> option (<a
href="https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest/issues/10119">#10119</a>)</li>
<li><a
href="https://github.com/vitest-dev/vitest/commit/663b99fe3e6a60fc8a7ccd2d9941d1cbe929b606"><code>663b99f</code></a>
fix: alias <code>agent</code> reporter to <code>minimal</code> (<a
href="https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest/issues/10157">#10157</a>)</li>
<li><a
href="https://github.com/vitest-dev/vitest/commit/122c25b5b157ffd31b376561b16ab983aa23e7bc"><code>122c25b</code></a>
fix: fix <code>vi.defineHelper</code> called as object method (<a
href="https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest/issues/10163">#10163</a>)</li>
<li><a
href="https://github.com/vitest-dev/vitest/commit/6abd557b7219156893dd13a1dbe86501d5542d2e"><code>6abd557</code></a>
feat(api): make test-specification options writable (<a
href="https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest/issues/10154">#10154</a>)</li>
<li><a
href="https://github.com/vitest-dev/vitest/commit/596f73986abe2161a9a06f0ca03df68e82690b21"><code>596f739</code></a>
fix: project color label on html reporter (<a
href="https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest/issues/10142">#10142</a>)</li>
<li><a
href="https://github.com/vitest-dev/vitest/commit/9423dc0841e97b6dcac8a73cdb8e656b3d6ba909"><code>9423dc0</code></a>
fix: --project negation excludes browser instances (<a
href="https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest/issues/10131">#10131</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/vitest-dev/vitest/commits/v4.1.5/packages/vitest">compare
view</a></li>
</ul>
</details>
<br />


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

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

---

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

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


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-25 13:12:25 +01:00
dependabot[bot] 74a466139d Bump actions/setup-node from 6.3.0 to 6.4.0 in the gh-actions group (#913)
Bumps the gh-actions group with 1 update:
[actions/setup-node](https://github.com/actions/setup-node).

Updates `actions/setup-node` from 6.3.0 to 6.4.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>v6.4.0</h2>
<h2>What's Changed</h2>
<h3>Dependency updates:</h3>
<ul>
<li>Upgrade <a
href="https://github.com/actions"><code>@​actions</code></a>
dependencies by <a
href="https://github.com/Copilot"><code>@​Copilot</code></a> in <a
href="https://redirect.github.com/actions/setup-node/pull/1525">actions/setup-node#1525</a></li>
<li>Update Node.js versions in versions.yml and bump package to v6.4.0
by <a
href="https://github.com/priya-kinthali"><code>@​priya-kinthali</code></a>
in <a
href="https://redirect.github.com/actions/setup-node/pull/1533">actions/setup-node#1533</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a href="https://github.com/Copilot"><code>@​Copilot</code></a> made
their first contribution in <a
href="https://redirect.github.com/actions/setup-node/pull/1525">actions/setup-node#1525</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/actions/setup-node/compare/v6...v6.4.0">https://github.com/actions/setup-node/compare/v6...v6.4.0</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/actions/setup-node/commit/48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e"><code>48b55a0</code></a>
Update Node.js versions in versions.yml and bump package to v6.4.0 (<a
href="https://redirect.github.com/actions/setup-node/issues/1533">#1533</a>)</li>
<li><a
href="https://github.com/actions/setup-node/commit/ab72c7e7eba0eaa11f8cab0f5679243900c2cac9"><code>ab72c7e</code></a>
Upgrade <a href="https://github.com/actions"><code>@​actions</code></a>
dependencies (<a
href="https://redirect.github.com/actions/setup-node/issues/1525">#1525</a>)</li>
<li>See full diff in <a
href="https://github.com/actions/setup-node/compare/53b83947a5a98c8d113130e565377fae1a50d02f...48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=actions/setup-node&package-manager=github_actions&previous-version=6.3.0&new-version=6.4.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-04-25 13:12:13 +01:00
Humaid KhanandHumaid Arif Khan d6a7588d22 Remove immutable to allow wal checkpoints (#916)
Fixes https://github.com/calibrain/shelfmark/issues/915

## Description

Remove immutable=1 to allow wal checkpoints to fire and get latest auth
info.

Note db is still in readonly mode, thanks to mode=ro

## Test case

1. Run CWA/CW
2. Change Admin password from admin/admin123 to anything else
3. Run shelfmark with CWA db auth
4. Shelfmark should be able to login after password change

Co-authored-by: Humaid Arif Khan <humaid.k@tlt.local>
2026-04-25 13:08:27 +01:00
dependabot[bot]andAlex 700935ec17 Bump the npm-deps group in /src/frontend with 6 updates (#899)
Bumps the npm-deps group in /src/frontend with 6 updates:

| Package | From | To |
| --- | --- | --- |
|
[react-router-dom](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router-dom)
| `7.14.0` | `7.14.1` |
| [knip](https://github.com/webpro-nl/knip/tree/HEAD/packages/knip) |
`6.4.0` | `6.4.1` |
| [oxfmt](https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt) |
`0.44.0` | `0.45.0` |
| [oxlint](https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint) |
`1.59.0` | `1.60.0` |
| [oxlint-tsgolint](https://github.com/oxc-project/tsgolint) | `0.20.0`
| `0.21.1` |
| [typescript](https://github.com/microsoft/TypeScript) | `6.0.2` |
`6.0.3` |

Updates `react-router-dom` from 7.14.0 to 7.14.1
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/remix-run/react-router/blob/main/packages/react-router-dom/CHANGELOG.md">react-router-dom's
changelog</a>.</em></p>
<blockquote>
<h2>v7.14.1</h2>
<h3>Patch Changes</h3>
<ul>
<li>Updated dependencies:
<ul>
<li><a
href="https://github.com/remix-run/react-router/releases/tag/react-router@7.14.1"><code>react-router@7.14.1</code></a></li>
</ul>
</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/remix-run/react-router/commit/197674ba9fc1b72b452e17894e5e783bdab7a087"><code>197674b</code></a>
Release 7.14.1 (<a
href="https://github.com/remix-run/react-router/tree/HEAD/packages/react-router-dom/issues/14973">#14973</a>)</li>
<li><a
href="https://github.com/remix-run/react-router/commit/a87774f997d8ba497c97562840f0766250c3e4ce"><code>a87774f</code></a>
Add new release process (<a
href="https://github.com/remix-run/react-router/tree/HEAD/packages/react-router-dom/issues/14916">#14916</a>)</li>
<li>See full diff in <a
href="https://github.com/remix-run/react-router/commits/react-router-dom@7.14.1/packages/react-router-dom">compare
view</a></li>
</ul>
</details>
<br />

Updates `knip` from 6.4.0 to 6.4.1
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/webpro-nl/knip/releases">knip's
releases</a>.</em></p>
<blockquote>
<h2>Release 6.4.1</h2>
<ul>
<li>license (2d3d8d86ea51f18224c3558a38c28df00113f683)</li>
<li>Handle file path arguments in Bun plugin (resolve <a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1678">#1678</a>)
(cdbe298efcb226ae4baec6567a0cbb6fdf001ee8)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/webpro-nl/knip/commit/ea70d72232b13dca6338105f9fd4ea211595dfba"><code>ea70d72</code></a>
Release knip@6.4.1</li>
<li><a
href="https://github.com/webpro-nl/knip/commit/cdbe298efcb226ae4baec6567a0cbb6fdf001ee8"><code>cdbe298</code></a>
Handle file path arguments in Bun plugin (resolve <a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1678">#1678</a>)</li>
<li>See full diff in <a
href="https://github.com/webpro-nl/knip/commits/knip@6.4.1/packages/knip">compare
view</a></li>
</ul>
</details>
<br />

Updates `oxfmt` from 0.44.0 to 0.45.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.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>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/oxc-project/oxc/commit/e16848eb4c29e51e2e3e2bbda36c76ede42ab974"><code>e16848e</code></a>
release(apps): oxlint v1.60.0 &amp;&amp; oxfmt v0.45.0 (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt/issues/21375">#21375</a>)</li>
<li><a
href="https://github.com/oxc-project/oxc/commit/50c389b64ffa65c6a889f725f53c748d7e3bc507"><code>50c389b</code></a>
fix(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>)</li>
<li>See full diff in <a
href="https://github.com/oxc-project/oxc/commits/oxfmt_v0.45.0/npm/oxfmt">compare
view</a></li>
</ul>
</details>
<br />

Updates `oxlint` from 1.59.0 to 1.60.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.60.0] - 2026-04-13</h2>
<h3>📚 Documentation</h3>
<ul>
<li>cfd8a4f linter: Don't rely on old eslint doc for available globals
(<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/21334">#21334</a>)
(Nicolas Le Cam)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/oxc-project/oxc/commit/e16848eb4c29e51e2e3e2bbda36c76ede42ab974"><code>e16848e</code></a>
release(apps): oxlint v1.60.0 &amp;&amp; oxfmt v0.45.0 (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/21375">#21375</a>)</li>
<li><a
href="https://github.com/oxc-project/oxc/commit/cfd8a4f7a46bb056e58f6d531caca2ec487948ba"><code>cfd8a4f</code></a>
docs(linter): don't rely on old eslint doc for available globals (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/21334">#21334</a>)</li>
<li>See full diff in <a
href="https://github.com/oxc-project/oxc/commits/oxlint_v1.60.0/npm/oxlint">compare
view</a></li>
</ul>
</details>
<br />

Updates `oxlint-tsgolint` from 0.20.0 to 0.21.1
<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.21.1</h2>
<h2>What's Changed</h2>
<ul>
<li>fix(no-unnecessary-condition): handle null overlap in narrowed
generic intersections by <a
href="https://github.com/camc314"><code>@​camc314</code></a> in <a
href="https://redirect.github.com/oxc-project/tsgolint/pull/891">oxc-project/tsgolint#891</a></li>
<li>revert(no-unnecessary-type-arguments): drop inference reporting by
<a href="https://github.com/camc314"><code>@​camc314</code></a> in <a
href="https://redirect.github.com/oxc-project/tsgolint/pull/892">oxc-project/tsgolint#892</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/oxc-project/tsgolint/compare/v0.21.0...v0.21.1">https://github.com/oxc-project/tsgolint/compare/v0.21.0...v0.21.1</a></p>
<h2>v0.21.0</h2>
<h2>What's Changed</h2>
<ul>
<li>chore: migrate gen-json-schemas to TS by <a
href="https://github.com/camc314"><code>@​camc314</code></a> in <a
href="https://redirect.github.com/oxc-project/tsgolint/pull/874">oxc-project/tsgolint#874</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/879">oxc-project/tsgolint#879</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/883">oxc-project/tsgolint#883</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/884">oxc-project/tsgolint#884</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/885">oxc-project/tsgolint#885</a></li>
<li>feat: improve <code>consistent-type-exports</code> diagnostics
quality by <a
href="https://github.com/camchenry"><code>@​camchenry</code></a> in <a
href="https://redirect.github.com/oxc-project/tsgolint/pull/880">oxc-project/tsgolint#880</a></li>
<li>chore(deps): update softprops/action-gh-release action to v3 by <a
href="https://github.com/renovate"><code>@​renovate</code></a>[bot] in
<a
href="https://redirect.github.com/oxc-project/tsgolint/pull/886">oxc-project/tsgolint#886</a></li>
<li>feat: enrich the <code>no-array-delete</code> diagnostic by <a
href="https://github.com/camchenry"><code>@​camchenry</code></a> in <a
href="https://redirect.github.com/oxc-project/tsgolint/pull/881">oxc-project/tsgolint#881</a></li>
<li>feat: enrich <code>no-duplicate-type-constituents</code> diagnostic
by <a href="https://github.com/camchenry"><code>@​camchenry</code></a>
in <a
href="https://redirect.github.com/oxc-project/tsgolint/pull/882">oxc-project/tsgolint#882</a></li>
<li>fix(no-meaningless-void-operator): align with typescript-eslint
union handling by <a
href="https://github.com/camc314"><code>@​camc314</code></a> in <a
href="https://redirect.github.com/oxc-project/tsgolint/pull/887">oxc-project/tsgolint#887</a></li>
<li>chore(deps): update crate-ci/typos action to v1.45.1 by <a
href="https://github.com/renovate"><code>@​renovate</code></a>[bot] in
<a
href="https://redirect.github.com/oxc-project/tsgolint/pull/888">oxc-project/tsgolint#888</a></li>
<li>fix(no-deprecated): avoid false positive on array destructuring
bindings by <a
href="https://github.com/camc314"><code>@​camc314</code></a> in <a
href="https://redirect.github.com/oxc-project/tsgolint/pull/890">oxc-project/tsgolint#890</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/oxc-project/tsgolint/compare/v0.20.0...v0.21.0">https://github.com/oxc-project/tsgolint/compare/v0.20.0...v0.21.0</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/oxc-project/tsgolint/commit/9cb95a8635bd9650759c9bb59b9d87b1d76b2a36"><code>9cb95a8</code></a>
revert(no-unnecessary-type-arguments): drop inference reporting (<a
href="https://redirect.github.com/oxc-project/tsgolint/issues/892">#892</a>)</li>
<li><a
href="https://github.com/oxc-project/tsgolint/commit/f1049e402095722698f7364717b0ad03bf21edb1"><code>f1049e4</code></a>
fix(no-unnecessary-condition): handle null overlap in narrowed generic
inters...</li>
<li><a
href="https://github.com/oxc-project/tsgolint/commit/f9adc305c454101984c5f0886f0ce6a71e780d15"><code>f9adc30</code></a>
fix(no-deprecated): avoid false positive on array destructuring bindings
(<a
href="https://redirect.github.com/oxc-project/tsgolint/issues/890">#890</a>)</li>
<li><a
href="https://github.com/oxc-project/tsgolint/commit/260701f61d543d0d3fc31bb5760407d07e272ae8"><code>260701f</code></a>
chore(deps): update crate-ci/typos action to v1.45.1 (<a
href="https://redirect.github.com/oxc-project/tsgolint/issues/888">#888</a>)</li>
<li><a
href="https://github.com/oxc-project/tsgolint/commit/6dcc81a8ef17d2204e23ca4584a2c70248b30097"><code>6dcc81a</code></a>
fix(no-meaningless-void-operator): align with typescript-eslint union
handlin...</li>
<li><a
href="https://github.com/oxc-project/tsgolint/commit/97315a7a2555edfc398ac2ef1c83d6d89ee95cc7"><code>97315a7</code></a>
feat: enrich <code>no-duplicate-type-constituents</code> diagnostic (<a
href="https://redirect.github.com/oxc-project/tsgolint/issues/882">#882</a>)</li>
<li><a
href="https://github.com/oxc-project/tsgolint/commit/2b8447cde4907a7d131e85747c9289cde3a66cc2"><code>2b8447c</code></a>
feat: enrich the <code>no-array-delete</code> diagnostic (<a
href="https://redirect.github.com/oxc-project/tsgolint/issues/881">#881</a>)</li>
<li><a
href="https://github.com/oxc-project/tsgolint/commit/ae6a5493416f9c348a246d9686d73a075c5b797f"><code>ae6a549</code></a>
chore(deps): update softprops/action-gh-release action to v3 (<a
href="https://redirect.github.com/oxc-project/tsgolint/issues/886">#886</a>)</li>
<li><a
href="https://github.com/oxc-project/tsgolint/commit/1fe50bb1c6b381de333ca20fbd8beb12ec5b1d21"><code>1fe50bb</code></a>
feat: improve <code>consistent-type-exports</code> diagnostics quality
(<a
href="https://redirect.github.com/oxc-project/tsgolint/issues/880">#880</a>)</li>
<li><a
href="https://github.com/oxc-project/tsgolint/commit/834bbb81049885777cbaf51670344e7df32862de"><code>834bbb8</code></a>
chore(deps): update npm packages (<a
href="https://redirect.github.com/oxc-project/tsgolint/issues/885">#885</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/oxc-project/tsgolint/compare/v0.20.0...v0.21.1">compare
view</a></li>
</ul>
</details>
<br />

Updates `typescript` from 6.0.2 to 6.0.3
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/microsoft/TypeScript/releases">typescript's
releases</a>.</em></p>
<blockquote>
<h2>TypeScript 6.0.3</h2>
<!-- raw HTML omitted -->
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/microsoft/TypeScript/commit/050880ce59e30b356b686bd3144efe24f875ebc8"><code>050880c</code></a>
Bump version to 6.0.3 and LKG</li>
<li><a
href="https://github.com/microsoft/TypeScript/commit/eeae9dd0f17aa494658e4ec079dc002e02dd625e"><code>eeae9dd</code></a>
🤖 Pick PR <a
href="https://redirect.github.com/microsoft/TypeScript/issues/63401">#63401</a>
(Also check package name validity in...) into release-6.0 (#...</li>
<li><a
href="https://github.com/microsoft/TypeScript/commit/ad1c695fada682764bb510dd680e8f175ae54094"><code>ad1c695</code></a>
🤖 Pick PR <a
href="https://redirect.github.com/microsoft/TypeScript/issues/63368">#63368</a>
(Harden ATA package name filtering) into release-6.0 (<a
href="https://redirect.github.com/microsoft/TypeScript/issues/63372">#63372</a>)</li>
<li><a
href="https://github.com/microsoft/TypeScript/commit/0725fb4664a1d5ec94040b6d94db77dc1cc354e4"><code>0725fb4</code></a>
🤖 Pick PR <a
href="https://redirect.github.com/microsoft/TypeScript/issues/63310">#63310</a>
(Mark class property initializers as...) into release-6.0 (#...</li>
<li>See full diff in <a
href="https://github.com/microsoft/TypeScript/compare/v6.0.2...v6.0.3">compare
view</a></li>
</ul>
</details>
<br />


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

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

---

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

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


</details>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Alex <25013571+alexhb1@users.noreply.github.com>
2026-04-23 21:03:46 +01:00
dependabot[bot] f7f920683d Bump the gh-actions group with 2 updates (#898)
Bumps the gh-actions group with 2 updates:
[astral-sh/setup-uv](https://github.com/astral-sh/setup-uv) and
[github/codeql-action](https://github.com/github/codeql-action).

Updates `astral-sh/setup-uv` from 8.0.0 to 8.1.0
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/astral-sh/setup-uv/releases">astral-sh/setup-uv's
releases</a>.</em></p>
<blockquote>
<h2>v8.1.0 🌈 New input <code>no-project</code></h2>
<h2>Changes</h2>
<p>This add the a new boolean input <code>no-project</code>.
It only makes sense to use in combination with
<code>activate-environment: true</code> and will append <code>--no
project</code> to the <code>uv venv</code> call. This is for example
useful <a
href="https://redirect.github.com/astral-sh/setup-uv/issues/854">if you
have a pyproject.toml file with parts unparseable by uv</a></p>
<h2>🚀 Enhancements</h2>
<ul>
<li>Add input no-project in combination with activate-environment <a
href="https://github.com/eifinger"><code>@​eifinger</code></a> (<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/856">#856</a>)</li>
</ul>
<h2>🧰 Maintenance</h2>
<ul>
<li>fix: grant contents:write to validate-release job <a
href="https://github.com/eifinger"><code>@​eifinger</code></a> (<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/860">#860</a>)</li>
<li>Add a release-gate step to the release workflow <a
href="https://github.com/zanieb"><code>@​zanieb</code></a> (<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/859">#859</a>)</li>
<li>Draft commitish releases <a
href="https://github.com/eifinger"><code>@​eifinger</code></a> (<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/858">#858</a>)</li>
<li>Add action-types.yml to instructions <a
href="https://github.com/eifinger"><code>@​eifinger</code></a> (<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/857">#857</a>)</li>
<li>chore: update known checksums for 0.11.7 @<a
href="https://github.com/apps/github-actions">github-actions[bot]</a>
(<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/853">#853</a>)</li>
<li>Refactor version resolving <a
href="https://github.com/eifinger"><code>@​eifinger</code></a> (<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/852">#852</a>)</li>
<li>chore: update known checksums for 0.11.6 @<a
href="https://github.com/apps/github-actions">github-actions[bot]</a>
(<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/850">#850</a>)</li>
<li>chore: update known checksums for 0.11.5 @<a
href="https://github.com/apps/github-actions">github-actions[bot]</a>
(<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/845">#845</a>)</li>
<li>chore: update known checksums for 0.11.4 @<a
href="https://github.com/apps/github-actions">github-actions[bot]</a>
(<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/843">#843</a>)</li>
<li>Add a release workflow <a
href="https://github.com/zanieb"><code>@​zanieb</code></a> (<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/839">#839</a>)</li>
<li>chore: update known checksums for 0.11.3 @<a
href="https://github.com/apps/github-actions">github-actions[bot]</a>
(<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/836">#836</a>)</li>
</ul>
<h2>📚 Documentation</h2>
<ul>
<li>Update ignore-nothing-to-cache documentation <a
href="https://github.com/eifinger"><code>@​eifinger</code></a> (<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/833">#833</a>)</li>
<li>Pin setup-uv docs to v8 <a
href="https://github.com/eifinger"><code>@​eifinger</code></a> (<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/829">#829</a>)</li>
</ul>
<h2>⬆️ Dependency updates</h2>
<ul>
<li>chore(deps): bump release-drafter/release-drafter from 7.1.1 to
7.2.0 @<a href="https://github.com/apps/dependabot">dependabot[bot]</a>
(<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/855">#855</a>)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/astral-sh/setup-uv/commit/08807647e7069bb48b6ef5acd8ec9567f424441b"><code>0880764</code></a>
fix: grant contents:write to validate-release job (<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/860">#860</a>)</li>
<li><a
href="https://github.com/astral-sh/setup-uv/commit/717d6aba0f15312f509f5c4999e34d71ecbab8a9"><code>717d6ab</code></a>
Add a release-gate step to the release workflow (<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/859">#859</a>)</li>
<li><a
href="https://github.com/astral-sh/setup-uv/commit/5a911eb3a3983b5e650f2dad95c1ce698ca94378"><code>5a911eb</code></a>
Draft commitish releases (<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/858">#858</a>)</li>
<li><a
href="https://github.com/astral-sh/setup-uv/commit/080c31e04cd7155b0ca676d08c7bc260a4476a23"><code>080c31e</code></a>
Add action-types.yml to instructions (<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/857">#857</a>)</li>
<li><a
href="https://github.com/astral-sh/setup-uv/commit/b3e97d2ba1a1eed7e9d1f8456dd06c3b725bc3a6"><code>b3e97d2</code></a>
Add input no-project in combination with activate-environment (<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/856">#856</a>)</li>
<li><a
href="https://github.com/astral-sh/setup-uv/commit/7dd591db9557f680290587fcc578372813b9ff64"><code>7dd591d</code></a>
chore(deps): bump release-drafter/release-drafter from 7.1.1 to 7.2.0
(<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/855">#855</a>)</li>
<li><a
href="https://github.com/astral-sh/setup-uv/commit/1541b7762698877904805605192ecd63d0e4787a"><code>1541b77</code></a>
chore: update known checksums for 0.11.7 (<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/853">#853</a>)</li>
<li><a
href="https://github.com/astral-sh/setup-uv/commit/cdfb2ee6dde255817c739680168ad81e184c4bfb"><code>cdfb2ee</code></a>
Refactor version resolving (<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/852">#852</a>)</li>
<li><a
href="https://github.com/astral-sh/setup-uv/commit/cb84d12dc6a0d495b82fcae14fa4559b90698660"><code>cb84d12</code></a>
chore: update known checksums for 0.11.6 (<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/850">#850</a>)</li>
<li><a
href="https://github.com/astral-sh/setup-uv/commit/1912cc65f2e839707d7a16f2372f30b57d35fd80"><code>1912cc6</code></a>
chore: update known checksums for 0.11.5 (<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/845">#845</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/astral-sh/setup-uv/compare/cec208311dfd045dd5311c1add060b2062131d57...08807647e7069bb48b6ef5acd8ec9567f424441b">compare
view</a></li>
</ul>
</details>
<br />

Updates `github/codeql-action` from 4.35.1 to 4.35.2
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/github/codeql-action/releases">github/codeql-action's
releases</a>.</em></p>
<blockquote>
<h2>v4.35.2</h2>
<ul>
<li>The undocumented TRAP cache cleanup feature that could be enabled
using the <code>CODEQL_ACTION_CLEANUP_TRAP_CACHES</code> environment
variable is deprecated and will be removed in May 2026. If you are
affected by this, we recommend disabling TRAP caching by passing the
<code>trap-caching: false</code> input to the <code>init</code> Action.
<a
href="https://redirect.github.com/github/codeql-action/pull/3795">#3795</a></li>
<li>The Git version 2.36.0 requirement for improved incremental analysis
now only applies to repositories that contain submodules. <a
href="https://redirect.github.com/github/codeql-action/pull/3789">#3789</a></li>
<li>Python analysis on GHES no longer extracts the standard library,
relying instead on models of the standard library. This should result in
significantly faster extraction and analysis times, while the effect on
alerts should be minimal. <a
href="https://redirect.github.com/github/codeql-action/pull/3794">#3794</a></li>
<li>Fixed a bug in the validation of OIDC configurations for private
registries that was added in CodeQL Action 4.33.0 / 3.33.0. <a
href="https://redirect.github.com/github/codeql-action/pull/3807">#3807</a></li>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.25.2">2.25.2</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/3823">#3823</a></li>
</ul>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/github/codeql-action/blob/main/CHANGELOG.md">github/codeql-action'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.35.2 - 15 Apr 2026</h2>
<ul>
<li>The undocumented TRAP cache cleanup feature that could be enabled
using the <code>CODEQL_ACTION_CLEANUP_TRAP_CACHES</code> environment
variable is deprecated and will be removed in May 2026. If you are
affected by this, we recommend disabling TRAP caching by passing the
<code>trap-caching: false</code> input to the <code>init</code> Action.
<a
href="https://redirect.github.com/github/codeql-action/pull/3795">#3795</a></li>
<li>The Git version 2.36.0 requirement for improved incremental analysis
now only applies to repositories that contain submodules. <a
href="https://redirect.github.com/github/codeql-action/pull/3789">#3789</a></li>
<li>Python analysis on GHES no longer extracts the standard library,
relying instead on models of the standard library. This should result in
significantly faster extraction and analysis times, while the effect on
alerts should be minimal. <a
href="https://redirect.github.com/github/codeql-action/pull/3794">#3794</a></li>
<li>Fixed a bug in the validation of OIDC configurations for private
registries that was added in CodeQL Action 4.33.0 / 3.33.0. <a
href="https://redirect.github.com/github/codeql-action/pull/3807">#3807</a></li>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.25.2">2.25.2</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/3823">#3823</a></li>
</ul>
<h2>4.35.1 - 27 Mar 2026</h2>
<ul>
<li>Fix incorrect minimum required Git version for <a
href="https://redirect.github.com/github/roadmap/issues/1158">improved
incremental analysis</a>: it should have been 2.36.0, not 2.11.0. <a
href="https://redirect.github.com/github/codeql-action/pull/3781">#3781</a></li>
</ul>
<h2>4.35.0 - 27 Mar 2026</h2>
<ul>
<li>Reduced the minimum Git version required for <a
href="https://redirect.github.com/github/roadmap/issues/1158">improved
incremental analysis</a> from 2.38.0 to 2.11.0. <a
href="https://redirect.github.com/github/codeql-action/pull/3767">#3767</a></li>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.25.1">2.25.1</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/3773">#3773</a></li>
</ul>
<h2>4.34.1 - 20 Mar 2026</h2>
<ul>
<li>Downgrade default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.24.3">2.24.3</a>
due to issues with a small percentage of Actions and JavaScript
analyses. <a
href="https://redirect.github.com/github/codeql-action/pull/3762">#3762</a></li>
</ul>
<h2>4.34.0 - 20 Mar 2026</h2>
<ul>
<li>Added an experimental change which disables TRAP caching when <a
href="https://redirect.github.com/github/roadmap/issues/1158">improved
incremental analysis</a> is enabled, since improved incremental analysis
supersedes TRAP caching. This will improve performance and reduce
Actions cache usage. We expect to roll this change out to everyone in
March. <a
href="https://redirect.github.com/github/codeql-action/pull/3569">#3569</a></li>
<li>We are rolling out improved incremental analysis to C/C++ analyses
that use build mode <code>none</code>. We expect this rollout to be
complete by the end of April 2026. <a
href="https://redirect.github.com/github/codeql-action/pull/3584">#3584</a></li>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.25.0">2.25.0</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/3585">#3585</a></li>
</ul>
<h2>4.33.0 - 16 Mar 2026</h2>
<ul>
<li>
<p>Upcoming change: Starting April 2026, the CodeQL Action will skip
collecting file coverage information on pull requests to improve
analysis performance. File coverage information will still be computed
on non-PR analyses. Pull request analyses will log a warning about this
upcoming change. <a
href="https://redirect.github.com/github/codeql-action/pull/3562">#3562</a></p>
<p>To opt out of this change:</p>
<ul>
<li><strong>Repositories owned by an organization:</strong> Create a
custom repository property with the name
<code>github-codeql-file-coverage-on-prs</code> and the type
&quot;True/false&quot;, then set this property to <code>true</code> in
the repository's settings. For more information, see <a
href="https://docs.github.com/en/organizations/managing-organization-settings/managing-custom-properties-for-repositories-in-your-organization">Managing
custom properties for repositories in your organization</a>.
Alternatively, if you are using an advanced setup workflow, you can set
the <code>CODEQL_ACTION_FILE_COVERAGE_ON_PRS</code> environment variable
to <code>true</code> in your workflow.</li>
<li><strong>User-owned repositories using default setup:</strong> Switch
to an advanced setup workflow and set the
<code>CODEQL_ACTION_FILE_COVERAGE_ON_PRS</code> environment variable to
<code>true</code> in your workflow.</li>
<li><strong>User-owned repositories using advanced setup:</strong> Set
the <code>CODEQL_ACTION_FILE_COVERAGE_ON_PRS</code> environment variable
to <code>true</code> in your workflow.</li>
</ul>
</li>
<li>
<p>Fixed <a
href="https://redirect.github.com/github/codeql-action/issues/3555">a
bug</a> which caused the CodeQL Action to fail loading repository
properties if a &quot;Multi select&quot; repository property was
configured for the repository. <a
href="https://redirect.github.com/github/codeql-action/pull/3557">#3557</a></p>
</li>
<li>
<p>The CodeQL Action now loads <a
href="https://docs.github.com/en/organizations/managing-organization-settings/managing-custom-properties-for-repositories-in-your-organization">custom
repository properties</a> on GitHub Enterprise Server, enabling the
customization of features such as
<code>github-codeql-disable-overlay</code> that was previously only
available on GitHub.com. <a
href="https://redirect.github.com/github/codeql-action/pull/3559">#3559</a></p>
</li>
<li>
<p>Once <a
href="https://docs.github.com/en/code-security/how-tos/secure-at-scale/configure-organization-security/manage-usage-and-access/giving-org-access-private-registries">private
package registries</a> can be configured with OIDC-based authentication
for organizations, the CodeQL Action will now be able to accept such
configurations. <a
href="https://redirect.github.com/github/codeql-action/pull/3563">#3563</a></p>
</li>
<li>
<p>Fixed the retry mechanism for database uploads. Previously this would
fail with the error &quot;Response body object should not be disturbed
or locked&quot;. <a
href="https://redirect.github.com/github/codeql-action/pull/3564">#3564</a></p>
</li>
<li>
<p>A warning is now emitted if the CodeQL Action detects a repository
property whose name suggests that it relates to the CodeQL Action, but
which is not one of the properties recognised by the current version of
the CodeQL Action. <a
href="https://redirect.github.com/github/codeql-action/pull/3570">#3570</a></p>
</li>
</ul>
<h2>4.32.6 - 05 Mar 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/95e58e9a2cdfd71adc6e0353d5c52f41a045d225"><code>95e58e9</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/3824">#3824</a>
from github/update-v4.35.2-d2e135a73</li>
<li><a
href="https://github.com/github/codeql-action/commit/6f31bfe060e817d81e938dbec767969d20031e25"><code>6f31bfe</code></a>
Update changelog for v4.35.2</li>
<li><a
href="https://github.com/github/codeql-action/commit/d2e135a73a39154e3a231aeb49163c4661c5b8b1"><code>d2e135a</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/3823">#3823</a>
from github/update-bundle/codeql-bundle-v2.25.2</li>
<li><a
href="https://github.com/github/codeql-action/commit/60abb65df09fcf213c398e064c8a80db1f15cdaf"><code>60abb65</code></a>
Add changelog note</li>
<li><a
href="https://github.com/github/codeql-action/commit/5a0a562209255e956ad8aafcee303294e64eefa2"><code>5a0a562</code></a>
Update default bundle to codeql-bundle-v2.25.2</li>
<li><a
href="https://github.com/github/codeql-action/commit/65216971a11ded447a6b76263d5a144519e5eee1"><code>6521697</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/3820">#3820</a>
from github/dependabot/github_actions/dot-github/wor...</li>
<li><a
href="https://github.com/github/codeql-action/commit/3c45af2dd258e1623af1898da5c86545b514e028"><code>3c45af2</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/3821">#3821</a>
from github/dependabot/npm_and_yarn/npm-minor-345b93...</li>
<li><a
href="https://github.com/github/codeql-action/commit/f1c339364c12f922998186ed897e45e3b4ae8874"><code>f1c3393</code></a>
Rebuild</li>
<li><a
href="https://github.com/github/codeql-action/commit/1024fc496c87e944a93e98d8cf2c09e2c7602a30"><code>1024fc4</code></a>
Rebuild</li>
<li><a
href="https://github.com/github/codeql-action/commit/9dd4cfed96030ccdfe1af4daf7a7964322704fed"><code>9dd4cfe</code></a>
Bump the npm-minor group across 1 directory with 6 updates</li>
<li>Additional commits viewable in <a
href="https://github.com/github/codeql-action/compare/c10b8064de6f491fea524254123dbe5e09572f13...95e58e9a2cdfd71adc6e0353d5c52f41a045d225">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-04-23 21:02:44 +01:00
Alex 819d794039 UI enhancement pass: Image streaming, dropdown UI fixes (#909)
- Added max image sizes and compression for frontend cached images
- Fixed various UI issues with dropdown placement and animation
2026-04-23 20:57:15 +01:00
Alex e0980a84d9 Fix temp filename max size (#912)
Fixes #891
2026-04-23 20:57:00 +01:00
Alex b8fdb2c841 Backend auth config check (#911)
Enforces auth config required fields, providing a warning if fields are
left empty upon save

Fixes #907
2026-04-23 20:56:45 +01:00
Alex 8bba0e28d7 Improved combined search behavior (#910)
- Added functionality to continue with a combined search if one option
is unfulfilled
- Added option to deselect currently selected release

Fixes #890
2026-04-23 20:56:32 +01:00
Alex 791ba184ee PrimaryTitle field and path template enhancements (#908)
- Added `PrimaryTitle` field for renaming templates
- Improved UI for creating a template, including live preview and custom
field picker

Fixes #905
2026-04-23 20:56:10 +01:00
Alex e35b4c47a7 Direct source refactor (#895)
- Updated mirror selection
- Removed built-in mirror options, users must provide their own
configurations
- Set Universal search to default, added ability to disable direct
source
- Updated documentation
- Updated makefile
2026-04-15 18:50:13 +01:00
blades 18a3f0bf44 Newznab capability (#867)
I've added a plugin using the same architecture as the prowlarr plugin
to enable Newznab as a source.
I've tested locally with nzbhydra2 and it all seems to work as intended.
I've added some unit tests for this feature, and found that a couple of
other unit tests weren't behaving so fixed those up while I was at it. I
also ran all of the linters in the makefile against it and fixed those
up, too, so hopefully this should be as clean and as compatible as it
can be.
2026-04-14 09:43:03 +01:00
Alex 0f7bcf8fd9 Fix lockfile (#888) 2026-04-13 11:40:41 +01:00
Alex 98f891916d Remove slash cleanup for OIDC discovery URL (#887)
Fixes #869
2026-04-13 11:21:44 +01:00
Alex a7694eb8ad Frontend tooling + Linter pass + React rule enforcement (#886)
- Added `oxlint`, `oxfmt`, `vitest`, `knip`
- Configured oxlint rules including react best practices (e.g. for
effect usage)
- Full linting pass on frontend code
- Full react rules pass on effect usage
- Full reformatting using oxfmt
- Full dead code cleanup using knip
2026-04-13 10:56:09 +01:00
Alex 87d5f127d6 Add prek + pytest-cov (#873) 2026-04-12 12:39:15 +01:00
Alex d7b9f2e67f Backend test hardening + quality enforcement (#872)
- Reworked many tests
- Enforcing lint + type checking for test suite
- Fixed various issues surfaced by the new tests
- CI tweaks
2026-04-12 12:01:52 +01:00
Alex 41c4aa1d72 Updated permissions model and non-root support (#871)
- Adds a non-root startup path at user 1000:1000 - skips privilege
escalation and ownership checks. Works e.g. for kubernetes deployments
(user 1000:1000 and runAsNonRoot enabled).
- Remove startup check/chown commands for user-owned folders. Checks can
be done with a "Test destination" button in settings which performs a
test write. Users are responsible for fixing their own permissions.
- Update docs
2026-04-12 08:22:32 +01:00
Alex 704da62202 Typecheck enforcement (#870)
All BasedPyright standard rules enforced and corrected
2026-04-12 07:35:36 +01:00
Alex 8d98e122ec Linter followup (#868)
Expanded Ruff rules and completed fixes
2026-04-11 16:18:52 +01:00
Alex 28eef75de0 Update path handling errors (#864) 2026-04-11 08:29:21 +01:00
Alex b3b8f34a13 Fix JSON script blocking behavior + tests (#862)
Fixes #859
2026-04-10 20:46:07 +01:00
Alex 8e78fea947 Add BasedPyright + Makefile commands (#858) 2026-04-10 17:12:54 +01:00
Alex 7bc6a9f8c6 Switch dev builds to nightly schedule (#856)
- `dev` images generated on a nightly schedule if new commits exist
- lite container smoke test added to PR CI checks
2026-04-10 15:12:23 +01:00
Alex 962e0ec68b Regenerate frontend lockfile (#855) 2026-04-10 14:33:47 +01:00
Alex 3d68b5eb2f Bump Vite to 8.0.8 + Typescript to 6.0.2 (#854) 2026-04-10 14:26:14 +01:00
dependabot[bot] ba4090aee2 Bump ruff from 0.15.9 to 0.15.10 (#848)
Bumps [ruff](https://github.com/astral-sh/ruff) from 0.15.9 to 0.15.10.
<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.10</h2>
<h2>Release Notes</h2>
<p>Released on 2026-04-09.</p>
<h3>Preview features</h3>
<ul>
<li>[<code>flake8-logging</code>] Allow closures in except handlers
(<code>LOG004</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/24464">#24464</a>)</li>
<li>[<code>flake8-self</code>] Make <code>SLF</code> diagnostics robust
to non-self-named variables (<a
href="https://redirect.github.com/astral-sh/ruff/pull/24281">#24281</a>)</li>
<li>[<code>flake8-simplify</code>] Make the fix for
<code>collapsible-if</code> safe in <code>preview</code>
(<code>SIM102</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/24371">#24371</a>)</li>
</ul>
<h3>Bug fixes</h3>
<ul>
<li>Avoid emitting multi-line f-string elements before Python 3.12 (<a
href="https://redirect.github.com/astral-sh/ruff/pull/24377">#24377</a>)</li>
<li>Avoid syntax error from <code>E502</code> fixes in f-strings and
t-strings (<a
href="https://redirect.github.com/astral-sh/ruff/pull/24410">#24410</a>)</li>
<li>Strip form feeds from indent passed to <code>dedent_to</code> (<a
href="https://redirect.github.com/astral-sh/ruff/pull/24381">#24381</a>)</li>
<li>[<code>pyupgrade</code>] Fix panic caused by handling of octals
(<code>UP012</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/24390">#24390</a>)</li>
<li>Reject multi-line f-string elements before Python 3.12 (<a
href="https://redirect.github.com/astral-sh/ruff/pull/24355">#24355</a>)</li>
</ul>
<h3>Rule changes</h3>
<ul>
<li>[<code>ruff</code>] Treat f-string interpolation as potential side
effect (<code>RUF019</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/24426">#24426</a>)</li>
</ul>
<h3>Server</h3>
<ul>
<li>Add support for custom file extensions (<a
href="https://redirect.github.com/astral-sh/ruff/pull/24463">#24463</a>)</li>
</ul>
<h3>Documentation</h3>
<ul>
<li>Document adding fixes in CONTRIBUTING.md (<a
href="https://redirect.github.com/astral-sh/ruff/pull/24393">#24393</a>)</li>
<li>Fix JSON typo in settings example (<a
href="https://redirect.github.com/astral-sh/ruff/pull/24517">#24517</a>)</li>
</ul>
<h3>Contributors</h3>
<ul>
<li><a
href="https://github.com/charliermarsh"><code>@​charliermarsh</code></a></li>
<li><a href="https://github.com/dylwil3"><code>@​dylwil3</code></a></li>
<li><a
href="https://github.com/silverstein"><code>@​silverstein</code></a></li>
<li><a
href="https://github.com/anishgirianish"><code>@​anishgirianish</code></a></li>
<li><a
href="https://github.com/shizukushq"><code>@​shizukushq</code></a></li>
<li><a href="https://github.com/zanieb"><code>@​zanieb</code></a></li>
<li><a
href="https://github.com/AlexWaygood"><code>@​AlexWaygood</code></a></li>
</ul>
<h2>Install ruff 0.15.10</h2>
<h3>Install prebuilt binaries via shell script</h3>
<pre lang="sh"><code>curl --proto '=https' --tlsv1.2 -LsSf
https://releases.astral.sh/github/ruff/releases/download/0.15.10/ruff-installer.sh
| sh
</code></pre>
<!-- 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.15.10</h2>
<p>Released on 2026-04-09.</p>
<h3>Preview features</h3>
<ul>
<li>[<code>flake8-logging</code>] Allow closures in except handlers
(<code>LOG004</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/24464">#24464</a>)</li>
<li>[<code>flake8-self</code>] Make <code>SLF</code> diagnostics robust
to non-self-named variables (<a
href="https://redirect.github.com/astral-sh/ruff/pull/24281">#24281</a>)</li>
<li>[<code>flake8-simplify</code>] Make the fix for
<code>collapsible-if</code> safe in <code>preview</code>
(<code>SIM102</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/24371">#24371</a>)</li>
</ul>
<h3>Bug fixes</h3>
<ul>
<li>Avoid emitting multi-line f-string elements before Python 3.12 (<a
href="https://redirect.github.com/astral-sh/ruff/pull/24377">#24377</a>)</li>
<li>Avoid syntax error from <code>E502</code> fixes in f-strings and
t-strings (<a
href="https://redirect.github.com/astral-sh/ruff/pull/24410">#24410</a>)</li>
<li>Strip form feeds from indent passed to <code>dedent_to</code> (<a
href="https://redirect.github.com/astral-sh/ruff/pull/24381">#24381</a>)</li>
<li>[<code>pyupgrade</code>] Fix panic caused by handling of octals
(<code>UP012</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/24390">#24390</a>)</li>
<li>Reject multi-line f-string elements before Python 3.12 (<a
href="https://redirect.github.com/astral-sh/ruff/pull/24355">#24355</a>)</li>
</ul>
<h3>Rule changes</h3>
<ul>
<li>[<code>ruff</code>] Treat f-string interpolation as potential side
effect (<code>RUF019</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/24426">#24426</a>)</li>
</ul>
<h3>Server</h3>
<ul>
<li>Add support for custom file extensions (<a
href="https://redirect.github.com/astral-sh/ruff/pull/24463">#24463</a>)</li>
</ul>
<h3>Documentation</h3>
<ul>
<li>Document adding fixes in CONTRIBUTING.md (<a
href="https://redirect.github.com/astral-sh/ruff/pull/24393">#24393</a>)</li>
<li>Fix JSON typo in settings example (<a
href="https://redirect.github.com/astral-sh/ruff/pull/24517">#24517</a>)</li>
</ul>
<h3>Contributors</h3>
<ul>
<li><a
href="https://github.com/charliermarsh"><code>@​charliermarsh</code></a></li>
<li><a href="https://github.com/dylwil3"><code>@​dylwil3</code></a></li>
<li><a
href="https://github.com/silverstein"><code>@​silverstein</code></a></li>
<li><a
href="https://github.com/anishgirianish"><code>@​anishgirianish</code></a></li>
<li><a
href="https://github.com/shizukushq"><code>@​shizukushq</code></a></li>
<li><a href="https://github.com/zanieb"><code>@​zanieb</code></a></li>
<li><a
href="https://github.com/AlexWaygood"><code>@​AlexWaygood</code></a></li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/astral-sh/ruff/commit/252f76102a618bff6537b6c53c316ca3837f4abf"><code>252f761</code></a>
Bump 0.15.10 (<a
href="https://redirect.github.com/astral-sh/ruff/issues/24519">#24519</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/37a1ec8bb8e30955787b0cdf6e97f7f2254dba7f"><code>37a1ec8</code></a>
[ty] Fix assignability of intersections with bounded typevars (<a
href="https://redirect.github.com/astral-sh/ruff/issues/24502">#24502</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/f518cc9ca0c830773dd49c3964eb5e49d52c8aed"><code>f518cc9</code></a>
[ty] Allow partially stringified <code>type[…]</code> annotations (<a
href="https://redirect.github.com/astral-sh/ruff/issues/24518">#24518</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/16c4090d0a711b9c0523b932014f3daf140f35bc"><code>16c4090</code></a>
docs: fix JSON typo in settings example (<a
href="https://redirect.github.com/astral-sh/ruff/issues/24517">#24517</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/99d97bd72f1934ac2af93e52468c10ef1c7a1a4e"><code>99d97bd</code></a>
[ty] Tighten up a few edge cases in <code>Concatenate</code>
type-expression parsing (<a
href="https://redirect.github.com/astral-sh/ruff/issues/2">#2</a>...</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/2714e345bdd64a5baae3844c0d25db7b0b9fe330"><code>2714e34</code></a>
[ty] Enable <code>pull-diagnostics</code> by default in E2E tests (<a
href="https://redirect.github.com/astral-sh/ruff/issues/24516">#24516</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/d8bc700722ab1b7272a4d724839da7c569b349d4"><code>d8bc700</code></a>
LSP: Add support for custom extensions (<a
href="https://redirect.github.com/astral-sh/ruff/issues/24463">#24463</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/a45f96d65dbd4f958b07accd718f8d2af48cb956"><code>a45f96d</code></a>
[ty] stop special-casing str constructor (<a
href="https://redirect.github.com/astral-sh/ruff/issues/24514">#24514</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/87a0f01cfd016e0297ef05ab638cde006bf8d947"><code>87a0f01</code></a>
[ruff] Treat f-string interpolation as potential side effect in RUF019
(<a
href="https://redirect.github.com/astral-sh/ruff/issues/24426">#24426</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/e9ba8489b8d1f1fd5fd66887a74d5f2f58f733d4"><code>e9ba848</code></a>
[ty] Fix excess subscript argument inference for non-generic types (<a
href="https://redirect.github.com/astral-sh/ruff/issues/24354">#24354</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/astral-sh/ruff/compare/0.15.9...0.15.10">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=ruff&package-manager=uv&previous-version=0.15.9&new-version=0.15.10)](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-04-10 13:40:28 +01:00
dependabot[bot] 03ec7d1c06 Bump the gh-actions group across 1 directory with 2 updates (#851)
Bumps the gh-actions group with 2 updates in the / directory:
[docker/build-push-action](https://github.com/docker/build-push-action)
and [astral-sh/setup-uv](https://github.com/astral-sh/setup-uv).

Updates `docker/build-push-action` from 7.0.0 to 7.1.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.1.0</h2>
<ul>
<li>Git context <a
href="https://docs.docker.com/build/concepts/context/#url-queries">query
format</a> support 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/1505">docker/build-push-action#1505</a></li>
<li>Bump <code>@​docker/actions-toolkit</code> from 0.79.0 to 0.87.0 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/1505">docker/build-push-action#1505</a></li>
<li>Bump brace-expansion from 1.1.12 to 1.1.13 in <a
href="https://redirect.github.com/docker/build-push-action/pull/1500">docker/build-push-action#1500</a></li>
<li>Bump fast-xml-parser from 5.4.2 to 5.5.7 in <a
href="https://redirect.github.com/docker/build-push-action/pull/1489">docker/build-push-action#1489</a></li>
<li>Bump flatted from 3.3.3 to 3.4.2 in <a
href="https://redirect.github.com/docker/build-push-action/pull/1491">docker/build-push-action#1491</a></li>
<li>Bump glob from 10.3.12 to 10.5.0 in <a
href="https://redirect.github.com/docker/build-push-action/pull/1490">docker/build-push-action#1490</a></li>
<li>Bump handlebars from 4.7.8 to 4.7.9 in <a
href="https://redirect.github.com/docker/build-push-action/pull/1497">docker/build-push-action#1497</a></li>
<li>Bump lodash from 4.17.23 to 4.18.1 in <a
href="https://redirect.github.com/docker/build-push-action/pull/1510">docker/build-push-action#1510</a></li>
<li>Bump picomatch from 4.0.3 to 4.0.4 in <a
href="https://redirect.github.com/docker/build-push-action/pull/1496">docker/build-push-action#1496</a></li>
<li>Bump undici from 6.23.0 to 6.24.1 in <a
href="https://redirect.github.com/docker/build-push-action/pull/1486">docker/build-push-action#1486</a></li>
<li>Bump vite from 7.3.1 to 7.3.2 in <a
href="https://redirect.github.com/docker/build-push-action/pull/1509">docker/build-push-action#1509</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/docker/build-push-action/compare/v7.0.0...v7.1.0">https://github.com/docker/build-push-action/compare/v7.0.0...v7.1.0</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/docker/build-push-action/commit/bcafcacb16a39f128d818304e6c9c0c18556b85f"><code>bcafcac</code></a>
Merge pull request <a
href="https://redirect.github.com/docker/build-push-action/issues/1509">#1509</a>
from docker/dependabot/npm_and_yarn/vite-7.3.2</li>
<li><a
href="https://github.com/docker/build-push-action/commit/18e62f1158d9c45a4a84a58a6828d21f8ed3644b"><code>18e62f1</code></a>
Merge pull request <a
href="https://redirect.github.com/docker/build-push-action/issues/1510">#1510</a>
from docker/dependabot/npm_and_yarn/lodash-4.18.1</li>
<li><a
href="https://github.com/docker/build-push-action/commit/46580d2c9d43b0888270cb6fa90956e483de56fc"><code>46580d2</code></a>
chore: update generated content</li>
<li><a
href="https://github.com/docker/build-push-action/commit/3f80b252ca2331f6ec3e890f4346b5506ee1dc81"><code>3f80b25</code></a>
chore(deps): Bump lodash from 4.17.23 to 4.18.1</li>
<li><a
href="https://github.com/docker/build-push-action/commit/efeec9557c40a646afe433e39a1e94ca689103f0"><code>efeec95</code></a>
Merge pull request <a
href="https://redirect.github.com/docker/build-push-action/issues/1505">#1505</a>
from crazy-max/refactor-git-context</li>
<li><a
href="https://github.com/docker/build-push-action/commit/ddf04b08eb12882258ed936fea4a2806754ff349"><code>ddf04b0</code></a>
Merge pull request <a
href="https://redirect.github.com/docker/build-push-action/issues/1511">#1511</a>
from docker/dependabot/github_actions/crazy-max-dot-...</li>
<li><a
href="https://github.com/docker/build-push-action/commit/db08d97a08e4a0d15f85d1c4e64dfd5f88cbe1a9"><code>db08d97</code></a>
chore(deps): Bump the crazy-max-dot-github group with 2 updates</li>
<li><a
href="https://github.com/docker/build-push-action/commit/ef1fb9688fc3626d0fd5e462f502cbbdc6456feb"><code>ef1fb96</code></a>
Merge pull request <a
href="https://redirect.github.com/docker/build-push-action/issues/1508">#1508</a>
from docker/dependabot/github_actions/docker/login-a...</li>
<li><a
href="https://github.com/docker/build-push-action/commit/2d8f2a1a378a5c302dcd7b2b4326cefa24180bb1"><code>2d8f2a1</code></a>
chore: update generated content</li>
<li><a
href="https://github.com/docker/build-push-action/commit/919ac7bd7d1aa8cb13fe4de76545abea8d8b5ed2"><code>919ac7b</code></a>
fix test since secrets are not written to temp path anymore</li>
<li>Additional commits viewable in <a
href="https://github.com/docker/build-push-action/compare/d08e5c354a6adb9ed34480a06d141179aa583294...bcafcacb16a39f128d818304e6c9c0c18556b85f">compare
view</a></li>
</ul>
</details>
<br />

Updates `astral-sh/setup-uv` from 7.3.1 to 8.0.0
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/astral-sh/setup-uv/releases">astral-sh/setup-uv's
releases</a>.</em></p>
<blockquote>
<h2>v8.0.0 🌈 Immutable releases and secure tags</h2>
<h1>This is the first immutable release of <code>setup-uv</code> 🥳</h1>
<p>All future releases are also immutable, if you want to know more
about what this means checkout <a
href="https://docs.github.com/en/code-security/concepts/supply-chain-security/immutable-releases">the
docs</a>.</p>
<p>This release also has two breaking changes</p>
<h2>New format for <code>manifest-file</code></h2>
<p>The previously deprecated way of defining a custom version manifest
to control which <code>uv</code> versions are available and where to
download them from got removed. The functionality is still there but you
have to use the <a
href="https://github.com/astral-sh/setup-uv/blob/main/docs/customization.md#format">new
format</a>.</p>
<h2>No more major and minor tags</h2>
<p>To increase <strong>security</strong> even more we will <strong>stop
publishing minor tags</strong>. You won't be able to use
<code>@v8</code> or <code>@v8.0</code> any longer. We do this because
pinning to major releases opens up users to supply chain attacks like
what happened to <a
href="https://unit42.paloaltonetworks.com/github-actions-supply-chain-attack/">tj-actions</a>.</p>
<blockquote>
<p>[!TIP]
Use the immutable tag as a version
<code>astral-sh/setup-uv@v8.0.0</code>
Or even better the githash
<code>astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57</code></p>
</blockquote>
<h2>🚨 Breaking changes</h2>
<ul>
<li>Remove update-major-minor-tags workflow <a
href="https://github.com/eifinger"><code>@​eifinger</code></a> (<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/826">#826</a>)</li>
<li>Remove deprecrated custom manifest <a
href="https://github.com/eifinger"><code>@​eifinger</code></a> (<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/813">#813</a>)</li>
</ul>
<h2>🧰 Maintenance</h2>
<ul>
<li>Shortcircuit latest version from manifest <a
href="https://github.com/eifinger"><code>@​eifinger</code></a> (<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/828">#828</a>)</li>
<li>Simplify inputs.ts <a
href="https://github.com/eifinger"><code>@​eifinger</code></a> (<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/827">#827</a>)</li>
<li>Bump release-drafter to v7.1.1 <a
href="https://github.com/eifinger"><code>@​eifinger</code></a> (<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/825">#825</a>)</li>
<li>Refactor inputs <a
href="https://github.com/eifinger"><code>@​eifinger</code></a> (<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/823">#823</a>)</li>
<li>Replace inline compile args with tsconfig <a
href="https://github.com/eifinger"><code>@​eifinger</code></a> (<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/824">#824</a>)</li>
<li>chore: update known checksums for 0.11.2 @<a
href="https://github.com/apps/github-actions">github-actions[bot]</a>
(<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/821">#821</a>)</li>
<li>chore: update known checksums for 0.11.1 @<a
href="https://github.com/apps/github-actions">github-actions[bot]</a>
(<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/817">#817</a>)</li>
<li>chore: update known checksums for 0.11.0 @<a
href="https://github.com/apps/github-actions">github-actions[bot]</a>
(<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/815">#815</a>)</li>
<li>Fix latest-version workflow check <a
href="https://github.com/eifinger"><code>@​eifinger</code></a> (<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/812">#812</a>)</li>
<li>chore: update known checksums for 0.10.11/0.10.12 @<a
href="https://github.com/apps/github-actions">github-actions[bot]</a>
(<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/811">#811</a>)</li>
</ul>
<h2>v7.6.0 🌈 Fetch uv from Astral's mirror by default</h2>
<h2>Changes</h2>
<p>We now default to download uv from <code>releases.astral.sh</code>.
This means by default we don't hit the GitHub API at all and shouldn't
see any rate limits and timeouts any more.</p>
<h2>🚀 Enhancements</h2>
<ul>
<li>Fetch uv from Astral's mirror by default <a
href="https://github.com/zsol"><code>@​zsol</code></a> (<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/809">#809</a>)</li>
</ul>
<h2>🧰 Maintenance</h2>
<ul>
<li>Switch to ESM for source and test, use CommonJS for dist <a
href="https://github.com/eifinger"><code>@​eifinger</code></a> (<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/806">#806</a>)</li>
<li>chore: update known checksums for 0.10.10 @<a
href="https://github.com/apps/github-actions">github-actions[bot]</a>
(<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/804">#804</a>)</li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/astral-sh/setup-uv/commit/cec208311dfd045dd5311c1add060b2062131d57"><code>cec2083</code></a>
Shortcircuit latest version from manifest (<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/828">#828</a>)</li>
<li><a
href="https://github.com/astral-sh/setup-uv/commit/4dd8ab45206a76f8c1dfe399fa88df10a7264f27"><code>4dd8ab4</code></a>
Simplify inputs.ts (<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/827">#827</a>)</li>
<li><a
href="https://github.com/astral-sh/setup-uv/commit/7fdbe7cf0c8ef50cfd0878eed7b5180abc6b53c7"><code>7fdbe7c</code></a>
Remove update-major-minor-tags workflow (<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/826">#826</a>)</li>
<li><a
href="https://github.com/astral-sh/setup-uv/commit/485abd05e5c74a247f0a309e333d2433ab9a353a"><code>485abd0</code></a>
Bump release-drafter to v7.1.1 (<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/825">#825</a>)</li>
<li><a
href="https://github.com/astral-sh/setup-uv/commit/f82eb19c06057c455674b2602e0139fd906f1428"><code>f82eb19</code></a>
Refactor inputs (<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/823">#823</a>)</li>
<li><a
href="https://github.com/astral-sh/setup-uv/commit/868d1f74d9d862d7b40219546bfe35299c6dd452"><code>868d1f7</code></a>
Replace inline compile args with tsconfig (<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/824">#824</a>)</li>
<li><a
href="https://github.com/astral-sh/setup-uv/commit/447e6d02b15d65b3247cce2d6019f11957285d11"><code>447e6d0</code></a>
chore: update known checksums for 0.11.2 (<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/821">#821</a>)</li>
<li><a
href="https://github.com/astral-sh/setup-uv/commit/5c62c5926145985eec91f09e2e0a75f40daed929"><code>5c62c59</code></a>
chore: update known checksums for 0.11.1 (<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/817">#817</a>)</li>
<li><a
href="https://github.com/astral-sh/setup-uv/commit/e1a7373adb857afd2a70b971e8ebdacc64ed27d0"><code>e1a7373</code></a>
chore: update known checksums for 0.11.0 (<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/815">#815</a>)</li>
<li><a
href="https://github.com/astral-sh/setup-uv/commit/89709315bb3bd4bf0f4b1db4b710e99009087ab5"><code>8970931</code></a>
Remove deprecrated custom manifest (<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/813">#813</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/astral-sh/setup-uv/compare/5a095e7a2014a4212f075830d4f7277575a9d098...cec208311dfd045dd5311c1add060b2062131d57">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-04-10 13:40:18 +01:00
dependabot[bot] 71ee56b7b7 Bump the npm-deps group across 1 directory with 4 updates (#852)
Bumps the npm-deps group with 4 updates in the /src/frontend directory:
[react](https://github.com/facebook/react/tree/HEAD/packages/react),
[react-dom](https://github.com/facebook/react/tree/HEAD/packages/react-dom),
[@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node)
and [postcss](https://github.com/postcss/postcss).

Updates `react` from 19.2.4 to 19.2.5
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/facebook/react/releases">react's
releases</a>.</em></p>
<blockquote>
<h2>19.2.5 (April 8th, 2026)</h2>
<h2>React Server Components</h2>
<ul>
<li>Add more cycle protections (<a
href="https://redirect.github.com/facebook/react/pull/36236">#36236</a>
by <a href="https://github.com/eps1lon"><code>@​eps1lon</code></a> and
<a
href="https://github.com/unstubbable"><code>@​unstubbable</code></a>)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/facebook/react/commit/23f4f9f30da9e9af2108c18bb197bae75ab584ea"><code>23f4f9f</code></a>
19.2.5</li>
<li>See full diff in <a
href="https://github.com/facebook/react/commits/v19.2.5/packages/react">compare
view</a></li>
</ul>
</details>
<br />

Updates `react-dom` from 19.2.4 to 19.2.5
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/facebook/react/releases">react-dom's
releases</a>.</em></p>
<blockquote>
<h2>19.2.5 (April 8th, 2026)</h2>
<h2>React Server Components</h2>
<ul>
<li>Add more cycle protections (<a
href="https://redirect.github.com/facebook/react/pull/36236">#36236</a>
by <a href="https://github.com/eps1lon"><code>@​eps1lon</code></a> and
<a
href="https://github.com/unstubbable"><code>@​unstubbable</code></a>)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/facebook/react/commit/23f4f9f30da9e9af2108c18bb197bae75ab584ea"><code>23f4f9f</code></a>
19.2.5</li>
<li>See full diff in <a
href="https://github.com/facebook/react/commits/v19.2.5/packages/react-dom">compare
view</a></li>
</ul>
</details>
<br />

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

Updates `postcss` from 8.5.8 to 8.5.9
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/postcss/postcss/releases">postcss's
releases</a>.</em></p>
<blockquote>
<h2>8.5.9</h2>
<ul>
<li>Speed up source map encoding paring in case of the error.</li>
</ul>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/postcss/postcss/blob/main/CHANGELOG.md">postcss's
changelog</a>.</em></p>
<blockquote>
<h2>8.5.9</h2>
<ul>
<li>Speed up source map encoding paring in case of the error.</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/postcss/postcss/commit/fe88ac29c06b7b218be32994cdc6ca1525bdf2c9"><code>fe88ac2</code></a>
Release 8.5.9 version</li>
<li><a
href="https://github.com/postcss/postcss/commit/c551632496b87ab3f1965bfda5dc386b6c71963e"><code>c551632</code></a>
Avoid RegExp when we can use simple JS</li>
<li><a
href="https://github.com/postcss/postcss/commit/89a6b744060eb8dee743351c785a9fbe37d4525a"><code>89a6b74</code></a>
Move SECURITY.txt for docs folder to keep GitHub page cleaner</li>
<li><a
href="https://github.com/postcss/postcss/commit/6ceb8a46af9f9de821faee98f861bdf84617347b"><code>6ceb8a4</code></a>
Create SECURITY.md</li>
<li><a
href="https://github.com/postcss/postcss/commit/02ccae6bc359ee386f84d8fbc36eb423acbdbc3f"><code>02ccae6</code></a>
Another way to fix CI with .ts ext in tests on old Node.js</li>
<li><a
href="https://github.com/postcss/postcss/commit/2c36658c7cd4ffee4d42fafd0a501b9c13b63a52"><code>2c36658</code></a>
Another way to fix CI with TS on old Node.js</li>
<li><a
href="https://github.com/postcss/postcss/commit/b90600361f33a3834fd708831819cb1106590908"><code>b906003</code></a>
Another way to fix CI with old Node.js</li>
<li><a
href="https://github.com/postcss/postcss/commit/04d32cdc69ac598fcd33d3d748de12c4c32e83fb"><code>04d32cd</code></a>
Fix another issue with Node.js 10 on CI</li>
<li><a
href="https://github.com/postcss/postcss/commit/df86cdfff016205260901394432574ecd2a2fa65"><code>df86cdf</code></a>
Try to fix Node.js 10 on CI</li>
<li><a
href="https://github.com/postcss/postcss/commit/82bec0dd8e194f653743f606e6a2b7e27cde9088"><code>82bec0d</code></a>
Move to oxfmt</li>
<li>Additional commits viewable in <a
href="https://github.com/postcss/postcss/compare/8.5.8...8.5.9">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-04-10 13:40:08 +01:00
Alex e3d5bd91fc Bump node to 24 LTS (#853) 2026-04-10 13:29:48 +01:00
Alex af38540991 Group dependabot PRs (#849) 2026-04-10 13:07:59 +01:00
Alex 3a3a3ce449 Add new python tooling + apply ruff linter cleanup (#845)
- Adds `uv`, `ruff`, `pyright`, `vulture` and `pytest-xdist`
- Move project, lockfile, docker build etc to uv
- Align python tooling on 3.14
- Huge bulk of ruff linter fixes applied. Still in progress but all the
core types are now enforced
- Update CI and test helpers
2026-04-10 13:03:25 +01:00
Alex ff094bed56 Fix: Orchestrator timeout and exception handling (#832)
Fixes #823
2026-04-03 09:38:58 +01:00
Alex c1143f808a Batch dependency updates (#831)
- github/codeql-action 3.32.6 → 4.35.1
- seleniumbase 4.47.3 → 4.47.9
- react-router-dom 7.13.1 → 7.14.0
- docker/login-action 3.7.0 → 4.1.0
- tailwindcss 4.2.1 → 4.2.2
- @tailwindcss/vite 4.2.1 → 4.2.2
- @tailwindcss/postcss 4.2.1 → 4.2.2
- actions/setup-python 5.6.0 → 6.2.0
- docker/setup-buildx-action 3.12.0 → 4.0.0
2026-04-03 09:36:31 +01:00
Alex 9bfcf828ea Fixes: Env variable config usage, retry availability, Entrypoint permissions (#817)
- Clean up a few uses of config options that may miss the env variable
if this is set
- Add enhanced retry availability utilising the DB to persist download
errors / retries across restarts, request failures, and pass Prowlarr
detail through the download task to maintain retry data.
- Strip back entrypoint permissions for less intensive chown operations.

Fixes #796
2026-03-29 16:39:40 +01:00
Alex 678c54cba2 Fixes: Entrypoint, seedtime, request policy flow (#805)
- Added a path for rootless permissions in the entrypoint script
- Routed prowlarr searches through torznab for seedtime info
- Added additional request flow for download permissions
2026-03-25 18:34:42 +00:00
Alex 019d36b27e Rename Booklore to Grimmory 2 (#792) 2026-03-21 15:48:06 +00:00
Alex 698eb07e71 Rename Booklore to Grimmory (#791) 2026-03-21 15:38:38 +00:00
Alex 8f949a73d5 Remove audible provider (#778) 2026-03-18 18:29:13 +00:00
Alex 2c6f46fc88 Combined mode follow-up (#777) 2026-03-18 18:27:39 +00:00
Alex 3f90c3805f Use title+author query for whitelisted indexers (#774) 2026-03-16 19:52:43 +00:00
Alex cb093f61c6 Feature: Combined book+audiobook downloads (#773)
- Adds a combined search option in the search bar selector
- Choose both a book and audiobook file in a two-step release modal, and
download both simultaneously from a single search result.
- Works for requests. Request both a book+audiobook at once, or works
seamlessly with request policies that differ between book + audiobook
(E.g. automatically download the ebook portion, while the audiobook gets
sent as a request)
- Hidden for users who have book or audiobooks blocked. 

Closes #611
2026-03-16 18:34:46 +00:00
Alex b464d62672 Fix entrypoint gosu test write (#772)
- Let gosu run the full test write
- Delete stale compose

Fixes #771 - gosu pipe broken on Proxmox LXC installs
2026-03-15 18:04:41 +00:00
Alex f3f26488b1 Fix TS error (#770) 2026-03-15 10:27:03 +00:00
Alex fec9d31c8a Frontend improvements for Audible provider (#769)
- Added square artwork support
- Added dedicated length and narrator icons
- Added audiobook info to release and details modals
- Moved search options button to accommodate larger Audible search
fields
2026-03-15 10:17:37 +00:00
cadric 3295be82a7 Add Audible metadata provider via Audimeta (#762)
Closes #515

  ## Summary

This adds a new `audible` metadata provider backed by the Audimeta API.

  The provider supports:
  - Audible/Audimeta metadata lookup without authentication
- region selection (`us`, `ca`, `uk`, `au`, `fr`, `de`, `jp`, `it`,
`in`, `es`, `br`)
  - ASIN book lookup
  - ISBN lookup with fallback search
  - series suggestions and series-order browsing
- richer audiobook metadata such as narrators, runtime, rating,
subtitle, cover, publisher, and series info
- configurable Audimeta base URL, timeout, cache usage, default sort,
and unreleased filtering

  ## Notes

  A few Audimeta-specific integration details were needed:
- send a meaningful `User-Agent`, otherwise Audimeta rejects requests
with `403`
  - send the `cache` parameter in the format Audimeta expects
- use `keywords` for general search instead of `query`, which gave
poor/irrelevant results for title-style
  searches

  ## Validation

  Tested locally with:
  - `python -m py_compile shelfmark/metadata_providers/audible.py`
  - `python -m pytest tests/metadata/test_audible.py -v`
- `python -m pytest
tests/metadata/test_metadata_provider_capabilities.py -v`

  Also verified manually in a Podman test container:
  - searching for `Discount Dan` returns Audible title `B0DXLXRNGG`
  - book details and series metadata load correctly

  ## Scope

This PR intentionally keeps the change localized to the provider layer
and docs:
  - new Audible provider
  - provider registration
  - provider docs
  - generated environment variable docs
2026-03-15 10:09:24 +00:00
Alex fff0fd07a1 Fix stale activity dismiss handling (#768)
Fixes #764
2026-03-15 10:08:45 +00:00
Alexanddependabot[bot] 3f1a14843b Update to React 19 (#766)
Supersedes the React dependabot patches
- `react`
- `react-dom`
- `@types/react`
- `@types/react-dom`

Left out Vite / tooling updates

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-13 18:46:56 +00:00
Alexanddependabot[bot] 21a11b06b9 Dependency updates roll-up (#765)
Supersedes the dependabot PRs after testing: 
- `actions/attest-build-provenance`
- `docker/build-push-action`
- `actions/checkout`
- `seleniumbase`
- `docker/metadata-action`
- `actions/setup-node`
- `python:3.14-slim`
- `@types/node`
- `postcss`
- `react-router-dom`

Left out for now: 
- `node:25-alpine`
- `vite@8`
- `@vitejs/plugin-react@6`

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-13 18:41:02 +00:00
Alex 0d856a3ef5 CodeQL fixes (#763)
- Block SSRF in image cover proxy (validate URL scheme and reject
private IPs)
- Sanitize settings tab name to prevent path traversal
2026-03-13 17:47:34 +00:00
Alex ebf4312174 Repo spring cleaning (#746)
- Add CI workflow (pytest + frontend typecheck/tests) on PRs
- Add CodeQL static analysis for Python and JS/TS
- Add Dependabot for pip, npm, Docker, and GitHub Actions
- Tighten workflow permissions
2026-03-13 17:09:49 +00:00
Alex 685c35d552 Hardcover list separation + Browser download fix (#745)
- Added full Hardcover reading status types into the list selector
- Split reading status entries from dedicated lists
- Added option to disable the automatic removal of books when downloaded
from a Hardcover list
- Fixed browser download not firing when the completed state was
triggered in specific cases
2026-03-13 14:33:10 +00:00
Alex 3d72f9e258 Various requested small features (#741)
- Added torrent removal option
- Pass Prowlarr seedtimes to download clients (excluding rTorrent)
- Split default release source option by content type
- Split download to browser option by content type
- Add "hide links" option
2026-03-12 17:36:07 +00:00
Alex 7f79da11e6 Activity routes logging (#736)
- Added specific logging for activity routes errors
- Fixed scrollbars
2026-03-11 19:59:12 +00:00
Alex c59ea46540 Frontend update + Misc fixes (#735)
- Updated frontend CSS to Tailwind v4
- Reverted socket IO origin restriction
- Fixed search queries not persisting after auth redirect
- Move advanced search options to left UI selector
- Unlock IRC source to be used for audiobook content_type
- Tweaked security settings env var syncing to be prioritised
- Fix AA "all languages" query generation
- Added language-free AA query as second fallback in case of no results
- Testing moving SeleniumBase scratch files to /tmp via symlink
- Added enhanced logging for activity dismissals and other events
- Removed iFrame restrictions
2026-03-11 18:16:34 +00:00
Alex a2a5a22324 Fix theming bug (#722) 2026-03-07 19:13:28 +00:00
Alex a7db7f04e9 Hardcover tweaks (#720) 2026-03-07 18:16:40 +00:00
Alex 9d08bb3ef1 Expanded Hardcover list features (#719)
- Adds full interaction with Hardcover lists, including adding and
removing from lists + want to read status
- List selection exposed in search results, details modal and release
modal
- Added automatic list dropdown when selecting "list" search
- Added auto-removal of books from a list when downloading from that
specific list page
- Changed search selector to hover-activated
2026-03-07 15:33:46 +00:00
Alex 80aa289a64 Misc fixes (#718)
- Update file movement to prefer copy
- Improved mirror config overwriting on app updates
- Request / user DB hardening
2026-03-07 10:30:47 +00:00
Alex edb437e905 Fix sorting + update readme (#715)
- Harden default sort preference use + fix series ordering use
- Update readme with contribution and project scope disclaimers
2026-03-06 17:06:20 +00:00
Alex 72464e32b8 Update makefile test (#713) 2026-03-06 15:00:05 +00:00
Alex 60893b19c6 Search UI revamp, series search and search suggestions (#712)
- Restructured search field options into the left-hand selector.
Includes dynamic options for each provider.
- Moved Hardcover list and manual search mode into the left hand
selector
- Added search mode and metadata provider into the search options area
- Added new Hardcover series API query and live series suggestions
- Added live Hardcover author and title suggestions
2026-03-06 14:44:55 +00:00
Alex 8bb188c903 Refactor direct source to use universal API (#711) 2026-03-06 12:59:37 +00:00
Alex d6d10a450e Enhance Hardcover lists (#710) 2026-03-06 10:45:15 +00:00
Alex 4b0d1aef13 Download history refactor pt3 (#706)
- Added canonical per-user visibility of requests and downloads via new
activity view table. Users get fully independent activity and history
views, while admins still see all.
- Replaces janky frontend + backend combination
2026-03-05 19:53:22 +00:00
Giovanni Scieri 447ed1a924 fix(search): include default language in search query filters (#704)
## Bug description

When a default language was configured, it was **not passed as a search
filter**.
This occurred regardless of configuration via UI or environment
variables.

## Fix

Updated filter logic so that the default language is always applied when
no explicit filter is provided:

```python
for value in filters.lang if filters.lang else config.BOOK_LANGUAGE or []:
    if value and value != "all":
        filters_query += f"&lang={quote(value)}"
```

This ensures:
- the default language is used when available
- empty or invalid values are ignored
- "all" does not apply a language filter

## Testing
- default language via UI → search filters correctly
- default language via environment variable → search filters correctly
- "all" value → no language filter applied
2026-03-05 16:25:19 +00:00
Alex ba92ad90bc Refine UI and adjust content type settings (#705)
- Tweak manual search toggle position
- Refinements to the Hardcover list dropdown behavior
- Hide the content type dropdown when a content type is blocked for a
user
- Fixes to Hardcover author parsing to strip out initialed names
- Remove `env_supported=false` for security config options.
2026-03-05 16:24:03 +00:00
Alex cce2c10704 Download history refactor pt.2 (#703)
Two-phase download history: downloads are now recorded in the DB at
queue time (not just at terminal time), eliminating the need to
reconstruct metadata in the terminal hook and removing the
`_is_graduated_request_download()` request-scan mess
2026-03-05 13:06:34 +00:00
Alex fbe25725d3 Download history refactor (#700)
- Much simpler handling of downloads in the activity sidebar, and
improved storage, persistence and UI behavior.
- Replace `ActivityService` with direct storage on
`DownloadHistoryService` and `download_requests` and removes the
activity_log/activity_dismissals tables
- Simplify no-auth mode by removing the fake user row pattern, handled
internally
- Add local download fallback so history entries can still serve files
after tasks leave the queue
- Downloads, requests and history are now entirely persistent between
updates / restarts, and correctly tied to each user.
2026-03-04 19:10:06 +00:00
Alex bd65bccf52 Fix: Refresh mirrors (#695) 2026-03-03 22:14:18 +00:00
Alex 71900e00db Feature: Hardcover list search (#694)
- Adds the functionality to search Hardcover lists, either public lists
or user's private lists
- Paste a list URL into the search box to view results
- Select a specific list from user's collection from advanced fields
dropdown
- Fixes content_type parameter in URL search query to use book/audiobook
2026-03-03 21:53:43 +00:00
Alex de18f2b9fe Fix: File movement trigger event (#691)
Creates IN_MOVED_TO event, fixes CWA ingest folder detection when file
movement fallback occurs
2026-03-03 18:00:48 +00:00
Alex 6718848cfb Feature: Manual search option (#687)
- Adds a toggle to advanced search fields to search sources manually
instead of using metadata
- Hidden for users when "Request Book" or "Blocked" default policy is in
effect.
2026-03-02 18:41:03 +00:00
Alex 7d992c3918 User DB cleanup and refactor (#686)
- Refactored user and request code to avoid any database conflicts
- Fix threading behavior with custom script execution
- Harden the no_auth activity user filtering
- Add a hint to add local admin if none is created
- Added secret key to persist login states across updates / restarts
2026-03-02 15:41:15 +00:00
Alex 9593c040b0 Misc features: Retries, user search config, sort by format, admin download control (#679)
- Added the manual retry option for failed downloads
- Added the ability to retry failed post-processing using existing
downloaded file
- Added admin-visible "Download as" selector, admin chooses a user to
download on-behalf of - inherits their output preferences.
- Added search mode and default metadata provider / release source
options to User Preferences and My Account settings.
- Added sort by format option in release results
- Added {OriginalName} renaming field option, to retain the exact
downloaded filename
- Frontend dependency updates - fixes rollup vulnerability from this
week

Closes #662 #656 #649 #562
2026-03-01 19:47:57 +00:00
Alex ea0d06ae08 Further notification tweaks (#671)
- Improved multi-URL notification handling
- Tweak Apprise validation to catch errors earlier
- Much improved notification logging and UI response
- More robust notification tests
2026-02-28 10:16:01 +00:00
Alex 0f3a06bc9c Fix: User DB hardening and apprise tweak (#668) 2026-02-27 21:06:39 +00:00
Alex d78aad066b Fix: Apprise logging and no_auth hardening (#667)
- Passes apprise logging into shelfmark logs
- Update UI activity dismissal when no authentication is active
2026-02-27 15:49:14 +00:00
Alex e7d2845235 Fixes: Auth edge cases, apprise logging, scoring and release refactors (#665)
- Added migration for builtin auth users who used dev builds during
multi-user development
- Display apprise errors in logging
- Fix user provisioning in reverse proxy auth setups
- Refactor scoring and release modal utils
2026-02-27 10:21:06 +00:00
Alex ac36d539c8 Patch: Various fixes (#660)
Various fixes from the last couple days: 

- Add manual approval option for book/audiobook requests (#651)
- Add flagged HTTP headers 
- Add filesystem fallback - copy + delete when hardlink/move fails
across filesystems (#647)
- Dependency updates
- Tweak frontend test config (simplified tsconfig for tests)
- Fix overlapping sort scoring in release modal - duplicate scoring keys
caused incorrect release ordering (#654 )
- Fix stale search session after download - search state was not
refreshed when returning from a download (#659)
- Fix multi-format release filtering - releases with multiple formats
were incorrectly excluded by the format filter (#658)
- Fix config persistence when action button is used - clicking "Test
connection" reset unsaved settings (#657)
- Fix request grid text positioning in admin request policy panel 
- Fix rTorrent path discovery (#653)
- Fix `/login` API check (#650)
2026-02-25 18:44:46 +00:00
Alex 91cbd51b67 Fix flask version (#646) 2026-02-23 10:09:16 +00:00
Alex c80c88676c Fix direct search request flow (#644)
Fixes #643
2026-02-23 09:27:55 +00:00
Alex 0d271f1f69 Patch: Certificate validation setting + Misc fixes (#642)
- Add certificate validation setting
- Fix some OIDC providers not linking emails to local users
- Reintroduce sort by peers option for prowlarr results
- Fix "All languages" search query reverting to default language
- Fix download/request dismissal with multiple admin users
- Fix download / request behavior on details modal
2026-02-22 23:07:55 +00:00
Alex 014fc38b48 Patch: OIDC polish (#636)
- Added two env vars for OIDC login: 
- HIDE_LOCAL_AUTH - Remove the "password" option on login page when OIDC
enabled
  - OIDC_AUTO_REDIRECT - Immediately launch OIDC provider page
- Improved UX for initial OIDC setup, including creating a local admin
user
- Added callback URL label to OIDC setup page
- Fix Qbittorrent save path bug
2026-02-21 11:51:11 +00:00
Alex fdd46852f2 Add new docs (#633) 2026-02-20 18:07:43 +00:00
Alex a57d081caa Fix OIDC name fallback with limited responses (#632) 2026-02-20 16:24:37 +00:00
Alex a50b43538a Temporarily pin Flask>=3.1.0 (#628) 2026-02-20 13:42:15 +00:00
Alex 8ad7f35136 Update readme (#627) 2026-02-20 12:23:00 +00:00
Alex 05115f7b41 Fix HTTP request behavior (#626) 2026-02-20 10:58:20 +00:00
Alex 554f5fcbe7 Patch: Various feature additions (#625)
- Add admin config for self-settings options visibility. Remove delivery
preferences or notifications from the view.
- Add option to use Booklore's Bookdrop API destination instead of a
specific library
- Add download path options for all torrent clients
2026-02-20 09:53:47 +00:00
Alex 8ff2d776ae Fix ABB magnet parsing (#623) 2026-02-16 16:58:56 +00:00
Alex 6c351f4bf3 Fix TypeScript error (#622) 2026-02-16 14:57:49 +00:00
Alex 7fdf55f5fd Enhancements to ABB handling (#621)
- Migrate download client handling from /prowlarr to /download. Moves
all torrent/usenet handling to app-level and gives ABB this
functionality.
- ABB Scraper now uses shared HTTP infrastructure instead of raw
requests, adding retry and proxy support
- Added author, age and bitrate info to ABB search results
- Added "best match" sorting option for releases
- Added size and bitrate sorting options for ABB
- Removed bundled default ABB hostname, must be configured by the user
- Added URL normalisation for ABB hostname
- Rearranged settings UI, moved download clients to its own section. 
- More tests
2026-02-16 14:52:46 +00:00
bonsai-dreams dd6fd1e199 Feature: Add AudiobookBay release source (#619)
## Summary

Adds AudiobookBay as a web-scraping release source for audiobook
torrents. Once enabled, a new tab shows up in the Find Releases modal.

## What's New

- **AudiobookBay source** – Search AudiobookBay for audiobook torrents
from the Shelfmark UI
- **Torrent downloads** – Extract magnet links from detail pages and add
them to the configured torrent client
- **Audiobook-only** – Source is limited to audiobooks
- **Download clients:** Currently uses the torrent client configured
under **Prowlarr > Download Clients**.
- Audiobook-specific categories (e.g. `QBITTORRENT_CATEGORY_AUDIOBOOK`)
are applied when set.
- **Settings → AudiobookBay**:
  - Enable toggle
  - Hostname
  - Max pages to search (default 5)
  - Rate limit delay in seconds (default 1)


## How It Works

1. User searches for an audiobook; AudiobookBay is queried if enabled.
2. Results show title, language, format, and size
3. User selects a release; the handler fetches the detail page and
extracts the magnet link.
4. Magnet link is sent to the configured torrent client.

## Testing

- Unit tests for source, handler, scraper, and utils
- Mocked HTTP requests and torrent client calls
- Coverage for search, relevance filtering, language mapping, size
parsing, and download flow

### Screenshots
<img width="600" alt="image"
src="https://github.com/user-attachments/assets/2e10a259-5c35-4065-980d-b59a1c961c9f"
/>
2026-02-16 14:44:47 +00:00
Alex ccb39e674e Patch: Request retry, admin-level requests, and various fixes (#620)
- Add request retry in the case of a download failure, admins will be prompted to attach a new file to the request
- Add admin-level "add to requests" button in the release modal
2026-02-16 12:27:56 +00:00
Alex 1931eb96a5 Feature: Notification support + Enhanced request management (#618)
- Added notification support via Apprise dependency
- Notifications can be configured globally or per user, with full
customization of events and notification type.
- Added expanded ActivityCard for increased detail of each request, file
info, and managing the attached file.
- Enhanced tests
2026-02-15 17:59:53 +00:00
Alex b7bee132a1 Requests: Various fixes and improvements (#617)
- Refactored activity backend for full user-level management, using the
db file
- Revamped the activity sidebar UX and categorisation
- Added download history and user filtering
- Added User Preferences modal, giving limited configuration for
non-admins - replaces the "restrict settings" config option.
- Many many bug fixes
- Many many new tests
2026-02-14 18:24:28 +00:00
Alex 68608b6162 Feature: Multi-user request system (#615)
- Adds a comprehensive multi-user request system to the existing
download flow
- Request configuration is policy based. Configure global settings for
content type, or narrow down policy for specific sources (E.g. allow
direct downloads, set prowlarr to request only, block IRC completely,
etc).
- Global policy configuration and per-user overrides for tailored
configs
- Replaced downloads sidebar with ActivitySidebar, combining active
downloads with requests. Admin management of user requests is done here,
and admins have view of downloads from all users. Sidebar can now be
pinned.
- Request either a standard book or a specific release. Release-requests
are used if you permit one source differently than the other. On
book-level requests, admins pick the specific file to be attached to the
fulfilled request.
- Users can request books with a note

This is WIP so some features are still not complete (notifications, more
automatic release selection, among others).
2026-02-14 11:08:20 +00:00
Alex af9d9ec8db Patch: Further multi-user fixes (#613) 2026-02-12 17:47:10 +00:00
arjunsrinivasan1997 a7064939ce feat: Add tag support to qBittorrent (#610)
Added support for adding tag(s) to torrents sent to qBittorrent via
shelfmark.
![Screenshot 2026-02-11 at 3 58
20 AM](https://github.com/user-attachments/assets/aa9b440a-27fd-4166-953b-31f5179688a3)
![Screenshot 2026-02-11 at 3 53
14 AM](https://github.com/user-attachments/assets/15084b44-9a68-493c-85e8-328c92206c85)
2026-02-12 14:52:34 +00:00
Alex 5bed0b20f4 Patch: Multi-user and OIDC polish (#612)
- Moved backend OIDC functionality to external library Authlib to help
maintainability
- Separated User settings UI into individual components, allowing for
standard settings UI decorator components to be used.
- Added full support for reverse proxy and CWA users alongside local and
OIDC
- Added mapping and syncing functionality for OIDC, CWA and reverse
proxy users
- Added per-user settings into the app-wide config system. Each config
can be declared as user-overrideable, and app-wide functionality can now
receive user-specific options via standard config calls.
- Added per-user audiobook destination config
- Updated login modal UI for simplified login, plus custom labels for
OIDC login
- Added user visibility in header dropdown
- Unified "restrict settings to admin" to use app-wide user roles.
2026-02-12 14:38:28 +00:00
Michael Joshua SaulandClaude Opus 4.6 2d2f54729f Add OIDC authentication and multi-user support (#606)
Closes #552

## Summary

Adds OIDC authentication and multi-user support to Shelfmark. Users can
now be managed individually with per-user download settings, while
maintaining full backwards compatibility with existing auth modes
(no-auth, builtin, proxy, CWA).

### Authentication
- **OIDC login** with PKCE, auto-discovery, group-based admin mapping
- **Password fallback** when OIDC is enabled (prevents admin lockout)
- **Auto-provisioning** of OIDC users (configurable on/off)
- **Email-based linking** of pre-created users to OIDC accounts
- **Lockout prevention** — requires a local admin before OIDC can be
enabled

### User Management
- **SQLite user database** (`users.db`) with admin CRUD API
- **Users management tab** in settings UI (admin-only)
- **Settings restricted to admins** in multi-user modes (builtin/OIDC) —
non-admin users cannot access settings
- Create, edit, and delete users with role assignment (admin/user)
- Password management for builtin auth users
- OIDC users shown with provider badge (password fields hidden)
- Per-user configurable settings:
  - **Download destination** — custom folder path per user
- **BookLore library & path** — dropdown select, each user's books go to
their own library
  - **Email recipients** — per-user email delivery targets
- **`{User}` template variable** — use in destination paths (e.g.,
`/books/{User}/`)
- Settings override model: per-user values override globals, empty/unset
falls back to global defaults

### Download Scoping
- **Per-user download visibility** — non-admins only see their own
downloads
- **Username display** in downloads sidebar (shows who requested each
download)
- **WebSocket room-based filtering** — admins see all, users see only
their own
- **Download progress scoping** — progress events routed to correct user
rooms

### BookLore Integration
- **Dynamic dropdown selects** for library/path (replaces text inputs)
- **Per-user library/path overrides** via user settings
- **Options cache refresh** after Test Connection

### Security
- SQL injection prevention (column whitelist on user updates)
- Generic OIDC error messages (no internal detail leakage)
- Admin self-deletion and last-local-admin deletion guards
- OIDC role overwrite fix (only updates role when admin_group is
configured)

## Migration

**No migration script needed.** The `users.db` is created automatically
on first startup. Existing builtin auth users are auto-migrated to the
database on their first login. All other auth modes (no-auth, proxy,
CWA) continue working unchanged.

## Test Plan

- [x] All 519 tests passing, 0 failures
- [ ] Test no-auth mode: settings accessible, downloads work without
login
- [ ] Test builtin auth: legacy credentials auto-migrate on login, new
users can be created
- [ ] Test OIDC auth: login flow, callback, auto-provisioning,
group-based admin
- [ ] Test CWA auth: unchanged behavior
- [ ] Test proxy auth: unchanged behavior
- [ ] Test per-user downloads: non-admin sees only own downloads
- [ ] Test BookLore dropdowns: library/path selection, per-user
overrides
- [ ] Test Docker build: no Dockerfile changes needed

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 17:44:27 +00:00
Alex b5923635a6 Fix recipient modal layout (#604) 2026-02-09 20:13:52 +00:00
Alex e09f5f7757 Feature: Email output mode (#603) 2026-02-09 19:33:12 +00:00
Alex 022e50a0ba Add threading to file system operations (#602) 2026-02-09 18:04:10 +00:00
Alex a560089ce3 Patch: Script improvements + bug fixes (#591)
- Add new booklore API file formats
- Renamed cookie for better login persistence with reverse proxy
- Updated fs.py to try hardlink before atomic move from tmp dir
- Fix transmission URL parsing 
- Fix scenario where file processing of huge files starves the
healthcheck
- Large enhancements to custom scripting, including passing JSON
download info, more consistent activation across output types,
decoupling from staging behavior, and added full documentation.
2026-02-06 13:51:23 +00:00
Alex f84fb082ad Fix: AA mirror behavior (#589)
- Refreshed available AA URLs
- Fixed potential redirect from AA itself causing mirror cache errors
- Added fully customizable mirror list in UI
- Segmented rotation behavior to Auto mode only

Fixes #588
2026-02-06 10:04:31 +00:00
Alex b10458a48b Patch: Migrate bypasser to pure CDP + Misc fixes (#575)
Bypasser:
- Refactored internal bypasser logic to use SeleniumBase Pure CDP mode,
removed chromedriver dependencies and UC code.
- Added dedicated threading for internal bypasser functions, fixes any
potential asyncio CPU spike behavior
- Fixed WebGL issue with Chromium 144. Reverted 1.0.3 hotfix and updated
to latest Chromium

Misc: 
- Added M4A color mapping
- Fix frontend language filtering with multi-language releases
- Added "days" age for usenet/torrent releases
- Improved entrypoint chown efficiency
- Added `ONBOARDING` env variable, default true
2026-02-02 20:32:19 +00:00
Andy Kelk f6dba959c9 Fix: Base path resolution timing issue for subpath deployments (#572)
When deployed under a URL prefix (e.g., /shelfmark), images loaded by
React were not respecting the base path, causing 404 errors. The logo
would incorrectly load from /logo.png instead of /shelfmark/logo.png.

The root cause was that the BASE_PATH constant was being initialized at
module load time, before the DOM was fully parsed. This meant
document.querySelector('base') returned null, causing BASE_PATH to
default to '/' regardless of the actual base tag value.

Changed to lazy initialization pattern where the base path is resolved
on first access, ensuring the DOM and base tag are ready.

Fixes [#571](https://github.com/calibrain/shelfmark/issues/571)
2026-02-02 17:26:06 +00:00
Alex e5ccabe1ef Patch: Various additions (#564)
- Added rich Prowlarr search results for whitelisted indexers
- Added torznab query for whitelisted indexers
- Added flags for all Prowlarr indexers
- Added completed external download retry mechanism and "locating" state
- Added client side preference storage of Book/Audiobook search
preference
- Fixed reverse proxy base URL in edge cases
- Added gevent locking for I/O operations, keeps healthcheck alive on
intensive processing operations
- Added M4A supported audiobook option
- Improved file transfer counting and logging with hardlink fallback
warnings
- Fixed proxy auth header for REMOTE_USER scenario
- Dependency tweak for internal bypasser
2026-01-31 12:53:11 +00:00
Marcel Meier 86082c999c Enhance naming templates with arbitrary prefix/suffix support (#560)
As described in https://github.com/calibrain/shelfmark/issues/559
I would like the option to use prefix/suffix text as part of my file
handling.

I appreciate every feedack
2026-01-30 13:53:05 +00:00
Webysther Sperandio 301b2e5456 Update readme (#557)
Confirmed working with automatic importing in calibre and calibre-web.
2026-01-29 19:39:05 +00:00
Ryan Dawes 4fde128fc7 Feature: Add indexer flags to results (#539)
Display Prowlarr indexer flags by rendering them as distinct,
color-coded badges.

- [New] TAGS Render Type: Added support for a TAGS column type that
renders a list of strings as distinct badges.
- Updated  `ReleaseCell` to handle the TAGS type:
  - Desktop: Renders distinct badges side-by-side.
- Mobile: Renders as a comma-separated text list (e.g., "FREELEECH,
DOUBLE UPLOAD").
- Styling: Added dynamic colors for common flags:
  - Freeleech → Green
  - Double Upload → Blue
  - VIP → Amber
  - Sticky → Yellow
- Prowlarr Source: Updated the "Flags" column to use the new TAGS render
type, enable uppercase styling, and show on mobile devices.

Desktop screenshot:
<img width="2042" height="110" alt="CleanShot 2026-01-25 at 21 32 24@2x"
src="https://github.com/user-attachments/assets/d135b1d6-176c-4cb9-afa8-fbbab0bcbc06"
/>

Mobile screenshot:
<img width="567" height="51" alt="image"
src="https://github.com/user-attachments/assets/b5b38a05-2466-4b6f-b4c4-ab6cce745408"
/>
2026-01-29 19:38:28 +00:00
Patrick VeverkaandCopilot d050417e01 fix newer versions of rtorrent (#549)
Closes https://github.com/calibrain/shelfmark/issues/534

This pull request enhances the rTorrent client testing and
implementation by adding more robust checks for directory paths and
improving how the base path is retrieved. The main focus is on verifying
and obtaining the correct download and base directories for torrents.

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-29 18:41:19 +00:00
KhakisandClaude Opus 4.5 0a7785a333 Docs: Subpath reverse proxy configuration (#542)
## Summary

This PR updates the reverse proxy documentation with comprehensive
configuration examples for subpath deployments, addressing several
issues discovered when running Shelfmark behind nginx at a subpath like
`/shelfmark/`.

## Changes

- **Root path setup**: Added complete nginx server block example
- **Subpath setup without auth**: Complete nginx configuration with all
necessary workarounds
- **Subpath setup with Authelia**: Full example including Authelia
snippets and Shelfmark proxy auth settings
- **Known issues section**: Documents the frontend bugs that require
workarounds

## Issues Addressed

The current documentation's simple example doesn't work for subpath
deployments because:

1. **Socket.IO connects to root**: Frontend connects to `/socket.io/`
instead of `/shelfmark/socket.io/`
2. **API calls use root path**: Cover images request `/api/` instead of
`/shelfmark/api/`
3. **Logo uses root path**: Requested from `/logo.png` instead of
`/shelfmark/logo.png`
4. **Socket.IO backend path**: Always at `/socket.io/` regardless of
`URL_BASE` setting

## Testing

Tested with:
- Nginx reverse proxy
- Authelia authentication proxy
- `URL_BASE=/shelfmark/` configuration
- WebSocket connections working
- Cover images loading
- Proxy authentication with admin group restrictions

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 20:03:35 +00:00
Alex 10bfaec793 Fix: Broken bypasser dependencies (#540) 2026-01-26 08:02:26 +00:00
Alex 1f093de763 Fix: Bypass activation on non-fast downloads (#536) 2026-01-25 20:22:07 +00:00
Alex 43e554b8ae Fix: HTTP grab behavior, logging enforcement (#521) 2026-01-23 17:34:44 +00:00
Alex 3be99effe4 Base url additions and bug fixes (#519)
- Base URL option in settings for reverse proxy setups
- Fix NZB downloads not deleting on completion
- Fix handling for audiobook files over 100+ parts
- Fix prowlarr search timeout 
- Fix prowlarr categorisation for expanded searches
2026-01-23 13:03:02 +00:00
Alex 03c364e375 Fix: Magnet and hash handling + Various bug fixes (#511) 2026-01-21 19:41:12 +00:00
Alex edf25150bd Fix: various external client issues (#505) 2026-01-20 19:34:18 +00:00
Alex a030bca5d3 Fix: Use info hash for clients (#495)
Passes prowlarr's info hash to download client, existing behavior as
fallback
2026-01-19 20:23:45 +00:00
Alex 8470095534 URL normalization and path mapping tweaks (#489)
- URL normalization (WIP) for external clients / prowlarr / booklore
URLs used.
- More robust handling of Windows path directories in mapping 
- UI tweaks
- Compose clean-ups
2026-01-18 17:36:16 +00:00
Patrick Veverka 4e00cf42f6 Fix: rTorrent alias (#487)
Fixes https://github.com/calibrain/shelfmark/issues/486
2026-01-18 08:02:15 +00:00
Alex f7375d56e2 Heuristic searches, full language support, manual search override (#483)
- Added heuristic-based author and title query creation, stripping out
unnecessary elements that could limit searches
- Improved language support when using Hardcover. Searches will now be
conducted on a per-language basis using localized book titles.
- Added manual search override option in the release modal.
2026-01-17 18:56:10 +00:00
Alex 5a6db5f8a8 Remote path mappings, Client handling improvements (#481) 2026-01-17 14:52:06 +00:00
Alex fd74021594 File processing refactor and Booklore upload support (#474)
- Added new book output option **upload to Booklore**, available in
download settings
- Got annoyed at my messy processing code while implementing Booklore so
refactored the whole thing
- Full black box file processing testing with randomised configuration
- Deluge: Connect via WebUI auth for simplified setup
- Added env vars documentation, auto generated via script, and unlocked
most settings to be used as env vars
2026-01-16 14:45:00 +00:00
Patrick Veverka ba906c45df Feature: rTorrent client support (#463)
This adds in rtorrent for
https://github.com/calibrain/shelfmark/issues/420

The one weird thing I noticed is that the download path needs to be the
same for both (that's not how I typically set it up)

But it definitely adds to rtorrent and gives progress.

**rTorrent client integration:**

* Added a new `RTorrentClient` class in
`shelfmark/release_sources/prowlarr/clients/rtorrent.py` that implements
the download client interface for rTorrent using XML-RPC, supporting
adding, removing, and querying torrent status.
* Registered the rTorrent client in the client registry in
`shelfmark/release_sources/prowlarr/clients/__init__.py`.

**Settings and configuration:**

* Extended the Prowlarr client settings UI and backend
(`shelfmark/release_sources/prowlarr/settings.py`) to add rTorrent as a
selectable client, provide rTorrent-specific configuration fields (URL,
username, password, label, download directory), and implement a
connection test action.
[[1]](diffhunk://#diff-052272b85804cb61162870f262cc7544ef321596ff3ebf08117a6c25afaa3ec5R390)
[[2]](diffhunk://#diff-052272b85804cb61162870f262cc7544ef321596ff3ebf08117a6c25afaa3ec5R539-R582)
[[3]](diffhunk://#diff-052272b85804cb61162870f262cc7544ef321596ff3ebf08117a6c25afaa3ec5R198-R225)

**Test environment and scripts:**

* Updated `docker-compose.test-clients.yml` to add an rTorrent service
for local testing, including configuration, ports, and documentation
updates.
[[1]](diffhunk://#diff-a9fe4200dec6a29947e21c338305d04c8b64a7bddd9b0e519f4ab5382c478ba6R17-L19)
[[2]](diffhunk://#diff-a9fe4200dec6a29947e21c338305d04c8b64a7bddd9b0e519f4ab5382c478ba6R39)
[[3]](diffhunk://#diff-a9fe4200dec6a29947e21c338305d04c8b64a7bddd9b0e519f4ab5382c478ba6R66)
[[4]](diffhunk://#diff-a9fe4200dec6a29947e21c338305d04c8b64a7bddd9b0e519f4ab5382c478ba6R164-R181)
* Enhanced `scripts/test_clients.py` to include rTorrent in the test
suite, with logic for connecting, adding, and removing torrents via
XML-RPC.
[[1]](diffhunk://#diff-c7146552cddc9665e380aec1473363fd8592535ab85f06443478464da8f5a99eR26)
[[2]](diffhunk://#diff-c7146552cddc9665e380aec1473363fd8592535ab85f06443478464da8f5a99eR57)
[[3]](diffhunk://#diff-c7146552cddc9665e380aec1473363fd8592535ab85f06443478464da8f5a99eR88-R90)
[[4]](diffhunk://#diff-c7146552cddc9665e380aec1473363fd8592535ab85f06443478464da8f5a99eR395-R468)
[[5]](diffhunk://#diff-c7146552cddc9665e380aec1473363fd8592535ab85f06443478464da8f5a99eR492)

Closes https://github.com/calibrain/shelfmark/issues/420
2026-01-16 14:28:21 +00:00
0d7a12ca7c Feature: Reverse proxy authentication (#455)
- Changes the auth settings to support more than two auth types
- Added a proxy auth type with settings for user and optionally group
headers
- Added a global middleware `proxy_auth_middleware` to handle proxy auth
(it does nothing if any other auth mode is set)
- Added support for proxy auth to `get_auth_mode`, `login_required`,
`api_login/out`, and `api_auth_check`
- Added a backend check to make protect the API for settings when admin
is required

---------

Co-authored-by: Joshua Tag Howard <git@jthoward.dev>
Co-authored-by: Alex <alex.bilbie1@gmail.com>
2026-01-15 13:27:50 +00:00
Alex 475ae420e5 Fix: Improve hardlink robustness (#449) 2026-01-13 20:59:18 +00:00
Alex c48d7a0cb0 Fix: Run onboarding once for all users (#446) 2026-01-13 20:13:14 +00:00
Alex 66dca96182 Fix: IRC connection threading + fs logging (#445) 2026-01-13 19:37:25 +00:00
Alex 8b801c104e Fix: Add SABnzbd cleanup, file directory notice, audiobook button (#444)
- Add SABnzbd archive cleanup upon download completion
- Added frontend audiobook library URL button
- Clearer errors for misconfigured destination paths
- Test clients.yml added
2026-01-13 19:06:17 +00:00
Alex be5382cd1e Fix: Config initialization and category fallback (#442)
- Added more robust config directory initialisation and file creation
- Fixed category fallback not triggering correctly for one content type
if another is cached
2026-01-13 18:26:51 +00:00
Alex fbc3dd2552 Fix: SABnzbd status polling (#439) 2026-01-13 16:29:12 +00:00
Alex bd1ad3495c Proxy, prowlarr, ingest dir fixes (#437) 2026-01-13 14:18:23 +00:00
Alex a0079c5a7f Added onboarding + release fixes (#433) 2026-01-13 11:25:12 +00:00
CaliBrain 92b8323a8b make os mv and cp commands non interactive (#430)
- fix(fs): handle NFS permission errors with robust fallback - Catch
PermissionError/OSError(EPERM) in atomic_move and atomic_copy -
Implement layered fallback: shutil.copyfile (content only) -> system
mv/cp - Add _perform_nfs_fallback and _system_op helpers to reduce
duplication - Set fallback logging to DEBUG to reduce spam on NFS mounts
- make os mv and cp commands non interactive (-f)
2026-01-13 00:08:49 -05:00
CaliBrain 1ca80e8b6f fix(fs): handle NFS permission errors with robust fallback (#429)
Fix for #423

- Catch PermissionError/OSError(EPERM) in atomic_move and atomic_copy
- Implement layered fallback: shutil.copyfile (content only) -> system
mv/cp
- Add _perform_nfs_fallback and _system_op helpers to reduce duplication
- Set fallback logging to DEBUG to reduce spam on NFS mounts
2026-01-13 00:02:26 -05:00
Alex cca2587d8a Update readme (#417) 2026-01-12 11:06:06 +00:00
CaliBrain e31e9774a3 Update GitHub Actions workflow permissions (#416)
Added permissions for contents and packages.
2026-01-11 18:32:46 -05:00
Alex afeae46821 Rename to Shelfmark and IRC adjustments (#415) 2026-01-11 19:38:38 +00:00
Alex 29a8d856a6 Update compose and documentation (#413) 2026-01-08 20:46:24 +00:00
Alex b97e48235b Direct download tweaks (#408)
- Simplified bypasser process, removed warmup functionality
- Added dedicated fast download sources, tried first.
2026-01-07 19:41:46 +00:00
Alex 7954ae9138 Mirror optimization (#401) 2026-01-05 21:10:51 +00:00
Alex 06778184af Fix: Directory config and init process (#396) 2026-01-05 17:56:00 +00:00
CaliBrain abf7f24178 Remove .org domain for AA and add alternative domains (#394) 2026-01-05 08:54:10 -05:00
Alex 3d84c5b42f Final tweaks and code cleanup (#392) 2026-01-04 14:12:36 +00:00
Alex b0206f76f8 File processing restructure and further feature additions (#390)
- Restructured the file processing settings to make more coherent
- Added hide settings UI for CWA non-users
- Added sort options for ReleaseModal listview entries
- Added separate Audiobook category selection for download clients -
2026-01-03 10:44:34 +00:00
Alex 8cb5335234 Fix: Auto parsing of Qbittorrent hash character lengths (#386) 2026-01-01 20:55:12 +00:00
Alex b2887eb4b0 Template based file naming and torrent hardlinking (#385)
- Added alternative file processing mode. Save files directly into a
library folder and set up file names / directories based on user
preference.
- Uses template based naming and directory creation. E.g. {Author} /
{Series} {Title} {Part} etc. Works for saving correctly to libraries
such as Audiobookshelf.
- Use torrent hardlinking directly into library directories.
2026-01-01 12:35:22 +00:00
Alex 06e468d043 Fix: Config dir permission setting (#382) 2025-12-31 13:44:11 +00:00
Alex c609c0b2bb Fix: Prowlarr categorisation and search fallback (#381) 2025-12-31 13:14:51 +00:00
Alex 875b705ed3 Audiobook mode (#380)
- Added a `content_type` field to switch metadata providers, prowlarr
search category, and file formats on the frontend.
- Switch between Book / Audiobook in the header dropdown. 
- Only Prowlarr declares itself as a supported audiobook source.
Internally switches to category 3030 for searches.
- Updated torrent client handling to accept and process magnet links
2025-12-31 12:22:33 +00:00
Alex 91dd479edb Prowlarr non-category search fallback, bypass optimizations, and code cleanup (#379)
- Prowlarr: Added automatic fallback to search without category filter
when indexers return no results with book category (7000), improving
compatibility with indexers that don't support category filtering
- Prowlarr: Hide language filter in UI since Prowlarr has unreliable
language metadata
- Bypass: Refactored internal bypasser with code cleanup, extracted
helper functions, and added health check capability
  - Bypass: Added fingerprint module for screen size handling
- qBittorrent: Fixed connection test to use web API version instead of
app version
- Frontend: Added supported_filters config to control which filters
display per source
- Auth: Improved CWA database path validation (now uses Path object
properly)
2025-12-30 23:19:25 +00:00
Alex e870ada452 Fix bypasser health check (#376) 2025-12-30 10:24:47 +00:00
Alex 98aada2f55 Selenium update and bypasser enhancements, various bug fixes and tests (#375)
- Updated Selenium to 4.45.6. Includes various crash and memory leak
fixes, plus new bypasser methods
- Bypasser now uses CDP captcha solving as priority - Faster, more
efficient, no PyAutoGUI needed. Fallback to existing methods.
- Better detection and cleanup of old Selenium instances to save memory.
- Added Hardcover graphQL API header detection
- Added AA download counts in details modal
- More robust switching of internal/external bypasser, fixed settings UI
toggle behavior.
2025-12-30 09:42:06 +00:00
bischoffjeremy dbe46e8e61 fix: default username to 'admin' if password is set but username is empty (#374)
Fixed an issue where hitting save with an empty username would fail
silently. This led users to think their changes were saved when they
actually weren't. Now it automatically defaults to "admin" if you set a
password but leave the username blank, making the save process reliable.

Cheers,

Your swiss librarian ;)
2025-12-30 09:32:33 +00:00
bischoffjeremy 74e657e955 fix: update auth priority and fallback logic (#373)
Reordered the auth priority because the old logic was misleading. It
would automatically default to "builtin" mode if credentials existed,
completely ignoring the CWA database even if you wanted to use it. You
wouldn't even notice it was happening until you realized the DB
integration wasn't actually active. This fix ensures explicit CWA auth
takes priority so you don't have to wipe your settings just to switch
methods.

Cheers,

Your swiss librarian ;)
2025-12-30 09:32:18 +00:00
Alex a0f8d14c45 Hardcover enhancements, refactor and cleanup, PUID/PGID additions (#365) 2025-12-28 22:51:40 +00:00
Alex a99dc1501d Prowlarr and IRC sources, Google Books, book series support + more (#361)
## Headline features 

### Prowlarr plugin - search trackers and download usenet/torrent books

- Search any usenet/torrent tracker via Prowlarr, returns books within
Universal search
- Configure download clients in the app settings (Qbittorrent, Deluge,
Transmission, NZBget, SABnzbd)
- Unified download and file handling within the app, same as AA. 

### IRC plugin 
- Search IRCHighway #ebooks channel for books and download right in the
app.
- No setup needed
- Credit to OpenBooks for the broad idea and inspiration for best
practices for ebook-specific search and download.

### Google Books Metadata Provider
- Create a Google Cloud API key and use Google Books as a metadata
provider
- Not the best source (Hardcover is still recommended), but another
option and further redundancy for universal search

### Book series support
  - New "Series" search field in Hardcover provider
  - "Series order" sort option - lists books in reading order
  - "View Series" button in book details modal to search the full series
  - Series info display (e.g., "3 of 12 in The Wheel of Time")

## Others: 

- Better format filtering, helpful errors when formats rejected (e.g.,
"Found 3 ebooks but format not supported (.pdf). Enable in Settings >
Formats."
- Directory processing - Handles multi-file torrent/usenet downloads
properly
- Expand search toggle - Skip ISBN search to find more editions
- Filtered authors - Uses primary authors only (excludes
translators/narrators) for better search results
- Language multi-select - Filter releases by multiple languages

 Docker / Build / Testing

  - pip cache mounts - Faster Docker builds via BuildKit cache
  - npm cache mounts - Faster frontend builds
  - APT cleanup - Smaller final image size
  - Added make restart command for quick restarts without rebuild
- New pytest-based test framework with proper configuration
(pyproject.toml)
- Unit tests for all download clients (qBittorrent, Transmission,
Deluge, NZBGet, SABnzbd)
  - Bencode parsing tests
  - Cache tests
  - Integration tests for Prowlarr handler
  - E2E test framework
2025-12-27 14:59:06 +00:00
Alex 2cf336d704 Update readme (#359)
Update readme to the v2 version
2025-12-26 18:06:10 +00:00
Alex f154b6994e Update readme (#357) 2025-12-24 08:52:21 +00:00
Alex 823ceeef4a Settings pass, SOCK5 proxy, RAR/ZIP handling + more (#355)
- Further pass on settings UI, rearranging and adding further options
- Full RAR/ZIP support, including automatic unzipping and moving valid
file formats to ingest folder
- SOCK5 proxy support
- Full pass on the orchestrator to handle RAR/ZIP and category-specific
ingest dirs regardless of release source.
- Enhanced debug output to include new config JSON files
- Further ReleaseModal refinement
2025-12-23 21:14:34 +00:00
Ronnoceel 8ed6b94dfb adds _blank target to footer github link. (#354) 2025-12-22 21:48:57 +00:00
Alex 2b5983d201 Settings UI enhancements - Source priority controls, default sort, caching controls (#353)
Also: 
Adjusted Welib/Zlib/Libgen URLs to be dynamically generated via hash.
Fixed Zlib downloads and user agent flow. AA URLS are now fetched lazily
if another source is prioritised.
2025-12-22 20:07:36 +00:00
Alex a4173eafcb Restructure + abstraction, plugin system, settings UI, universal search mode (#351)
Key changes:   

| Category | Lines | What it is |

|--------------------------|--------|----------------------------------------------------------------------|
| Docs | ~2,100 | plugin-settings.md, release-sources-plugin-guide.md,
provider README |
| Settings UI | ~1,650 | Modal, sidebar, field components (TextField,
SelectField, etc.) |
| ReleaseModal | ~1,200 | Universal mode release picker UI |
| Metadata Providers | ~2,100 | Hardcover + OpenLibrary + base classes |
| Core Infrastructure | ~2,150 | Cache decorator, queue, image cache,
models, config |
| main.py | ~1,570 | Flask routes (replaces old app.py but bigger) |
| Orchestrator | ~590 | Download queue management |
| Config/Settings Registry | ~1,400 | Backend settings system |
| Frontend Hooks | ~750 | useSettings, useSearch, useDownloadTracking,
etc. |
| Other Frontend | ~500 | BookGetButton, ReleaseCell, utils |
| Release Sources base | ~320 | Plugin interfaces |
2025-12-22 12:13:11 -05:00
CaliBrain 15a61a5191 Fix tor timeout (#349)
Tentative fix for #340
2025-12-18 15:53:39 -05:00
Alex 0cac541c0b Update Readme with new changes (#344) 2025-12-15 10:40:02 -05:00
CaliBrain 85c8c9151d Fix tor timeout (#343)
Fix for #340
2025-12-14 22:18:18 -05:00
Alex 4472fbe8cf Download overhaul - DNS fallback, bypasser enhancements, revamped error handling, better frontend UX (#336)
## Changelog

### 🌐 Network Resilience

- **Auto DNS rotation**: New `CUSTOM_DNS=auto` mode (now default) starts
with system DNS and automatically rotates through Cloudflare, Google,
Quad9, and OpenDNS when failures are detected. DNS results are cached to
improve performance.
- **Mirror failover**: Anna's Archive requests automatically fail over
between mirrors (.org, .se, .li) when one is unreachable
- **Round-robin source distribution**: Concurrent downloads are
distributed across different AA partner servers to avoid rate limiting

### 📥 Download Reliability

- **Much more reliable downloads**: Improved parsing of Anna's Archive
pages, smarter source prioritization, and better retry logic with
exponential backoff
- **Download resume support**: Interrupted downloads can now resume from
where they left off (if the server supports Range requests)
- **Cookie sharing**: Cloudflare bypass cookies are extracted and shared
with subsequent requests, often avoiding the need for re-bypass entirely
- **Stall detection**: Downloads with no progress for 5 minutes are
automatically cancelled and retried
- **Staggered concurrent downloads**: Small delays between starting
concurrent downloads to avoid hitting rate limits
- **Source failure tracking**: After multiple failures from the same
source type (e.g., Libgen), that source is temporarily skipped
- **Lazy welib loading**: Welib sources are fetched as a fallback only
when primary sources fail (unless `PRIORITIZE_WELIB` is enabled)

### 🛡️ Cloudflare & Protection Bypass

- **DDOS-Guard support**: Internal bypasser now detects and handles
DDOS-Guard challenges with dedicated bypass strategies
- **Cancellation support**: Bypass operations can now be cancelled
mid-operation when user cancels a download
- **Smart warmup**: Chrome driver is pre-warmed when first client
connects (controlled by `BYPASS_WARMUP_ON_CONNECT` env var) and shuts
down after periods of inactivity

### 🔌 External Bypasser (FlareSolverr)

- **Improved resilience**: Retry with exponential backoff, mirror/DNS
rotation on failure, and proper timeout handling
- **Cancellation support**: External bypasser operations respect
cancellation flags

### 🖥️ Web UI Improvements

- **Simplified download status**: Removed intermediate states
(bypassing, verifying, ingesting) — now just shows Queued → Resolving →
Downloading → Complete
- **Status messages**: Downloads show detailed status like "Trying
Anna's Archive (Server 3)" or "Server busy, trying next...", or live
waitlist countdowns.
- **Improved download sidebar**:
  - Downloads sorted by add time (newest first)
  - X button moved to top-right corner for better UX
  - Wave animation on in-progress items
  - Error messages shown directly on failed items
  - X button on completed/errored items clears them from the list

### ⚙️ Configuration Changes

- **`CUSTOM_DNS=auto`** is now the default (previously empty/system DNS)
- **`DOWNLOAD_PROGRESS_UPDATE_INTERVAL`** default changed from 5s to 1s
for smoother progress
- **`BYPASS_WARMUP_ON_CONNECT`** (default: true) — warm up Chrome when
first client connects

### 🐛 Bug Fixes

- **Download cancellation actually works**: Fixed issue where cancelling
downloads didn't properly stop in-progress operations
- **WELIB prioritization**: Fixed `PRIORITIZE_WELIB` not being respected
- **File exists handling**: Downloads to same filename now get `_1`,
`_2` suffix instead of overwriting
- **Empty search results**: "No books found" now returns empty list
instead of throwing exception
- **Search unavailable error**: Network/mirror failures during search
now return proper 503 error to client
2025-12-14 21:18:05 -05:00
535 changed files with 142006 additions and 9862 deletions
+1
View File
@@ -1,6 +1,7 @@
.git
.github
.vscode
.local
.mypy_cache
README_images
.gitignore
+4 -4
View File
@@ -11,9 +11,9 @@ assignees: ''
## Steps To Reproduce
<!-- Steps to reproduce the behavior -->
1.
2.
3.
1.
2.
3.
## Expected Behavior
<!-- A clear and concise description of what you expected to happen -->
@@ -34,4 +34,4 @@ When running in debug mode, a DEBUG button will appear in the interface. Please
<!-- If applicable, please provide your full docker-compose (redacted from any secrets) -->
## Additional Context
<!-- Add any other context about the problem here -->
<!-- Add any other context about the problem here -->
+49
View File
@@ -0,0 +1,49 @@
version: 2
updates:
# Python dependencies
- package-ecosystem: "uv"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 10
groups:
python-deps:
patterns: ["*"]
update-types: ["minor", "patch"]
# Frontend npm dependencies
- package-ecosystem: "npm"
directory: "/src/frontend"
schedule:
interval: "weekly"
open-pull-requests-limit: 10
groups:
npm-deps:
patterns: ["*"]
update-types: ["minor", "patch"]
# Dockerfile base images
- package-ecosystem: "docker"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 5
groups:
docker-images:
patterns: ["*"]
ignore:
# Node.js: block major-version bumps so dependabot never proposes
# moving from one LTS line to a non-LTS "Current" release (e.g. 24 -> 25).
# Node LTS is even-numbered only; major bumps should be deliberate.
- dependency-name: "node"
update-types: ["version-update:semver-major"]
# GitHub Actions
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 5
groups:
gh-actions:
patterns: ["*"]
@@ -1,16 +1,54 @@
name: Create and publish Docker images
on:
push:
branches:
- 'main'
tags:
- 'v*'
schedule:
# Nightly at 03:17 UTC — only builds if there are new commits on main
# since the last successful run (see check-changes job).
- cron: '17 3 * * *'
workflow_dispatch:
permissions: read-all
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
IMAGE_NAME: ${{ github.repository_owner }}/shelfmark
jobs:
check-changes:
runs-on: ubuntu-latest
outputs:
should_build: ${{ steps.check.outputs.should_build }}
steps:
- name: Check for new commits since last successful build
id: check
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
EVENT_NAME: ${{ github.event_name }}
CURRENT_SHA: ${{ github.sha }}
REPO: ${{ github.repository }}
run: |
# Always build on tag pushes and manual dispatch.
if [[ "$EVENT_NAME" != "schedule" ]]; then
echo "Event is $EVENT_NAME — building unconditionally."
echo "should_build=true" >> "$GITHUB_OUTPUT"
exit 0
fi
# Scheduled run: only build if HEAD differs from the last successful build on main.
LAST_SHA=$(gh api "/repos/${REPO}/actions/workflows/build-and-publish-docker-image.yml/runs?branch=main&status=success&per_page=1" --jq '.workflow_runs[0].head_sha' 2>/dev/null || true)
echo "Last successful build SHA: ${LAST_SHA:-<none>}"
echo "Current HEAD SHA: ${CURRENT_SHA}"
if [[ -z "$LAST_SHA" || "$LAST_SHA" != "$CURRENT_SHA" ]]; then
echo "New commits detected — building."
echo "should_build=true" >> "$GITHUB_OUTPUT"
else
echo "No new commits since last successful build — skipping."
echo "should_build=false" >> "$GITHUB_OUTPUT"
fi
build-and-push-images:
needs: check-changes
if: needs.check-changes.outputs.should_build == 'true'
runs-on: ubuntu-latest
permissions:
contents: read
@@ -20,33 +58,27 @@ jobs:
strategy:
matrix:
include:
- suffix: ""
target: cwa-bd
image_name_suffix: ""
- suffix: "-tor"
target: cwa-bd-tor
image_name_suffix: "-tor"
- suffix: "-extbp"
target: cwa-bd-extbp
image_name_suffix: "-extbp"
- target: shelfmark
- target: shelfmark-lite
image_name_suffix: "-lite"
steps:
- name: Get current date
id: date
run: echo "date=$(date +'%Y-%m-%d')" >> $GITHUB_OUTPUT
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Log in to the Container registry
uses: docker/login-action@v3
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata for ${{ matrix.target }} image
id: meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}${{ matrix.image_name_suffix }}
tags: |
@@ -56,13 +88,13 @@ jobs:
type=semver,pattern={{major}}.{{minor}}
type=sha
type=ref,event=tag
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- name: Build and push ${{ matrix.target }} Docker image
id: push
uses: docker/build-push-action@v5
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
platforms: linux/amd64,linux/arm64
context: .
@@ -73,11 +105,78 @@ jobs:
RELEASE_VERSION=${{ github.ref_name }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
- name: Generate artifact attestation for ${{ matrix.target }} image
if: github.event_name != 'pull_request'
uses: actions/attest-build-provenance@v2
uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0
with:
subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}${{ matrix.image_name_suffix }}
subject-digest: ${{ steps.push.outputs.digest }}
push-to-registry: true
push-to-registry: true
# Create aliases for backwards compatibility
create-aliases:
needs: build-and-push-images
runs-on: ubuntu-latest
if: github.event_name != 'pull_request'
permissions:
contents: read
packages: write
env:
# Legacy name for backwards compatibility (hardcoded so it works after rename)
LEGACY_NAME: calibre-web-automated-book-downloader
steps:
- name: Log in to registry
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- name: Create legacy aliases
run: |
# Current image names (follows repo name)
STANDARD="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}"
LITE="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-lite"
# Legacy image names (hardcoded for backwards compatibility)
LEGACY="${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ env.LEGACY_NAME }}"
LEGACY_TOR="${LEGACY}-tor"
LEGACY_EXTBP="${LEGACY}-extbp"
SHA_SHORT=$(echo "${{ github.sha }}" | cut -c1-7)
# Helper function to create alias with all standard tags
create_alias() {
local SOURCE=$1
local ALIAS=$2
# Always create SHA tag
docker buildx imagetools create -t "${ALIAS}:sha-${SHA_SHORT}" "${SOURCE}:sha-${SHA_SHORT}"
if [[ "${{ github.ref }}" == refs/tags/v* ]]; then
VERSION="${{ github.ref_name }}"
VERSION_NUM="${VERSION#v}"
MINOR="${VERSION_NUM%.*}"
docker buildx imagetools create -t "${ALIAS}:latest" "${SOURCE}:latest"
docker buildx imagetools create -t "${ALIAS}:${VERSION_NUM}" "${SOURCE}:${VERSION_NUM}"
docker buildx imagetools create -t "${ALIAS}:${MINOR}" "${SOURCE}:${MINOR}"
docker buildx imagetools create -t "${ALIAS}:${VERSION}" "${SOURCE}:${VERSION}"
else
docker buildx imagetools create -t "${ALIAS}:dev" "${SOURCE}:dev"
fi
}
# Create legacy aliases pointing to current images
# calibre-web-automated-book-downloader → standard image
create_alias "${STANDARD}" "${LEGACY}"
# calibre-web-automated-book-downloader-tor → standard image
create_alias "${STANDARD}" "${LEGACY_TOR}"
# calibre-web-automated-book-downloader-extbp → lite image
create_alias "${LITE}" "${LEGACY_EXTBP}"
+158
View File
@@ -0,0 +1,158 @@
name: CI
on:
pull_request:
branches: [main]
permissions:
contents: read
jobs:
python-quality:
name: Python Quality
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install uv and Python
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
version: "0.11.3"
python-version: "3.14"
enable-cache: true
- name: Sync dependencies
run: make install-python-dev
- name: Lint
run: make python-lint
- name: Check formatting
run: make python-format
- name: Check dead code
run: make python-dead-code
python-typechecks:
name: Python Typechecks
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install uv and Python
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
version: "0.11.3"
python-version: "3.14"
enable-cache: true
- name: Sync dependencies
run: make install-python-dev
- name: Typecheck
run: make python-typecheck
python-tests:
name: Python Tests
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install uv and Python
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
version: "0.11.3"
python-version: "3.14"
enable-cache: true
- name: Sync dependencies
run: make install-python-dev
- name: Run tests
run: make python-test
docker-build-check:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- name: Build shelfmark-lite image
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
context: .
target: shelfmark-lite
platforms: linux/amd64
push: false
build-args: |
BUILD_VERSION=pr-${{ github.sha }}
RELEASE_VERSION=pr-${{ github.event.pull_request.number }}
frontend-quality:
name: Frontend Quality
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 24
cache: "npm"
cache-dependency-path: src/frontend/package-lock.json
- name: Install dependencies
run: make install-ci
- name: Lint
run: make frontend-lint
- name: Check formatting
run: make frontend-format
frontend-typechecks:
name: Frontend Typechecks
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 24
cache: "npm"
cache-dependency-path: src/frontend/package-lock.json
- name: Install dependencies
run: make install-ci
- name: Typecheck
run: make frontend-typecheck
frontend-tests:
name: Frontend Tests
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 24
cache: "npm"
cache-dependency-path: src/frontend/package-lock.json
- name: Install dependencies
run: make install-ci
- name: Unit tests
run: make frontend-test
+38
View File
@@ -0,0 +1,38 @@
name: CodeQL
on:
push:
branches: [main]
pull_request:
branches: [main]
schedule:
- cron: "0 6 * * 1" # Weekly on Monday at 6am UTC
permissions:
contents: read
jobs:
analyze:
runs-on: ubuntu-latest
permissions:
security-events: write
strategy:
fail-fast: false
matrix:
language: [python, javascript-typescript]
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Initialize CodeQL
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v3
with:
languages: ${{ matrix.language }}
- name: Autobuild
uses: github/codeql-action/autobuild@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v3
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v3
with:
category: "/language:${{ matrix.language }}"
+9
View File
@@ -74,6 +74,7 @@ pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
src/frontend/coverage/
htmlcov/
.tox/
.nox/
@@ -227,3 +228,11 @@ pyrightconfig.json
# End of https://www.toptal.com/developers/gitignore/api/macos,visualstudiocode,python
/downloaded_files
/.local/
*.local.*
AGENTS.md
.claude/
.nvmrc
.playwright-mcp/
frontend-dist/
node_modules/
+23
View File
@@ -0,0 +1,23 @@
repos:
- repo: builtin
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-added-large-files
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.15.10
hooks:
- id: ruff-check
args: [--fix]
- id: ruff-format
- repo: local
hooks:
- id: oxfmt
name: oxfmt
entry: npx --prefix src/frontend oxfmt --config src/frontend/.oxfmtrc.json
language: system
types_or: [javascript, jsx, ts, tsx, css, json]
files: ^src/frontend/
exclude: package-lock\.json
+9 -9
View File
@@ -2,17 +2,17 @@
"version": "0.2.0",
"configurations": [
{
"name": "Python Debugger: Current CWABD File",
"name": "Python Debugger: Current Shelfmark File",
"type": "debugpy",
"request": "launch",
"program": "${file}",
"console": "integratedTerminal",
"justMyCode": false,
"env": {
"INGEST_DIR": "/tmp/cwa-book-downloader",
"TEMP_DIR": "/tmp/cwa-book-downloader",
"INGEST_DIR": "/tmp/shelfmark",
"TEMP_DIR": "/tmp/shelfmark",
"LOG_LEVEL": "DEBUG",
"LOG_ROOT": "/tmp/cwa-book-downloader",
"LOG_ROOT": "/tmp/shelfmark",
"ENABLE_LOGGING": "true",
"DOCKERMODE": "false",
"DEBUG": "true",
@@ -27,7 +27,7 @@
"preLaunchTask": "docker-compose up (dev)", // Spin up dev containers
"postDebugTask": "docker-compose down (dev)", // Optional: tear them down
"env": {
"INGEST_DIR": "/tmp/cwa-book-downloader"
"INGEST_DIR": "/tmp/shelfmark"
},
},
{
@@ -38,7 +38,7 @@
"preLaunchTask": "docker-compose up (prod)",
"postDebugTask": "docker-compose down (prod)",
"env": {
"INGEST_DIR": "/tmp/cwa-book-downloader"
"INGEST_DIR": "/tmp/shelfmark"
},
},
{
@@ -54,11 +54,11 @@
],
"compounds": [
{
"name": "Launch CWA-BD",
"name": "Launch Shelfmark",
"configurations": [
"Launch cwa-bd app.py",
"Launch Shelfmark app.py",
"Launch Browser"
]
}
]
}
}
+69 -58
View File
@@ -4,7 +4,7 @@ ARG BUILDPLATFORM
ARG BUILDARCH
# Frontend build stage.
FROM --platform=$BUILDPLATFORM node:20-alpine AS frontend-builder
FROM --platform=$BUILDPLATFORM node:24-alpine AS frontend-builder
# Helpful debug output to see what platforms BuildKit thinks it's using
RUN echo "BUILDPLATFORM=$BUILDPLATFORM BUILDARCH=$BUILDARCH TARGETPLATFORM=$TARGETPLATFORM TARGETARCH=$TARGETARCH"
@@ -14,8 +14,9 @@ WORKDIR /frontend
# Copy frontend package files
COPY src/frontend/package*.json ./
# Install dependencies
RUN npm ci
# Install dependencies (cache mount for faster rebuilds)
RUN --mount=type=cache,target=/root/.npm \
npm ci
# Copy frontend source
COPY src/frontend/ ./
@@ -24,7 +25,9 @@ COPY src/frontend/ ./
RUN npm run build
# Use python-slim as the base image
FROM python:3.10-slim AS base
FROM python:3.14-slim AS base
COPY --from=ghcr.io/astral-sh/uv:0.11.3 /uv /uvx /bin/
# Add build argument for version
ARG BUILD_VERSION
@@ -38,15 +41,14 @@ SHELL ["/bin/bash", "-o", "pipefail", "-c"]
# Consistent environment variables grouped together
ENV DEBIAN_FRONTEND=noninteractive \
DOCKERMODE=true \
UV_LINK_MODE=copy \
PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PYTHONIOENCODING=UTF-8 \
PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1 \
PIP_DEFAULT_TIMEOUT=100 \
NAME=Calibre-Web-Automated-Book-Downloader \
NAME=Shelfmark \
PATH=/app/.venv/bin:$PATH \
PYTHONPATH=/app \
# UID/GID will be handled by entrypoint script, but TZ/Locale are still needed
# PUID/PGID will be handled by entrypoint script, but TZ/Locale are still needed
LANG=en_US.UTF-8 \
LANGUAGE=en_US:en \
LC_ALL=en_US.UTF-8
@@ -55,7 +57,6 @@ ENV DEBIAN_FRONTEND=noninteractive \
ENV FLASK_PORT=8084
# Configure locale, timezone, and perform initial cleanup in a single layer
# User/group creation is removed
RUN apt-get update && \
apt-get install -y --no-install-recommends \
# For locale
@@ -67,7 +68,14 @@ RUN apt-get update && \
# For debug
zip iputils-ping \
# For user switching
sudo && \
gosu \
# --- Tor support (activated via USING_TOR=true) ---
tor \
supervisor \
iptables && \
# Configure iptables alternatives for tor.sh compatibility
update-alternatives --set iptables /usr/sbin/iptables-legacy && \
update-alternatives --set ip6tables /usr/sbin/ip6tables-legacy && \
# Cleanup APT cache *after* all installs in this layer
apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false && \
apt-get clean && \
@@ -80,16 +88,20 @@ RUN apt-get update && \
echo "LC_ALL=en_US.UTF-8" >> /etc/environment && \
echo "LANG=en_US.UTF-8" > /etc/locale.conf
# Create a fixed runtime user/group so hardened Docker/Kubernetes deployments
# can start the container directly as a non-root user with a passwd entry.
RUN groupadd -g 1000 shelfmark && \
useradd -u 1000 -g shelfmark -d /home/shelfmark -s /usr/sbin/nologin shelfmark && \
mkdir -p /home/shelfmark && \
chown 1000:1000 /home/shelfmark
# Set working directory
WORKDIR /app
# Install Python dependencies using pip
# Upgrade pip first, then copy requirements and install
# Copying requirements-base.txt separately leverages build cache
COPY requirements-base.txt .
RUN pip install --no-cache-dir -r requirements-base.txt && \
# Clean root's pip cache
rm -rf /root/.cache
# Install core Python dependencies first for better layer caching
COPY pyproject.toml uv.lock ./
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --locked --no-default-groups
# Copy application code *after* dependencies are installed
COPY . .
@@ -97,10 +109,19 @@ COPY . .
# Copy built frontend from frontend-builder stage
COPY --from=frontend-builder /frontend/dist /app/frontend-dist
# Final setup: permissions and directories in one layer
# Only creating directories and setting executable bits.
# Ownership will be handled by the entrypoint script.
RUN mkdir -p /var/log/cwa-book-downloader /cwa-book-ingest && \
# Final setup: create image-owned runtime paths for the fixed non-root user.
# Root/PUID mode still re-homes ownership at startup when needed.
RUN mkdir -p \
/config \
/books \
/var/log/shelfmark \
/tmp/shelfmark/seleniumbase/downloaded_files \
/tmp/shelfmark/seleniumbase/archived_files && \
rm -rf /app/downloaded_files /app/archived_files && \
ln -s /tmp/shelfmark/seleniumbase/downloaded_files /app/downloaded_files && \
ln -s /tmp/shelfmark/seleniumbase/archived_files /app/archived_files && \
chown -R 1000:1000 /config /books /home/shelfmark /tmp/shelfmark /var/log/shelfmark && \
chmod -R a+rX /app && \
chmod +x /app/entrypoint.sh /app/tor.sh /app/genDebug.sh
# Expose the application port
@@ -115,7 +136,7 @@ HEALTHCHECK --interval=60s --timeout=60s --start-period=60s --retries=3 \
ENTRYPOINT ["/usr/bin/dumb-init", "--"]
FROM base AS cwa-bd
FROM base AS shelfmark
RUN apt-get update && \
apt-get install -y --no-install-recommends \
@@ -123,49 +144,39 @@ RUN apt-get update && \
xvfb \
# For screen recording
ffmpeg \
# --- Chromium ---
# --- Chromium (unpinned - uses latest from Debian repos) ---
# Chrome 144+ requires --enable-unsafe-swiftshader for WebGL in Docker.
# This flag is set in internal_bypasser.py _get_browser_args()
chromium \
# --- ChromeDriver ---
chromium-driver \
chromium-common \
# For tkinter (pyautogui)
python3-tk
# install additional dependencies
COPY requirements-cwa-bd.txt .
RUN pip install --no-cache-dir -r requirements-cwa-bd.txt && \
# Clean root's pip cache
rm -rf /root/.cache
# Add this line to grant read/execute permissions to others
RUN chmod -R o+rx /usr/bin/chromium && \
chmod -R o+rx /usr/bin/chromedriver && \
chmod -R o+w /usr/local/lib/python3.10/site-packages/seleniumbase/drivers/
# Default command to run the application entrypoint script
CMD ["/app/entrypoint.sh"]
FROM cwa-bd AS cwa-bd-tor
ENV USING_TOR=true
# Install Tor and dependencies
RUN apt-get update && \
apt-get install -y --no-install-recommends \
# --- Tor ---
tor \
# --- iptables ---
iptables && \
update-alternatives --set iptables /usr/sbin/iptables-legacy && \
update-alternatives --set ip6tables /usr/sbin/ip6tables-legacy && \
# Cleanup APT cache *after* all installs in this layer
python3-tk \
# For RAR extraction
unrar-free && \
# Create symlink so rarfile library can find unrar
ln -sf /usr/bin/unrar-free /usr/bin/unrar && \
# Cleanup APT cache
apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
# Override the default command to run Tor
# Install the browser automation stack used by the full image
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --locked --no-default-groups --extra browser
# Keep SeleniumBase's bundled driver cache writable for the fixed non-root user.
RUN SELENIUMBASE_DRIVERS_DIR=$(/app/.venv/bin/python -c "import pathlib, seleniumbase; print(pathlib.Path(seleniumbase.__file__).resolve().parent / 'drivers')") && \
chown -R 1000:1000 "${SELENIUMBASE_DRIVERS_DIR}" && \
chmod -R u+rwX,go+rX "${SELENIUMBASE_DRIVERS_DIR}" && \
if [ -f "${SELENIUMBASE_DRIVERS_DIR}/uc_driver" ]; then chmod +x "${SELENIUMBASE_DRIVERS_DIR}/uc_driver"; fi
# Grant read/execute permissions to others
RUN chmod -R o+rx /usr/bin/chromium
# Default command to run the application entrypoint script
CMD ["/app/entrypoint.sh"]
FROM base AS cwa-bd-extbp
FROM base AS shelfmark-lite
ENV USING_EXTERNAL_BYPASSER=true
+114 -3
View File
@@ -1,4 +1,4 @@
.PHONY: help install dev build preview typecheck clean up down docker-build refresh
.PHONY: help install install-ci install-python-dev dev build preview frontend-typecheck frontend-lint frontend-format frontend-format-fix frontend-checks frontend-test clean up down docker-build refresh restart build-serve python-lint python-lint-fix python-format python-format-fix python-typecheck python-dead-code python-checks python-test python-test-cov checks fix
# Frontend directory
FRONTEND_DIR := src/frontend
@@ -10,17 +10,40 @@ COMPOSE_FILE := docker-compose.dev.yml
help:
@echo "Available targets:"
@echo ""
@echo "Quality:"
@echo " checks - Run ALL static analysis checks (frontend + Python)"
@echo " fix - Auto-fix lint + format issues (frontend + Python)"
@echo ""
@echo "Frontend:"
@echo " install - Install frontend dependencies"
@echo " dev - Start development server"
@echo " build - Build frontend for production"
@echo " build-serve - Build and serve via Flask (test prod build without Docker)"
@echo " preview - Preview production build"
@echo " typecheck - Run TypeScript type checking"
@echo " frontend-typecheck - Run TypeScript type checking"
@echo " frontend-lint - Run Oxlint against frontend code"
@echo " frontend-format - Check frontend formatting with Oxfmt"
@echo " frontend-format-fix - Format frontend code with Oxfmt"
@echo " frontend-checks - Run all frontend static analysis checks"
@echo " frontend-test - Run frontend unit tests"
@echo ""
@echo "Python:"
@echo " install-python-dev - Sync Python runtime + dev tooling with uv"
@echo " python-lint - Run Ruff against Python code (backend + tests)"
@echo " python-lint-fix - Run Ruff with safe auto-fixes"
@echo " python-format - Check Python formatting with Ruff"
@echo " python-format-fix - Format Python code with Ruff"
@echo " python-typecheck - Run BasedPyright against backend + tests"
@echo " python-dead-code - Run Vulture against backend code"
@echo " python-checks - Run all Python static analysis checks"
@echo " python-test - Run unit tests"
@echo " python-test-cov - Run unit tests with coverage report"
@echo " clean - Remove node_modules and build artifacts"
@echo ""
@echo "Backend (Docker):"
@echo " up - Start backend services"
@echo " down - Stop backend services"
@echo " restart - Restart backend services (no rebuild)"
@echo " docker-build - Build Docker image"
@echo " refresh - Rebuild and restart backend services"
@@ -29,6 +52,17 @@ install:
@echo "Installing frontend dependencies..."
cd $(FRONTEND_DIR) && npm install
install-ci:
@echo "Installing frontend dependencies (CI, lockfile-strict)..."
cd $(FRONTEND_DIR) && npm ci
# Install Python development dependencies
install-python-dev:
@echo "Syncing Python runtime and dev tooling with uv..."
uv sync --locked --extra browser
@echo "Installing prek git hooks..."
uv run prek install
# Start development server
dev:
@echo "Starting development server..."
@@ -39,16 +73,88 @@ build:
@echo "Building frontend for production..."
cd $(FRONTEND_DIR) && npm run build
# Build frontend and sync to frontend-dist for the running container to serve
build-serve: build
@echo "Syncing build to frontend-dist..."
@mkdir -p frontend-dist
rsync -a --delete $(FRONTEND_DIR)/dist/ frontend-dist/
@echo "Done. Hit the Flask backend (port 8084) to test the production build."
# Preview production build
preview:
@echo "Previewing production build..."
cd $(FRONTEND_DIR) && npm run preview
# Type checking
typecheck:
frontend-typecheck:
@echo "Running TypeScript type checking..."
cd $(FRONTEND_DIR) && npm run typecheck
# Python linting (backend + tests)
python-lint:
@echo "Running Ruff..."
uv run ruff check shelfmark tests
python-lint-fix:
@echo "Running Ruff with safe auto-fixes..."
uv run ruff check shelfmark tests --fix
python-format:
@echo "Checking Python formatting with Ruff..."
uv run ruff format --check shelfmark tests
python-format-fix:
@echo "Formatting Python code with Ruff..."
uv run ruff format shelfmark tests
python-typecheck:
@echo "Running BasedPyright..."
uv run basedpyright
@echo "Running BasedPyright against tests..."
uv run basedpyright tests --skipunannotated
python-dead-code:
@echo "Running Vulture..."
uv run vulture shelfmark
python-checks: python-lint python-format python-typecheck python-dead-code
python-test:
@echo "Running tests..."
uv run pytest tests/ -x --tb=short -m "not integration and not e2e"
python-test-cov:
@echo "Running tests with coverage..."
uv run pytest tests/ -x --tb=short -m "not integration and not e2e" --cov --cov-report=term-missing
# Frontend linting
frontend-lint:
@echo "Running Oxlint..."
cd $(FRONTEND_DIR) && npm run lint
# Frontend formatting
frontend-format:
@echo "Checking frontend formatting with Oxfmt..."
cd $(FRONTEND_DIR) && npm run format:check
frontend-format-fix:
@echo "Formatting frontend code with Oxfmt..."
cd $(FRONTEND_DIR) && npm run format
# All frontend static analysis
frontend-checks: frontend-lint frontend-format frontend-typecheck
# Run frontend unit tests
frontend-test:
@echo "Running frontend unit tests..."
cd $(FRONTEND_DIR) && npm run test:unit
# All static analysis checks (frontend + Python)
checks: frontend-checks python-checks
# Auto-fix lint + format issues (frontend + Python)
fix: python-lint-fix python-format-fix frontend-format-fix
# Clean build artifacts and dependencies
clean:
@echo "Cleaning build artifacts and dependencies..."
@@ -70,6 +176,11 @@ docker-build:
@echo "Building Docker image..."
docker compose -f $(COMPOSE_FILE) build
# Restart backend services (no rebuild)
restart:
@echo "Restarting backend services..."
docker compose -f $(COMPOSE_FILE) restart
# Rebuild and restart backend services
refresh:
@echo "Rebuilding and restarting backend services..."
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 848 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 371 KiB

-822
View File
@@ -1,822 +0,0 @@
"""Flask web application for book download service with URL rewrite support."""
import logging
import io, re, os
import sqlite3
import time
from datetime import datetime, timedelta
from functools import wraps
from flask import Flask, request, jsonify, send_file, send_from_directory, session
from flask_cors import CORS
from flask_socketio import SocketIO, emit
from werkzeug.middleware.proxy_fix import ProxyFix
from werkzeug.security import check_password_hash
from werkzeug.wrappers import Response
import typing
from logger import setup_logger
from config import _SUPPORTED_BOOK_LANGUAGE, BOOK_LANGUAGE, SUPPORTED_FORMATS
from env import FLASK_HOST, FLASK_PORT, CWA_DB_PATH, DEBUG, USING_EXTERNAL_BYPASSER, BUILD_VERSION, RELEASE_VERSION, CALIBRE_WEB_URL
import backend
from models import SearchFilters
from websocket_manager import ws_manager
logger = setup_logger(__name__)
app = Flask(__name__)
app.wsgi_app = ProxyFix(app.wsgi_app) # type: ignore
app.config['SEND_FILE_MAX_AGE_DEFAULT'] = 0 # Disable caching
app.config['APPLICATION_ROOT'] = '/'
# Socket.IO async mode.
# We run this app under Gunicorn with a gevent websocket worker (even when DEBUG=true),
# so Socket.IO should always use gevent here.
async_mode = 'gevent'
# Initialize Flask-SocketIO with reverse proxy support
socketio = SocketIO(
app,
cors_allowed_origins="*",
async_mode=async_mode,
logger=False,
engineio_logger=False,
# Reverse proxy / Traefik compatibility settings
path='/socket.io',
ping_timeout=60, # Time to wait for pong response
ping_interval=25, # Send ping every 25 seconds
# Allow both websocket and polling for better compatibility
transports=['websocket', 'polling'],
# Enable CORS for all origins (you can restrict this in production)
allow_upgrades=True,
# Important for proxies that buffer
http_compression=True
)
# Initialize WebSocket manager
ws_manager.init_app(app, socketio)
logger.info(f"Flask-SocketIO initialized with async_mode='{async_mode}'")
# Rate limiting for login attempts
# Structure: {username: {'count': int, 'lockout_until': datetime}}
failed_login_attempts: typing.Dict[str, typing.Dict[str, typing.Any]] = {}
MAX_LOGIN_ATTEMPTS = 10
LOCKOUT_DURATION_MINUTES = 30
def cleanup_old_lockouts() -> None:
"""Remove expired lockout entries to prevent memory buildup."""
current_time = datetime.now()
expired_users = [
username for username, data in failed_login_attempts.items()
if 'lockout_until' in data and data['lockout_until'] < current_time
]
for username in expired_users:
logger.info(f"Lockout expired for user: {username}")
del failed_login_attempts[username]
def is_account_locked(username: str) -> bool:
"""Check if an account is currently locked due to failed login attempts."""
cleanup_old_lockouts()
if username not in failed_login_attempts:
return False
lockout_until = failed_login_attempts[username].get('lockout_until')
if lockout_until and datetime.now() < lockout_until:
return True
return False
def record_failed_login(username: str, ip_address: str) -> bool:
"""
Record a failed login attempt and lock account if threshold is reached.
Returns True if account is now locked, False otherwise.
"""
if username not in failed_login_attempts:
failed_login_attempts[username] = {'count': 0}
failed_login_attempts[username]['count'] += 1
count = failed_login_attempts[username]['count']
logger.warning(f"Failed login attempt {count}/{MAX_LOGIN_ATTEMPTS} for user '{username}' from IP {ip_address}")
if count >= MAX_LOGIN_ATTEMPTS:
lockout_until = datetime.now() + timedelta(minutes=LOCKOUT_DURATION_MINUTES)
failed_login_attempts[username]['lockout_until'] = lockout_until
logger.warning(f"Account locked for user '{username}' until {lockout_until.strftime('%Y-%m-%d %H:%M:%S')} due to {count} failed login attempts")
return True
return False
def clear_failed_logins(username: str) -> None:
"""Clear failed login attempts for a user after successful login."""
if username in failed_login_attempts:
del failed_login_attempts[username]
logger.debug(f"Cleared failed login attempts for user: {username}")
# Enable CORS in development mode for local frontend development
if DEBUG:
CORS(app, resources={
r"/*": {
"origins": ["http://localhost:5173", "http://127.0.0.1:5173"],
"supports_credentials": True,
"allow_headers": ["Content-Type", "Authorization"],
"methods": ["GET", "POST", "PUT", "DELETE", "OPTIONS"]
}
})
# Custom log filter to exclude routine status endpoint polling
class StatusEndpointFilter(logging.Filter):
"""Filter out routine status endpoint requests to reduce log noise."""
def filter(self, record):
# Exclude GET /api/status requests
if hasattr(record, 'getMessage'):
message = record.getMessage()
if 'GET /api/status' in message:
return False
return True
# Flask logger
app.logger.handlers = logger.handlers
app.logger.setLevel(logger.level)
# Also handle Werkzeug's logger
werkzeug_logger = logging.getLogger('werkzeug')
werkzeug_logger.handlers = logger.handlers
werkzeug_logger.setLevel(logger.level)
# Add filter to suppress routine status endpoint polling logs
werkzeug_logger.addFilter(StatusEndpointFilter())
# Set up authentication defaults
# The secret key will reset every time we restart, which will
# require users to authenticate again
# Session cookie security - set to 'true' if exclusively using HTTPS
session_cookie_secure_env = os.getenv('SESSION_COOKIE_SECURE', 'false').lower()
SESSION_COOKIE_SECURE = session_cookie_secure_env in ['true', 'yes', '1']
app.config.update(
SECRET_KEY = os.urandom(64),
SESSION_COOKIE_HTTPONLY = True,
SESSION_COOKIE_SAMESITE = 'Lax',
SESSION_COOKIE_SECURE = SESSION_COOKIE_SECURE,
PERMANENT_SESSION_LIFETIME = 604800 # 7 days in seconds
)
logger.info(f"Session cookie secure setting: {SESSION_COOKIE_SECURE} (from env: {session_cookie_secure_env})")
def login_required(f):
@wraps(f)
def decorated_function(*args, **kwargs):
# If the CWA_DB_PATH variable exists, but isn't a valid
# path, return a server error
if CWA_DB_PATH is not None and not os.path.isfile(CWA_DB_PATH):
logger.error(f"CWA_DB_PATH is set to {CWA_DB_PATH} but this is not a valid path")
return jsonify({"error": "Internal Server Error"}), 500
# If no database is configured, allow access
if not CWA_DB_PATH:
return f(*args, **kwargs)
# Check if user has a valid session
if 'user_id' not in session:
return jsonify({"error": "Unauthorized"}), 401
return f(*args, **kwargs)
return decorated_function
# Serve frontend static files
@app.route('/assets/<path:filename>')
def serve_frontend_assets(filename: str) -> Response:
"""
Serve static assets from the built frontend.
"""
return send_from_directory(os.path.join(app.root_path, 'frontend-dist', 'assets'), filename)
@app.route('/')
def index() -> Response:
"""
Serve the React frontend application.
Authentication is handled by the React app itself.
"""
return send_from_directory(os.path.join(app.root_path, 'frontend-dist'), 'index.html')
@app.route('/logo.png')
def logo() -> Response:
"""
Serve logo from built frontend assets.
"""
return send_from_directory(os.path.join(app.root_path, 'frontend-dist'),
'logo.png', mimetype='image/png')
@app.route('/favicon.ico')
@app.route('/favico<path:_>')
def favicon(_ : typing.Any = None) -> Response:
"""
Serve favicon from built frontend assets.
"""
return send_from_directory(os.path.join(app.root_path, 'frontend-dist'),
'favicon.ico', mimetype='image/vnd.microsoft.icon')
from typing import Union, Tuple
if DEBUG:
import subprocess
import time
if USING_EXTERNAL_BYPASSER:
STOP_GUI = lambda: None # No-op for external bypasser
else:
from cloudflare_bypasser import _reset_driver as STOP_GUI
@app.route('/debug', methods=['GET'])
@login_required
def debug() -> Union[Response, Tuple[Response, int]]:
"""
This will run the /app/debug.sh script, which will generate a debug zip with all the logs
The file will be named /tmp/cwa-book-downloader-debug.zip
And then return it to the user
"""
try:
# Run the debug script
STOP_GUI()
time.sleep(1)
result = subprocess.run(['/app/genDebug.sh'], capture_output=True, text=True, check=True)
if result.returncode != 0:
raise Exception(f"Debug script failed: {result.stderr}")
logger.info(f"Debug script executed: {result.stdout}")
debug_file_path = result.stdout.strip().split('\n')[-1]
if not os.path.exists(debug_file_path):
logger.error("Debug zip file not found after running debug script")
return jsonify({"error": "Failed to generate debug information"}), 500
# Return the file to the user
return send_file(
debug_file_path,
mimetype='application/zip',
download_name=os.path.basename(debug_file_path),
as_attachment=True
)
except subprocess.CalledProcessError as e:
logger.error_trace(f"Debug script error: {e}, stdout: {e.stdout}, stderr: {e.stderr}")
return jsonify({"error": f"Debug script failed: {e.stderr}"}), 500
except Exception as e:
logger.error_trace(f"Debug endpoint error: {e}")
return jsonify({"error": str(e)}), 500
if DEBUG:
@app.route('/api/restart', methods=['GET'])
@login_required
def restart() -> Union[Response, Tuple[Response, int]]:
"""
Restart the application
"""
os._exit(0)
@app.route('/api/search', methods=['GET'])
@login_required
def api_search() -> Union[Response, Tuple[Response, int]]:
"""
Search for books matching the provided query.
Query Parameters:
query (str): Search term (ISBN, title, author, etc.)
isbn (str): Book ISBN
author (str): Book Author
title (str): Book Title
lang (str): Book Language
sort (str): Order to sort results
content (str): Content type of book
format (str): File format filter (pdf, epub, mobi, azw3, fb2, djvu, cbz, cbr)
Returns:
flask.Response: JSON array of matching books or error response.
"""
query = request.args.get('query', '')
filters = SearchFilters(
isbn = request.args.getlist('isbn'),
author = request.args.getlist('author'),
title = request.args.getlist('title'),
lang = request.args.getlist('lang'),
sort = request.args.get('sort'),
content = request.args.getlist('content'),
format = request.args.getlist('format'),
)
if not query and not any(vars(filters).values()):
return jsonify([])
try:
books = backend.search_books(query, filters)
return jsonify(books)
except Exception as e:
logger.error_trace(f"Search error: {e}")
return jsonify({"error": str(e)}), 500
@app.route('/api/info', methods=['GET'])
@login_required
def api_info() -> Union[Response, Tuple[Response, int]]:
"""
Get detailed book information.
Query Parameters:
id (str): Book identifier (MD5 hash)
Returns:
flask.Response: JSON object with book details, or an error message.
"""
book_id = request.args.get('id', '')
if not book_id:
return jsonify({"error": "No book ID provided"}), 400
try:
book = backend.get_book_info(book_id)
if book:
return jsonify(book)
return jsonify({"error": "Book not found"}), 404
except Exception as e:
logger.error_trace(f"Info error: {e}")
return jsonify({"error": str(e)}), 500
@app.route('/api/download', methods=['GET'])
@login_required
def api_download() -> Union[Response, Tuple[Response, int]]:
"""
Queue a book for download.
Query Parameters:
id (str): Book identifier (MD5 hash)
Returns:
flask.Response: JSON status object indicating success or failure.
"""
book_id = request.args.get('id', '')
if not book_id:
return jsonify({"error": "No book ID provided"}), 400
try:
priority = int(request.args.get('priority', 0))
success = backend.queue_book(book_id, priority)
if success:
return jsonify({"status": "queued", "priority": priority})
return jsonify({"error": "Failed to queue book"}), 500
except Exception as e:
logger.error_trace(f"Download error: {e}")
return jsonify({"error": str(e)}), 500
@app.route('/api/config', methods=['GET'])
@login_required
def api_config() -> Union[Response, Tuple[Response, int]]:
"""
Get application configuration for frontend.
"""
try:
config = {
"calibre_web_url": CALIBRE_WEB_URL,
"debug": DEBUG,
"build_version": BUILD_VERSION,
"release_version": RELEASE_VERSION,
"book_languages": _SUPPORTED_BOOK_LANGUAGE,
"default_language": BOOK_LANGUAGE,
"supported_formats": SUPPORTED_FORMATS
}
return jsonify(config)
except Exception as e:
logger.error_trace(f"Config error: {e}")
return jsonify({"error": str(e)}), 500
@app.route('/api/health', methods=['GET'])
def api_health() -> Union[Response, Tuple[Response, int]]:
"""
Health check endpoint for container orchestration.
No authentication required.
Returns:
flask.Response: JSON with status "ok".
"""
return jsonify({"status": "ok"})
@app.route('/api/status', methods=['GET'])
@login_required
def api_status() -> Union[Response, Tuple[Response, int]]:
"""
Get current download queue status.
Returns:
flask.Response: JSON object with queue status.
"""
try:
status = backend.queue_status()
return jsonify(status)
except Exception as e:
logger.error_trace(f"Status error: {e}")
return jsonify({"error": str(e)}), 500
@app.route('/api/localdownload', methods=['GET'])
@login_required
def api_local_download() -> Union[Response, Tuple[Response, int]]:
"""
Download an EPUB file from local storage if available.
Query Parameters:
id (str): Book identifier (MD5 hash)
Returns:
flask.Response: The EPUB file if found, otherwise an error response.
"""
book_id = request.args.get('id', '')
if not book_id:
return jsonify({"error": "No book ID provided"}), 400
try:
file_data, book_info = backend.get_book_data(book_id)
if file_data is None:
# Book data not found or not available
return jsonify({"error": "File not found"}), 404
# Santize the file name
file_name = book_info.title
file_name = re.sub(r'[\\/:*?"<>|]', '_', file_name.strip())[:245]
file_extension = book_info.format
# Prepare the file for sending to the client
data = io.BytesIO(file_data)
return send_file(
data,
download_name=f"{file_name}.{file_extension}",
as_attachment=True
)
except Exception as e:
logger.error_trace(f"Local download error: {e}")
return jsonify({"error": str(e)}), 500
@app.route('/api/download/<book_id>/cancel', methods=['DELETE'])
@login_required
def api_cancel_download(book_id: str) -> Union[Response, Tuple[Response, int]]:
"""
Cancel a download.
Path Parameters:
book_id (str): Book identifier to cancel
Returns:
flask.Response: JSON status indicating success or failure.
"""
try:
success = backend.cancel_download(book_id)
if success:
return jsonify({"status": "cancelled", "book_id": book_id})
return jsonify({"error": "Failed to cancel download or book not found"}), 404
except Exception as e:
logger.error_trace(f"Cancel download error: {e}")
return jsonify({"error": str(e)}), 500
@app.route('/api/queue/<book_id>/priority', methods=['PUT'])
@login_required
def api_set_priority(book_id: str) -> Union[Response, Tuple[Response, int]]:
"""
Set priority for a queued book.
Path Parameters:
book_id (str): Book identifier
Request Body:
priority (int): New priority level (lower number = higher priority)
Returns:
flask.Response: JSON status indicating success or failure.
"""
try:
data = request.get_json()
if not data or 'priority' not in data:
return jsonify({"error": "Priority not provided"}), 400
priority = int(data['priority'])
success = backend.set_book_priority(book_id, priority)
if success:
return jsonify({"status": "updated", "book_id": book_id, "priority": priority})
return jsonify({"error": "Failed to update priority or book not found"}), 404
except ValueError:
return jsonify({"error": "Invalid priority value"}), 400
except Exception as e:
logger.error_trace(f"Set priority error: {e}")
return jsonify({"error": str(e)}), 500
@app.route('/api/queue/reorder', methods=['POST'])
@login_required
def api_reorder_queue() -> Union[Response, Tuple[Response, int]]:
"""
Bulk reorder queue by setting new priorities.
Request Body:
book_priorities (dict): Mapping of book_id to new priority
Returns:
flask.Response: JSON status indicating success or failure.
"""
try:
data = request.get_json()
if not data or 'book_priorities' not in data:
return jsonify({"error": "book_priorities not provided"}), 400
book_priorities = data['book_priorities']
if not isinstance(book_priorities, dict):
return jsonify({"error": "book_priorities must be a dictionary"}), 400
# Validate all priorities are integers
for book_id, priority in book_priorities.items():
if not isinstance(priority, int):
return jsonify({"error": f"Invalid priority for book {book_id}"}), 400
success = backend.reorder_queue(book_priorities)
if success:
return jsonify({"status": "reordered", "updated_count": len(book_priorities)})
return jsonify({"error": "Failed to reorder queue"}), 500
except Exception as e:
logger.error_trace(f"Reorder queue error: {e}")
return jsonify({"error": str(e)}), 500
@app.route('/api/queue/order', methods=['GET'])
@login_required
def api_queue_order() -> Union[Response, Tuple[Response, int]]:
"""
Get current queue order for display.
Returns:
flask.Response: JSON array of queued books with their order and priorities.
"""
try:
queue_order = backend.get_queue_order()
return jsonify({"queue": queue_order})
except Exception as e:
logger.error_trace(f"Queue order error: {e}")
return jsonify({"error": str(e)}), 500
@app.route('/api/downloads/active', methods=['GET'])
@login_required
def api_active_downloads() -> Union[Response, Tuple[Response, int]]:
"""
Get list of currently active downloads.
Returns:
flask.Response: JSON array of active download book IDs.
"""
try:
active_downloads = backend.get_active_downloads()
return jsonify({"active_downloads": active_downloads})
except Exception as e:
logger.error_trace(f"Active downloads error: {e}")
return jsonify({"error": str(e)}), 500
@app.route('/api/queue/clear', methods=['DELETE'])
@login_required
def api_clear_completed() -> Union[Response, Tuple[Response, int]]:
"""
Clear all completed, errored, or cancelled books from tracking.
Returns:
flask.Response: JSON with count of removed books.
"""
try:
removed_count = backend.clear_completed()
# Broadcast status update after clearing
if ws_manager and ws_manager.is_enabled():
ws_manager.broadcast_status_update(backend.queue_status())
return jsonify({"status": "cleared", "removed_count": removed_count})
except Exception as e:
logger.error_trace(f"Clear completed error: {e}")
return jsonify({"error": str(e)}), 500
@app.errorhandler(404)
def not_found_error(error: Exception) -> Union[Response, Tuple[Response, int]]:
"""
Handle 404 (Not Found) errors.
Args:
error (HTTPException): The 404 error raised by Flask.
Returns:
flask.Response: JSON error message with 404 status.
"""
logger.warning(f"404 error: {request.url} : {error}")
return jsonify({"error": "Resource not found"}), 404
@app.errorhandler(500)
def internal_error(error: Exception) -> Union[Response, Tuple[Response, int]]:
"""
Handle 500 (Internal Server) errors.
Args:
error (HTTPException): The 500 error raised by Flask.
Returns:
flask.Response: JSON error message with 500 status.
"""
logger.error_trace(f"500 error: {error}")
return jsonify({"error": "Internal server error"}), 500
@app.route('/api/auth/login', methods=['POST'])
def api_login() -> Union[Response, Tuple[Response, int]]:
"""
Login endpoint that validates credentials and creates a session.
Includes rate limiting: 10 failed attempts = 30 minute lockout.
Request Body:
username (str): Username
password (str): Password
remember_me (bool): Whether to extend session duration
Returns:
flask.Response: JSON with success status or error message.
"""
try:
# Get client IP address (handles reverse proxy forwarding)
ip_address = request.headers.get('X-Forwarded-For', request.remote_addr)
if ip_address and ',' in ip_address:
# X-Forwarded-For can contain multiple IPs, take the first one
ip_address = ip_address.split(',')[0].strip()
data = request.get_json()
if not data:
return jsonify({"error": "No data provided"}), 400
username = data.get('username', '').strip()
password = data.get('password', '')
remember_me = data.get('remember_me', False)
if not username or not password:
return jsonify({"error": "Username and password are required"}), 400
# Check if account is locked due to failed login attempts
if is_account_locked(username):
lockout_until = failed_login_attempts[username].get('lockout_until')
remaining_time = (lockout_until - datetime.now()).total_seconds() / 60
logger.warning(f"Login attempt blocked for locked account '{username}' from IP {ip_address}")
return jsonify({
"error": f"Account temporarily locked due to multiple failed login attempts. Try again in {int(remaining_time)} minutes."
}), 429
# If the database doesn't exist, authentication always succeeds
if not CWA_DB_PATH:
session['user_id'] = username
session.permanent = remember_me
clear_failed_logins(username)
logger.info(f"Login successful for user '{username}' from IP {ip_address} (no DB configured)")
return jsonify({"success": True})
# If the CWA_DB_PATH variable exists, but isn't a valid path, return error
if not os.path.isfile(CWA_DB_PATH):
logger.error(f"CWA_DB_PATH is set to {CWA_DB_PATH} but this is not a valid path")
return jsonify({"error": "Database configuration error"}), 500
# Validate credentials against database
try:
db_path = os.fspath(CWA_DB_PATH)
db_uri = f"file:{db_path}?mode=ro&immutable=1"
conn = sqlite3.connect(db_uri, uri=True)
cur = conn.cursor()
cur.execute("SELECT password FROM user WHERE name = ?", (username,))
row = cur.fetchone()
conn.close()
# Check if user exists and password is correct
if not row or not row[0] or not check_password_hash(row[0], password):
# Record failed login attempt
is_now_locked = record_failed_login(username, ip_address)
if is_now_locked:
return jsonify({
"error": f"Account locked due to {MAX_LOGIN_ATTEMPTS} failed login attempts. Try again in {LOCKOUT_DURATION_MINUTES} minutes."
}), 429
else:
attempts_remaining = MAX_LOGIN_ATTEMPTS - failed_login_attempts[username]['count']
# Only show attempts remaining when 5 or fewer attempts remain (after 6+ failed attempts)
if attempts_remaining <= 5:
return jsonify({
"error": f"Invalid username or password. {attempts_remaining} attempts remaining."
}), 401
else:
return jsonify({
"error": "Invalid username or password."
}), 401
# Successful authentication - create session and clear failed attempts
session['user_id'] = username
session.permanent = remember_me
clear_failed_logins(username)
logger.info(f"Login successful for user '{username}' from IP {ip_address} (remember_me={remember_me})")
return jsonify({"success": True})
except Exception as e:
logger.error_trace(f"Database error during login: {e}")
return jsonify({"error": "Authentication system error"}), 500
except Exception as e:
logger.error_trace(f"Login error: {e}")
return jsonify({"error": "Login failed"}), 500
@app.route('/api/auth/logout', methods=['POST'])
def api_logout() -> Union[Response, Tuple[Response, int]]:
"""
Logout endpoint that clears the session.
Returns:
flask.Response: JSON with success status.
"""
try:
# Get client IP address (handles reverse proxy forwarding)
ip_address = request.headers.get('X-Forwarded-For', request.remote_addr)
if ip_address and ',' in ip_address:
ip_address = ip_address.split(',')[0].strip()
username = session.get('user_id', 'unknown')
session.clear()
logger.info(f"Logout successful for user '{username}' from IP {ip_address}")
return jsonify({"success": True})
except Exception as e:
logger.error_trace(f"Logout error: {e}")
return jsonify({"error": "Logout failed"}), 500
@app.route('/api/auth/check', methods=['GET'])
def api_auth_check() -> Union[Response, Tuple[Response, int]]:
"""
Check if user has a valid session.
Returns:
flask.Response: JSON with authentication status and whether auth is required.
"""
try:
# If no database is configured, authentication is not required
if not CWA_DB_PATH:
return jsonify({
"authenticated": True,
"auth_required": False
})
# Check if user has a valid session
is_authenticated = 'user_id' in session
return jsonify({
"authenticated": is_authenticated,
"auth_required": True
})
except Exception as e:
logger.error_trace(f"Auth check error: {e}")
return jsonify({
"authenticated": False,
"auth_required": True
})
# Catch-all route for React Router (must be last)
# This handles client-side routing by serving index.html for any unmatched routes
@app.route('/<path:path>')
def catch_all(path: str) -> Response:
"""
Serve the React app for any route not matched by API endpoints.
This allows React Router to handle client-side routing.
Authentication is handled by the React app itself.
"""
# If the request is for an API endpoint or static file, let it 404
if path.startswith('api/') or path.startswith('assets/'):
return jsonify({"error": "Resource not found"}), 404
# Otherwise serve the React app
return send_from_directory(os.path.join(app.root_path, 'frontend-dist'), 'index.html')
# WebSocket event handlers
@socketio.on('connect')
def handle_connect():
"""Handle client connection."""
logger.info("WebSocket client connected")
# Send initial status to the newly connected client
try:
status = backend.queue_status()
emit('status_update', status)
except Exception as e:
logger.error(f"Error sending initial status: {e}")
@socketio.on('disconnect')
def handle_disconnect():
"""Handle client disconnection."""
logger.info("WebSocket client disconnected")
@socketio.on('request_status')
def handle_status_request():
"""Handle manual status request from client."""
try:
status = backend.queue_status()
emit('status_update', status)
except Exception as e:
logger.error(f"Error handling status request: {e}")
emit('error', {'message': 'Failed to get status'})
logger.log_resource_usage()
if __name__ == '__main__':
logger.info(f"Starting Flask application with WebSocket support on {FLASK_HOST}:{FLASK_PORT} (debug={DEBUG})")
socketio.run(
app,
host=FLASK_HOST,
port=FLASK_PORT,
debug=DEBUG,
allow_unsafe_werkzeug=True # For development only
)
-426
View File
@@ -1,426 +0,0 @@
"""Backend logic for the book download application."""
import threading, time
import shutil
from pathlib import Path
from typing import Dict, List, Optional, Any, Tuple
import subprocess
import os
from concurrent.futures import ThreadPoolExecutor, Future
from threading import Event
from logger import setup_logger
from config import CUSTOM_SCRIPT
from env import (INGEST_DIR, DOWNLOAD_PATHS, TMP_DIR, MAIN_LOOP_SLEEP_TIME, USE_BOOK_TITLE,
MAX_CONCURRENT_DOWNLOADS, DOWNLOAD_PROGRESS_UPDATE_INTERVAL)
from models import book_queue, BookInfo, QueueStatus, SearchFilters
import book_manager
logger = setup_logger(__name__)
# Import WebSocket manager (will be initialized by app.py)
try:
from websocket_manager import ws_manager
except ImportError:
logger.warning("WebSocket manager not available")
ws_manager = None
def _sanitize_filename(filename: str) -> str:
"""Sanitize a filename by replacing spaces with underscores and removing invalid characters."""
keepcharacters = (' ','.','_')
return "".join(c for c in filename if c.isalnum() or c in keepcharacters).rstrip()
def search_books(query: str, filters: SearchFilters) -> List[Dict[str, Any]]:
"""Search for books matching the query.
Args:
query: Search term
filters: Search filters object
Returns:
List[Dict]: List of book information dictionaries
"""
try:
books = book_manager.search_books(query, filters)
return [_book_info_to_dict(book) for book in books]
except Exception as e:
logger.error_trace(f"Error searching books: {e}")
return []
def get_book_info(book_id: str) -> Optional[Dict[str, Any]]:
"""Get detailed information for a specific book.
Args:
book_id: Book identifier
Returns:
Optional[Dict]: Book information dictionary if found
"""
try:
book = book_manager.get_book_info(book_id)
return _book_info_to_dict(book)
except Exception as e:
logger.error_trace(f"Error getting book info: {e}")
return None
def queue_book(book_id: str, priority: int = 0) -> bool:
"""Add a book to the download queue with specified priority.
Args:
book_id: Book identifier
priority: Priority level (lower number = higher priority)
Returns:
bool: True if book was successfully queued
"""
try:
book_info = book_manager.get_book_info(book_id)
book_queue.add(book_id, book_info, priority)
logger.info(f"Book queued with priority {priority}: {book_info.title}")
# Broadcast status update via WebSocket
if ws_manager and ws_manager.is_enabled():
ws_manager.broadcast_status_update(queue_status())
return True
except Exception as e:
logger.error_trace(f"Error queueing book: {e}")
return False
def queue_status() -> Dict[str, Dict[str, Any]]:
"""Get current status of the download queue.
Returns:
Dict: Queue status organized by status type with serialized book data
"""
status = book_queue.get_status()
for _, books in status.items():
for _, book_info in books.items():
if book_info.download_path:
if not os.path.exists(book_info.download_path):
book_info.download_path = None
# Convert Enum keys to strings and BookInfo objects to dicts for JSON serialization
return {
status_type.value: {
book_id: _book_info_to_dict(book_info)
for book_id, book_info in books.items()
}
for status_type, books in status.items()
}
def get_book_data(book_id: str) -> Tuple[Optional[bytes], BookInfo]:
"""Get book data for a specific book, including its title.
Args:
book_id: Book identifier
Returns:
Tuple[Optional[bytes], str]: Book data if available, and the book title
"""
try:
book_info = book_queue._book_data[book_id]
path = book_info.download_path
with open(path, "rb") as f:
return f.read(), book_info
except Exception as e:
logger.error_trace(f"Error getting book data: {e}")
if book_info:
book_info.download_path = None
return None, book_info if book_info else BookInfo(id=book_id, title="Unknown")
def _book_info_to_dict(book: BookInfo) -> Dict[str, Any]:
"""Convert BookInfo object to dictionary representation."""
return {
key: value for key, value in book.__dict__.items()
if value is not None
}
def _prepare_download_folder(book_info: BookInfo) -> Path:
"""Prepare final content-type subdir"""
content = book_info.content
content_dir = DOWNLOAD_PATHS.get(content) if content and content in DOWNLOAD_PATHS else INGEST_DIR
os.makedirs(content_dir, exist_ok=True)
return content_dir
def _download_book_with_cancellation(book_id: str, cancel_flag: Event) -> Optional[str]:
"""Download and process a book with cancellation support.
Args:
book_id: Book identifier
cancel_flag: Threading event to signal cancellation
Returns:
str: Path to the downloaded book if successful, None otherwise
"""
try:
# Check for cancellation before starting
if cancel_flag.is_set():
logger.info(f"Download cancelled before starting: {book_id}")
return None
book_info = book_queue._book_data[book_id]
logger.info(f"Starting download: {book_info.title}")
if USE_BOOK_TITLE:
book_name = _sanitize_filename(book_info.title)
else:
book_name = book_id
# If format is not set, use the format of the first download URL
if book_info.format == "":
book_info.format = book_info.download_urls[0].split(".")[-1]
book_name += f".{book_info.format}"
book_path = TMP_DIR / book_name
# Check cancellation before download
if cancel_flag.is_set():
logger.info(f"Download cancelled before book manager call: {book_id}")
return None
progress_callback = lambda progress: update_download_progress(book_id, progress)
status_callback = lambda status: update_download_status(book_id, status)
success_download_url = book_manager.download_book(book_info, book_path, progress_callback, cancel_flag, status_callback)
# Stop progress updates
cancel_flag.wait(0.1) # Brief pause for progress thread cleanup
if cancel_flag.is_set():
logger.info(f"Download cancelled during download: {book_id}")
# Clean up partial download
if book_path.exists():
book_path.unlink()
return None
if not success_download_url:
raise Exception("Unknown error downloading book")
# Check cancellation before post-processing
if cancel_flag.is_set():
logger.info(f"Download cancelled before post-processing: {book_id}")
if book_path.exists():
book_path.unlink()
return None
# Update status to verifying
book_queue.update_status(book_id, QueueStatus.VERIFYING)
if ws_manager and ws_manager.is_enabled():
ws_manager.broadcast_status_update(queue_status())
logger.info(f"Verifying download: {book_info.title}")
if CUSTOM_SCRIPT:
logger.info(f"Running custom script: {CUSTOM_SCRIPT}")
subprocess.run([CUSTOM_SCRIPT, book_path])
# Update status to ingesting
book_queue.update_status(book_id, QueueStatus.INGESTING)
if ws_manager and ws_manager.is_enabled():
ws_manager.broadcast_status_update(queue_status())
if success_download_url and book_info.format == "":
book_info.format = success_download_url.split(".")[-1]
book_name += f".{book_info.format}"
final_dir = _prepare_download_folder(book_info)
intermediate_path = final_dir / f"{book_id}.crdownload"
final_path = final_dir / book_name
if os.path.exists(book_path):
logger.info(f"Moving book to ingest directory: {book_path} -> {final_path}")
try:
shutil.move(book_path, intermediate_path)
except Exception as e:
try:
logger.debug(f"Error moving book: {e}, will try copying instead")
shutil.move(book_path, intermediate_path)
except Exception as e:
logger.debug(f"Error copying book: {e}, will try copying without permissions instead")
shutil.copyfile(book_path, intermediate_path)
os.remove(book_path)
# Final cancellation check before completing
if cancel_flag.is_set():
logger.info(f"Download cancelled before final rename: {book_id}")
if intermediate_path.exists():
intermediate_path.unlink()
return None
os.rename(intermediate_path, final_path)
logger.info(f"Download completed successfully: {book_info.title}")
return str(final_path)
except Exception as e:
if cancel_flag.is_set():
logger.info(f"Download cancelled during error handling: {book_id}")
else:
logger.error_trace(f"Error downloading book: {e}")
return None
def update_download_progress(book_id: str, progress: float) -> None:
"""Update download progress."""
book_queue.update_progress(book_id, progress)
# Broadcast progress via WebSocket
if ws_manager and ws_manager.is_enabled():
ws_manager.broadcast_download_progress(book_id, progress, 'downloading')
def update_download_status(book_id: str, status: str) -> None:
"""Update download status."""
# Map string status to QueueStatus enum
status_map = {
'queued': QueueStatus.QUEUED,
'resolving': QueueStatus.RESOLVING,
'bypassing': QueueStatus.BYPASSING,
'downloading': QueueStatus.DOWNLOADING,
'verifying': QueueStatus.VERIFYING,
'ingesting': QueueStatus.INGESTING,
'complete': QueueStatus.COMPLETE,
'available': QueueStatus.AVAILABLE,
'error': QueueStatus.ERROR,
'done': QueueStatus.DONE,
'cancelled': QueueStatus.CANCELLED,
}
queue_status_enum = status_map.get(status.lower())
if queue_status_enum:
book_queue.update_status(book_id, queue_status_enum)
# Broadcast status update via WebSocket
if ws_manager and ws_manager.is_enabled():
ws_manager.broadcast_status_update(queue_status())
def cancel_download(book_id: str) -> bool:
"""Cancel a download.
Args:
book_id: Book identifier to cancel
Returns:
bool: True if cancellation was successful
"""
result = book_queue.cancel_download(book_id)
# Broadcast status update via WebSocket
if result and ws_manager and ws_manager.is_enabled():
ws_manager.broadcast_status_update(queue_status())
return result
def set_book_priority(book_id: str, priority: int) -> bool:
"""Set priority for a queued book.
Args:
book_id: Book identifier
priority: New priority level (lower = higher priority)
Returns:
bool: True if priority was successfully changed
"""
return book_queue.set_priority(book_id, priority)
def reorder_queue(book_priorities: Dict[str, int]) -> bool:
"""Bulk reorder queue.
Args:
book_priorities: Dict mapping book_id to new priority
Returns:
bool: True if reordering was successful
"""
return book_queue.reorder_queue(book_priorities)
def get_queue_order() -> List[Dict[str, any]]:
"""Get current queue order for display."""
return book_queue.get_queue_order()
def get_active_downloads() -> List[str]:
"""Get list of currently active downloads."""
return book_queue.get_active_downloads()
def clear_completed() -> int:
"""Clear all completed downloads from tracking."""
return book_queue.clear_completed()
def _process_single_download(book_id: str, cancel_flag: Event) -> None:
"""Process a single download job."""
try:
# Status will be updated through callbacks during download process
# (resolving -> bypassing -> downloading -> verifying -> ingesting -> complete)
download_path = _download_book_with_cancellation(book_id, cancel_flag)
if cancel_flag.is_set():
book_queue.update_status(book_id, QueueStatus.CANCELLED)
# Broadcast cancellation
if ws_manager and ws_manager.is_enabled():
ws_manager.broadcast_status_update(queue_status())
return
if download_path:
book_queue.update_download_path(book_id, download_path)
new_status = QueueStatus.COMPLETE
else:
new_status = QueueStatus.ERROR
book_queue.update_status(book_id, new_status)
# Broadcast final status (completed or error)
if ws_manager and ws_manager.is_enabled():
ws_manager.broadcast_status_update(queue_status())
logger.info(
f"Book {book_id} download {'successful' if download_path else 'failed'}"
)
except Exception as e:
if not cancel_flag.is_set():
logger.error_trace(f"Error in download processing: {e}")
book_queue.update_status(book_id, QueueStatus.ERROR)
else:
logger.info(f"Download cancelled: {book_id}")
book_queue.update_status(book_id, QueueStatus.CANCELLED)
# Broadcast error/cancelled status
if ws_manager and ws_manager.is_enabled():
ws_manager.broadcast_status_update(queue_status())
def concurrent_download_loop() -> None:
"""Main download coordinator using ThreadPoolExecutor for concurrent downloads."""
logger.info(f"Starting concurrent download loop with {MAX_CONCURRENT_DOWNLOADS} workers")
with ThreadPoolExecutor(max_workers=MAX_CONCURRENT_DOWNLOADS, thread_name_prefix="BookDownload") as executor:
active_futures: Dict[Future, str] = {} # Track active download futures
while True:
# Clean up completed futures
completed_futures = [f for f in active_futures if f.done()]
for future in completed_futures:
book_id = active_futures.pop(future)
try:
future.result() # This will raise any exceptions from the worker
except Exception as e:
logger.error_trace(f"Future exception for {book_id}: {e}")
# Start new downloads if we have capacity
while len(active_futures) < MAX_CONCURRENT_DOWNLOADS:
next_download = book_queue.get_next()
if not next_download:
break
book_id, cancel_flag = next_download
logger.info(f"Starting concurrent download: {book_id}")
# Submit download job to thread pool
future = executor.submit(_process_single_download, book_id, cancel_flag)
active_futures[future] = book_id
# Brief sleep to prevent busy waiting
time.sleep(MAIN_LOOP_SLEEP_TIME)
# Start concurrent download coordinator
download_coordinator_thread = threading.Thread(
target=concurrent_download_loop,
daemon=True,
name="DownloadCoordinator"
)
download_coordinator_thread.start()
logger.info(f"Download system initialized with {MAX_CONCURRENT_DOWNLOADS} concurrent workers")
-495
View File
@@ -1,495 +0,0 @@
"""Book download manager handling search and retrieval operations."""
import time, json, os, re
from pathlib import Path
from urllib.parse import quote
from typing import List, Optional, Dict, Union, Callable
from threading import Event
from bs4 import BeautifulSoup, Tag, NavigableString, ResultSet
import downloader
from logger import setup_logger
from config import SUPPORTED_FORMATS, BOOK_LANGUAGE, AA_BASE_URL
from env import AA_DONATOR_KEY, USE_CF_BYPASS, PRIORITIZE_WELIB, ALLOW_USE_WELIB, DOWNLOAD_PATHS
from models import BookInfo, SearchFilters
logger = setup_logger(__name__)
def search_books(query: str, filters: SearchFilters) -> List[BookInfo]:
"""Search for books matching the query.
Args:
query: Search term (ISBN, title, author, etc.)
Returns:
List[BookInfo]: List of matching books
Raises:
Exception: If no books found or parsing fails
"""
query_html = quote(query)
if filters.isbn:
# ISBNs are included in query string
isbns = " || ".join(
[f"('isbn13:{isbn}' || 'isbn10:{isbn}')" for isbn in filters.isbn]
)
query_html = quote(f"({isbns}) {query}")
filters_query = ""
for value in filters.lang or BOOK_LANGUAGE:
if value != "all":
filters_query += f"&lang={quote(value)}"
if filters.sort:
filters_query += f"&sort={quote(filters.sort)}"
if filters.content:
for value in filters.content:
filters_query += f"&content={quote(value)}"
# Handle format filter
formats_to_use = filters.format if filters.format else SUPPORTED_FORMATS
index = 1
for filter_type, filter_values in vars(filters).items():
if filter_type == "author" or filter_type == "title" and filter_values:
for value in filter_values:
filters_query += (
f"&termtype_{index}={filter_type}&termval_{index}={quote(value)}"
)
index += 1
url = (
f"{AA_BASE_URL}"
f"/search?index=&page=1&display=table"
f"&acc=aa_download&acc=external_download"
f"&ext={'&ext='.join(formats_to_use)}"
f"&q={query_html}"
f"{filters_query}"
)
html = downloader.html_get_page(url)
if not html:
raise Exception("Failed to fetch search results")
if "No files found." in html:
logger.info(f"No books found for query: {query}")
raise Exception("No books found. Please try another query.")
soup = BeautifulSoup(html, "html.parser")
tbody: Tag | NavigableString | None = soup.find("table")
if not tbody:
logger.warning(f"No results table found for query: {query}")
raise Exception("No books found. Please try another query.")
books = []
if isinstance(tbody, Tag):
for line_tr in tbody.find_all("tr"):
try:
book = _parse_search_result_row(line_tr)
if book:
books.append(book)
except Exception as e:
logger.error_trace(f"Failed to parse search result row: {e}")
books.sort(
key=lambda x: (
SUPPORTED_FORMATS.index(x.format)
if x.format in SUPPORTED_FORMATS
else len(SUPPORTED_FORMATS)
)
)
return books
def _parse_search_result_row(row: Tag) -> Optional[BookInfo]:
"""Parse a single search result row into a BookInfo object."""
try:
# Skip ad rows
if row.text.strip().lower().startswith("your ad here"):
return None
cells = row.find_all("td")
preview_img = cells[0].find("img")
preview = preview_img["src"] if preview_img else None
return BookInfo(
id=row.find_all("a")[0]["href"].split("/")[-1],
preview=preview,
title=cells[1].find("span").next,
author=cells[2].find("span").next,
publisher=cells[3].find("span").next,
year=cells[4].find("span").next,
language=cells[7].find("span").next,
content=cells[8].find("span").next.lower(),
format=cells[9].find("span").next.lower(),
size=cells[10].find("span").next,
)
except Exception as e:
logger.error_trace(f"Error parsing search result row: {e}")
return None
def get_book_info(book_id: str) -> BookInfo:
"""Get detailed information for a specific book.
Args:
book_id: Book identifier (MD5 hash)
Returns:
BookInfo: Detailed book information
"""
url = f"{AA_BASE_URL}/md5/{book_id}"
html = downloader.html_get_page(url)
if not html:
raise Exception(f"Failed to fetch book info for ID: {book_id}")
soup = BeautifulSoup(html, "html.parser")
return _parse_book_info_page(soup, book_id)
def _parse_book_info_page(soup: BeautifulSoup, book_id: str) -> BookInfo:
"""Parse the book info page HTML into a BookInfo object."""
data = soup.select_one("body > main > div:nth-of-type(1)")
if not data:
raise Exception(f"Failed to parse book info for ID: {book_id}")
preview: str = ""
node = data.select_one("div:nth-of-type(1) > img")
if node:
preview_value = node.get("src", "")
if isinstance(preview_value, list):
preview = preview_value[0]
else:
preview = preview_value
data = soup.find_all("div", {"class": "main-inner"})[0].find_next("div")
divs = list(data.children)
every_url = soup.find_all("a")
slow_urls_no_waitlist = set()
slow_urls_with_waitlist = set()
external_urls_libgen = set()
external_urls_z_lib = set()
external_urls_welib = set()
for url in every_url:
try:
if url.text.strip().lower().startswith("slow partner server"):
if (
url.next is not None
and url.next.next is not None
and "waitlist" in url.next.next.strip().lower()
):
internal_text = url.next.next.strip().lower()
if "no waitlist" in internal_text:
slow_urls_no_waitlist.add(url["href"])
else:
slow_urls_with_waitlist.add(url["href"])
elif (
url.next is not None
and url.next.next is not None
and "click “GET” at the top" in url.next.next.text.strip()
):
libgen_url = url["href"]
# TODO : Temporary fix ? Maybe get URLs from https://open-slum.org/ ?
libgen_url = re.sub(r'libgen\.(lc|is|bz|st)', 'libgen.gl', url["href"])
external_urls_libgen.add(libgen_url)
elif url.text.strip().lower().startswith("z-lib"):
if ".onion/" not in url["href"]:
external_urls_z_lib.add(url["href"])
except:
pass
external_urls_welib = _get_download_urls_from_welib(book_id) if USE_CF_BYPASS else set()
urls = []
urls += list(external_urls_welib) if PRIORITIZE_WELIB else []
urls += list(slow_urls_no_waitlist) if USE_CF_BYPASS else []
urls += list(external_urls_libgen)
urls += list(external_urls_welib) if not PRIORITIZE_WELIB else []
urls += list(slow_urls_with_waitlist) if USE_CF_BYPASS else []
urls += list(external_urls_z_lib)
for i in range(len(urls)):
urls[i] = downloader.get_absolute_url(AA_BASE_URL, urls[i])
# Remove empty urls
urls = [url for url in urls if url != ""]
# Filter out divs that are not text
original_divs = divs
divs = [div for div in divs if div.text.strip() != ""]
all_details = _find_in_divs(divs, " · ")
format = ""
size = ""
content = ""
for _details in all_details:
_details = _details.split(" · ")
for f in _details:
if format == "" and f.strip().lower() in SUPPORTED_FORMATS:
format = f.strip().lower()
if size == "" and any(u in f.strip().lower() for u in ["mb", "kb", "gb"]):
size = f.strip().lower()
if content == "":
for ct in DOWNLOAD_PATHS.keys():
if ct in f.strip().lower():
content = ct
break
if format == "" or size == "":
for f in _details:
stripped = f.strip().lower()
if format == "" and stripped and " " not in stripped:
format = stripped
if size == "" and "." in stripped:
size = stripped
book_title = _find_in_divs(divs, "🔍")[0].strip("🔍").strip()
# Extract basic information
description = _extract_book_description(soup)
book_info = BookInfo(
id=book_id,
preview=preview,
title=book_title,
content=content,
publisher=_find_in_divs(divs, "icon-[mdi--company]", isClass=True)[0],
author=_find_in_divs(divs, "icon-[mdi--user-edit]", isClass=True)[0],
format=format,
size=size,
description=description,
download_urls=urls,
)
# Extract additional metadata
info = _extract_book_metadata(original_divs[-6])
book_info.info = info
# Set language and year from metadata if available
if info.get("Language"):
book_info.language = info["Language"][0]
if info.get("Year"):
book_info.year = info["Year"][0]
# TODO :
# Backfill missing metadata from original book
# To do this, we need to cache the results of search_books() in some kind of LRU
return book_info
def _find_in_divs(divs: List[str], text: str, isClass: bool = False) -> List[str]:
divs_found = []
for div in divs:
if isClass:
if div.find(class_ = text):
divs_found.append(div.text.strip())
else:
if text in div.text.strip():
divs_found.append(div.text.strip())
return divs_found
def _get_download_urls_from_welib(book_id: str) -> set[str]:
if ALLOW_USE_WELIB == False:
return set()
"""Get download urls from welib.org."""
url = f"https://welib.org/md5/{book_id}"
logger.info(f"Getting download urls from welib.org for {book_id}. While this uses the bypasser, it will not start downloading them yet.")
html = downloader.html_get_page(url, use_bypasser=True)
if not html:
return []
soup = BeautifulSoup(html, "html.parser")
download_links = soup.find_all("a", href=True)
download_links = [link["href"] for link in download_links]
download_links = [link for link in download_links if "/slow_download/" in link]
download_links = [downloader.get_absolute_url(url, link) for link in download_links]
return set(download_links)
def _get_next_value_div(label_div: Tag) -> Optional[Tag]:
"""Find the next sibling div that holds the value for a metadata label."""
sibling = label_div.next_sibling
while sibling:
if isinstance(sibling, Tag) and sibling.name == "div":
return sibling
sibling = sibling.next_sibling
return None
def _extract_book_description(soup: BeautifulSoup) -> Optional[str]:
"""Extract the primary or alternative description from the book page."""
container = soup.select_one(".js-md5-top-box-description")
if not container:
return None
description: Optional[str] = None
alternative: Optional[str] = None
label_divs = container.select("div.text-xs.text-gray-500.uppercase")
for label_div in label_divs:
label_text = label_div.get_text(strip=True).lower()
value_div = _get_next_value_div(label_div)
if not value_div:
continue
value_text = value_div.get_text(separator=" ", strip=True)
if not value_text:
continue
if label_text == "description":
return value_text
if label_text == "alternative description" and not alternative:
alternative = value_text
if alternative:
return alternative
# Fallback to the first text block inside the description container
fallback_div = container.find("div", class_="mb-1")
if fallback_div:
fallback_text = fallback_div.get_text(separator=" ", strip=True)
if fallback_text:
return fallback_text
return None
def _extract_book_metadata(metadata_divs) -> Dict[str, List[str]]:
"""Extract metadata from book info divs."""
info: Dict[str, List[str]] = {}
# Process the first set of metadata
sub_datas = metadata_divs.find_all("div")[0]
sub_datas = list(sub_datas.children)
for sub_data in sub_datas:
if sub_data.text.strip() == "":
continue
sub_data = list(sub_data.children)
key = sub_data[0].text.strip()
value = sub_data[1].text.strip()
if key not in info:
info[key] = set()
info[key].add(value)
# make set into list
for key, value in info.items():
info[key] = list(value)
# Filter relevant metadata
relevant_prefixes = [
"ISBN-",
"ALTERNATIVE",
"ASIN",
"Goodreads",
"Language",
"Year",
]
return {
k.strip(): v
for k, v in info.items()
if any(k.lower().startswith(prefix.lower()) for prefix in relevant_prefixes)
and "filename" not in k.lower()
}
def download_book(book_info: BookInfo, book_path: Path, progress_callback: Optional[Callable[[float], None]] = None, cancel_flag: Optional[Event] = None, status_callback: Optional[Callable[[str], None]] = None) -> Optional[str]:
"""Download a book from available sources.
Args:
book_id: Book identifier (MD5 hash)
title: Book title for logging
progress_callback: Optional callback for download progress updates
cancel_flag: Optional cancellation flag
status_callback: Optional callback for status updates
Returns:
str: Download URL if successful, None otherwise
"""
if len(book_info.download_urls) == 0:
book_info = get_book_info(book_info.id)
download_links = book_info.download_urls
# If AA_DONATOR_KEY is set, use the fast download URL. Else try other sources.
if AA_DONATOR_KEY != "":
download_links.insert(
0,
f"{AA_BASE_URL}/dyn/api/fast_download.json?md5={book_info.id}&key={AA_DONATOR_KEY}",
)
for link in download_links:
try:
# Update status to resolving before attempting download URL fetch
if status_callback:
status_callback("resolving")
download_url = _get_download_url(link, book_info.title, cancel_flag, status_callback)
if download_url != "":
# Update status to downloading before starting actual download
if status_callback:
status_callback("downloading")
logger.info(f"Downloading `{book_info.title}` from `{download_url}`")
data = downloader.download_url(download_url, book_info.size or "", progress_callback, cancel_flag)
if not data:
raise Exception("No data received")
logger.info(f"Download finished. Writing to {book_path}")
with open(book_path, "wb") as f:
f.write(data.getbuffer())
logger.info(f"Writing `{book_info.title}` successfully")
return download_url
except Exception as e:
logger.error_trace(f"Failed to download from {link}: {e}")
continue
return None
def _get_download_url(link: str, title: str, cancel_flag: Optional[Event] = None, status_callback: Optional[Callable[[str], None]] = None) -> str:
"""Extract actual download URL from various source pages."""
url = ""
if link.startswith(f"{AA_BASE_URL}/dyn/api/fast_download.json"):
page = downloader.html_get_page(link, status_callback=status_callback)
url = json.loads(page).get("download_url")
else:
html = downloader.html_get_page(link, status_callback=status_callback)
if html == "":
return ""
soup = BeautifulSoup(html, "html.parser")
if link.startswith("https://z-lib."):
download_link = soup.find_all("a", href=True, class_="addDownloadedBook")
if download_link:
url = download_link[0]["href"]
elif "/slow_download/" in link:
download_links = soup.find_all("a", href=True, string="📚 Download now")
if not download_links:
countdown = soup.find_all("span", class_="js-partner-countdown")
if countdown:
sleep_time = int(countdown[0].text)
logger.info(f"Waiting {sleep_time}s for {title}")
if cancel_flag is not None and cancel_flag.wait(timeout=sleep_time):
logger.info(f"Cancelled wait for {title}")
return ""
url = _get_download_url(link, title, cancel_flag, status_callback)
else:
url = download_links[0]["href"]
else:
url = soup.find_all("a", string="GET")[0]["href"]
return downloader.get_absolute_url(link, url)
-491
View File
@@ -1,491 +0,0 @@
import time
import os
import socket
from urllib.parse import urlparse
import threading
import env
from env import LOG_DIR, DEBUG
import signal
from datetime import datetime
import subprocess
import requests
from typing import Optional
# --- SeleniumBase Import ---
from seleniumbase import Driver
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from selenium.common.exceptions import TimeoutException
import network
from logger import setup_logger
from env import MAX_RETRY, DEFAULT_SLEEP
from config import PROXIES, CUSTOM_DNS, DOH_SERVER, VIRTUAL_SCREEN_SIZE, RECORDING_DIR
logger = setup_logger(__name__)
network.init()
DRIVER = None
DISPLAY = {
"xvfb": None,
"ffmpeg": None,
}
LAST_USED = None
LOCKED = threading.Lock()
TENTATIVE_CURRENT_URL = None
def _reset_pyautogui_display_state():
try:
import pyautogui
import Xlib.display
pyautogui._pyautogui_x11._display = (
Xlib.display.Display(os.environ['DISPLAY'])
)
except Exception as e:
logger.warning(f"Error resetting pyautogui display state: {e}")
def _is_bypassed(sb, escape_emojis : bool = True) -> bool:
"""Enhanced bypass detection with more comprehensive checks"""
try:
# Get page information with error handling
try:
title = sb.get_title().lower()
except:
title = ""
try:
body = sb.get_text("body").lower()
except:
body = ""
try:
current_url = sb.get_current_url()
except:
current_url = ""
# Check if page is too long, if so we are probably bypassed
if len(body.strip()) > 100000:
logger.debug(f"Page content too long, we are probably bypassed len: {len(body.strip())}")
return True
# Detect if there is an emoji in the page, any utf8 emoji, if so we are probably bypassed
if escape_emojis:
import emoji
emoji_list = emoji.emoji_list(body)
if len(emoji_list) >= 3:
logger.debug(f"Detected emoji in page, we are probably bypassed len: {len(emoji_list)}")
return True
# Enhanced verification texts for newer Cloudflare versions
verification_texts = [
"just a moment",
"verify you are human",
"verifying you are human",
"cloudflare.com/products/turnstile/?utm_source=turnstile"
]
# Check for Cloudflare indicators
for text in verification_texts:
if text in title or text in body:
logger.debug(f"Cloudflare indicator found: '{text}' in page")
return False
# Additional checks for specific Cloudflare patterns
if "cf-" in body or "cloudflare" in current_url.lower():
logger.debug("Cloudflare patterns detected in page")
return False
# Check if we're still on a challenge page (common Cloudflare pattern)
if "/cdn-cgi/" in current_url:
logger.debug("Still on Cloudflare CDN challenge page")
return False
# If page is mostly empty, it might still be loading
if len(body.strip()) < 50:
logger.debug("Page content too short, might still be loading")
return False
logger.debug(f"Bypass check passed - Title: '{title[:100]}', Body length: {len(body)}")
return True
except Exception as e:
logger.warning(f"Error checking bypass status: {e}")
# If we can't check, assume we're not bypassed
return False
def _bypass_method_1(sb) -> bool:
"""Original bypass method using uc_gui_click_captcha"""
try:
logger.debug("Attempting bypass method 1: uc_gui_click_captcha")
sb.uc_gui_click_captcha()
time.sleep(3)
return _is_bypassed(sb)
except Exception as e:
logger.debug(f"Method 1 failed on first try: {e}")
try:
time.sleep(5)
sb.wait_for_element_visible('body', timeout=10)
sb.uc_gui_click_captcha()
time.sleep(3)
return _is_bypassed(sb)
except Exception as e2:
logger.debug(f"Method 1 failed on second try: {e2}")
try:
time.sleep(DEFAULT_SLEEP)
sb.uc_gui_click_captcha()
time.sleep(5)
return _is_bypassed(sb)
except Exception as e3:
logger.debug(f"Method 1 completely failed: {e3}")
return False
def _bypass_method_2(sb) -> bool:
"""Alternative bypass method using longer waits and manual interaction"""
try:
logger.debug("Attempting bypass method 2: wait and reload")
# Wait longer for page to load completely
time.sleep(10)
# Try refreshing the page
sb.refresh()
time.sleep(8)
# Check if bypass worked after refresh
if _is_bypassed(sb):
return True
# Try clicking on the page center (sometimes helps trigger bypass)
try:
sb.click_if_visible("body", timeout=5)
time.sleep(5)
except:
pass
return _is_bypassed(sb)
except Exception as e:
logger.debug(f"Method 2 failed: {e}")
return False
def _bypass_method_3(sb) -> bool:
"""Third bypass method using user-agent rotation and stealth mode"""
try:
logger.debug("Attempting bypass method 3: stealth approach")
# Wait a random amount to appear more human
import random
wait_time = random.uniform(8, 15)
time.sleep(wait_time)
# Try to scroll the page (human-like behavior)
try:
sb.scroll_to_bottom()
time.sleep(2)
sb.scroll_to_top()
time.sleep(3)
except:
pass
# Check if this helped
if _is_bypassed(sb):
return True
# Try the original captcha click as last resort
try:
sb.uc_gui_click_captcha()
time.sleep(5)
except:
pass
return _is_bypassed(sb)
except Exception as e:
logger.debug(f"Method 3 failed: {e}")
return False
def _bypass(sb, max_retries: int = MAX_RETRY) -> None:
"""Enhanced bypass function with multiple strategies"""
try_count = 0
methods = [_bypass_method_1, _bypass_method_2, _bypass_method_3]
while not _is_bypassed(sb):
if try_count >= max_retries:
logger.warning("Exceeded maximum retries. Bypass failed.")
break
method_index = try_count % len(methods)
method = methods[method_index]
logger.info(f"Bypass attempt {try_count + 1} / {max_retries} using {method.__name__}")
try_count += 1
# Progressive backoff: wait longer between retries
wait_time = min(DEFAULT_SLEEP * (try_count - 1), 15)
if wait_time > 0:
logger.info(f"Waiting {wait_time}s before trying...")
time.sleep(wait_time)
try:
if method(sb):
logger.info(f"Bypass successful using {method.__name__}")
return
except Exception as e:
logger.warning(f"Exception in {method.__name__}: {e}")
logger.info(f"Bypass method {method.__name__} failed.")
def _get_chromium_args():
arguments = [
# Ignore certificate and SSL errors (similar to curl's --insecure)
"--ignore-certificate-errors",
"--ignore-ssl-errors",
"--allow-running-insecure-content",
"--ignore-certificate-errors-spki-list",
"--ignore-certificate-errors-skip-list"
]
# Conditionally add verbose logging arguments
if DEBUG:
arguments.extend([
"--enable-logging", # Enable Chrome browser logging
"--v=1", # Set verbosity level for Chrome logs
"--log-file=" + str(LOG_DIR / "chrome_browser.log")
])
# Add proxy settings if configured
if PROXIES:
proxy_url = PROXIES.get('https') or PROXIES.get('http')
if proxy_url:
arguments.append(f'--proxy-server={proxy_url}')
# --- Add Custom DNS settings ---
try:
if len(CUSTOM_DNS) > 0:
if DOH_SERVER:
logger.info(f"Configuring DNS over HTTPS (DoH) with server: {DOH_SERVER}")
# TODO: This is probably broken and a halucination,
# but it should still default to google DOH so its fine...
arguments.extend(['--enable-features=DnsOverHttps', '--dns-over-https-mode=secure', f'--dns-over-https-servers="{DOH_SERVER}"'])
doh_hostname = urlparse(DOH_SERVER).hostname
if doh_hostname:
try:
arguments.append(f'--host-resolver-rules=MAP {doh_hostname} {socket.gethostbyname(doh_hostname)}')
except socket.gaierror:
logger.warning(f"Could not resolve DoH hostname: {doh_hostname}")
elif CUSTOM_DNS:
arguments.append(f'--dns-server="{",".join(CUSTOM_DNS)}"')
arguments.append(f'--disable-features=DnsOverHttps')
except Exception as e:
logger.error_trace(f"Error configuring DNS settings: {e}")
return arguments
CHROMIUM_ARGS = _get_chromium_args()
def _get(url, retry : int = MAX_RETRY):
try:
logger.info(f"SB_GET: {url}")
sb = _get_driver()
# Enhanced page loading with better error handling
logger.debug("Opening URL with SeleniumBase...")
sb.uc_open_with_reconnect(url, DEFAULT_SLEEP)
time.sleep(DEFAULT_SLEEP)
# Log current page title and URL for debugging
try:
current_url = sb.get_current_url()
current_title = sb.get_title()
logger.debug(f"Page loaded - URL: {current_url}, Title: {current_title}")
except Exception as debug_e:
logger.debug(f"Could not get page info: {debug_e}")
# Attempt bypass
logger.debug("Starting bypass process...")
_bypass(sb)
if _is_bypassed(sb):
logger.info("Bypass successful.")
return sb.page_source
else:
logger.warning("Bypass completed but page still shows Cloudflare protection")
# Log page content for debugging (truncated)
try:
page_text = sb.get_text("body")[:500] + "..." if len(sb.get_text("body")) > 500 else sb.get_text("body")
logger.debug(f"Page content: {page_text}")
except:
pass
except Exception as e:
# Enhanced error logging with full stack trace
import traceback
error_details = f"Exception type: {type(e).__name__}, Message: {str(e)}"
stack_trace = traceback.format_exc()
if retry == 0:
logger.error(f"Failed to initialize browser after all retries: {error_details}")
logger.debug(f"Full stack trace: {stack_trace}")
_reset_driver()
raise e
logger.warning(f"Failed to bypass Cloudflare (retry {MAX_RETRY - retry + 1}/{MAX_RETRY}): {error_details}")
logger.debug(f"Stack trace: {stack_trace}")
# Reset driver on certain errors
if "WebDriverException" in str(type(e)) or "SessionNotCreatedException" in str(type(e)):
logger.info("Resetting driver due to WebDriver error...")
_reset_driver()
return _get(url, retry - 1)
def get(url, retry : int = MAX_RETRY):
global LOCKED, TENTATIVE_CURRENT_URL, LAST_USED
with LOCKED:
TENTATIVE_CURRENT_URL = url
ret = _get(url, retry)
LAST_USED = time.time()
return ret
def _init_driver():
global DRIVER
if DRIVER:
_reset_driver()
driver = Driver(uc=True, headless=False, size=f"{VIRTUAL_SCREEN_SIZE[0]},{VIRTUAL_SCREEN_SIZE[1]}", chromium_arg=CHROMIUM_ARGS)
DRIVER = driver
time.sleep(DEFAULT_SLEEP)
return driver
def _get_driver():
global DRIVER, DISPLAY
global LAST_USED
logger.info("Getting driver...")
LAST_USED = time.time()
if env.DOCKERMODE and env.USE_CF_BYPASS and not DISPLAY["xvfb"]:
from pyvirtualdisplay import Display
display = Display(visible=False, size=VIRTUAL_SCREEN_SIZE)
display.start()
logger.info("Display started")
DISPLAY["xvfb"] = display
time.sleep(DEFAULT_SLEEP)
_reset_pyautogui_display_state()
if env.DEBUG:
timestamp = datetime.now().strftime("%y%m%d-%H%M%S")
output_file = RECORDING_DIR / f"screen_recording_{timestamp}.mp4"
ffmpeg_cmd = [
"ffmpeg",
"-y",
"-f", "x11grab",
"-video_size", f"{VIRTUAL_SCREEN_SIZE[0]}x{VIRTUAL_SCREEN_SIZE[1]}",
"-i", f":{display.display}",
"-c:v", "libx264",
"-preset", "ultrafast", # or "veryfast" (trade speed for slightly better compression)
"-maxrate", "700k", # Slightly higher bitrate for text clarity
"-bufsize", "1400k", # Buffer size (2x maxrate)
"-crf", "36", # Adjust as needed: higher = smaller, lower = better quality (23 is visually lossless)
"-pix_fmt", "yuv420p", # Crucial for compatibility with most players
"-tune", "animation", # Optimize encoding for screen content
"-x264-params", "bframes=0:deblock=-1,-1", # Optimize for text, disable b-frames and deblocking
"-r", "15", # Reduce frame rate (if content allows)
"-an", # Disable audio recording (if not needed)
output_file.as_posix(),
"-nostats", "-loglevel", "0"
]
logger.info("Starting FFmpeg recording to %s", output_file)
logger.debug_trace(f"FFmpeg command: {' '.join(ffmpeg_cmd)}")
DISPLAY["ffmpeg"] = subprocess.Popen(ffmpeg_cmd)
if not DRIVER:
return _init_driver()
logger.log_resource_usage()
return DRIVER
def _reset_driver():
logger.log_resource_usage()
logger.info("Resetting driver...")
global DRIVER, DISPLAY
if DRIVER:
try:
DRIVER.quit()
DRIVER = None
except Exception as e:
logger.warning(f"Error quitting driver: {e}")
time.sleep(0.5)
if DISPLAY["xvfb"]:
try:
DISPLAY["xvfb"].stop()
DISPLAY["xvfb"] = None
except Exception as e:
logger.warning(f"Error stopping display: {e}")
time.sleep(0.5)
try:
os.system("pkill -f Xvfb")
except Exception as e:
logger.debug(f"Error killing Xvfb: {e}")
time.sleep(0.5)
if DISPLAY["ffmpeg"]:
try:
DISPLAY["ffmpeg"].send_signal(signal.SIGINT)
DISPLAY["ffmpeg"] = None
except Exception as e:
logger.debug(f"Error stopping ffmpeg: {e}")
time.sleep(0.5)
try:
os.system("pkill -f ffmpeg")
except Exception as e:
logger.debug(f"Error killing ffmpeg: {e}")
time.sleep(0.5)
try:
os.system("pkill -f chrom")
except Exception as e:
logger.debug(f"Error killing chrom: {e}")
time.sleep(0.5)
logger.info("Driver reset.")
logger.log_resource_usage()
def _cleanup_driver():
global LOCKED
global LAST_USED
with LOCKED:
if LAST_USED:
if time.time() - LAST_USED >= env.BYPASS_RELEASE_INACTIVE_MIN * 60:
_reset_driver()
LAST_USED = None
logger.info("Driver reset due to inactivity.")
def _cleanup_loop():
while True:
_cleanup_driver()
time.sleep(max(env.BYPASS_RELEASE_INACTIVE_MIN / 2, 1))
def _init_cleanup_thread():
cleanup_thread = threading.Thread(target=_cleanup_loop)
cleanup_thread.daemon = True
cleanup_thread.start()
def wait_for_result(func, timeout : int = 10, condition : any = True):
start_time = time.time()
while time.time() - start_time < timeout:
result = func()
if condition(result):
return result
time.sleep(0.5)
return None
_init_cleanup_thread()
def get_bypassed_page(url: str) -> Optional[str]:
"""Fetch HTML content from a URL using the internal Cloudflare Bypasser.
Args:
url: Target URL
Returns:
str: HTML content if successful, None otherwise
"""
response_html = get(url)
logger.debug(f"Cloudflare Bypasser response length: {len(response_html)}")
if response_html.strip() != "":
return response_html
else:
raise requests.exceptions.RequestException("Failed to bypass Cloudflare")
-34
View File
@@ -1,34 +0,0 @@
from logger import setup_logger
from typing import Optional
import requests
try:
from env import EXT_BYPASSER_PATH, EXT_BYPASSER_TIMEOUT, EXT_BYPASSER_URL
except ImportError:
raise RuntimeError("Failed to import environment variables. Are you using an `extbp` image?")
logger = setup_logger(__name__)
def get_bypassed_page(url: str) -> Optional[str]:
"""Fetch HTML content from a URL using an External Cloudflare Resolver.
Args:
url: Target URL
Returns:
str: HTML content if successful, None otherwise
"""
if not EXT_BYPASSER_URL or not EXT_BYPASSER_PATH:
logger.error("Wrong External Bypass configuration. Please check your environment configuration.")
return None
ext_url = f"{EXT_BYPASSER_URL}{EXT_BYPASSER_PATH}"
headers = {"Content-Type": "application/json"}
data = {
"cmd": "request.get",
"url": url,
"maxTimeout": EXT_BYPASSER_TIMEOUT
}
response = requests.post(ext_url, headers=headers, json=data)
response.raise_for_status()
logger.debug(f"External Bypass response for '{url}': {response.json()['status']} - {response.json()['message']}")
return response.json()['solution']['response']
+16
View File
@@ -0,0 +1,16 @@
services:
shelfmark-lite:
image: ghcr.io/calibrain/shelfmark-lite:latest
container_name: shelfmark-lite
environment:
# EXT_BYPASSER_URL: http://flaresolverr:8191 #If using Flaresolverr
PUID: 1000
PGID: 1000
ports:
- 8084:8084
restart: unless-stopped
volumes:
- /path/to/books:/books # Default destination for book downloads
- /path/to/config:/config # App configuration
# Required for torrent / usenet - path must match your download client's volume exactly
# - /path/to/downloads:/path/to/downloads
+20
View File
@@ -0,0 +1,20 @@
# Routes all traffic through Tor - requires root startup
services:
shelfmark-tor:
image: ghcr.io/calibrain/shelfmark:latest
environment:
FLASK_PORT: 8084
USING_TOR: true
PUID: 1000
PGID: 1000
cap_add:
- NET_ADMIN
- NET_RAW
ports:
- 8084:8084
restart: unless-stopped
volumes:
- /path/to/books:/books # Default destination for book downloads
- /path/to/config:/config # App configuration
# Required for torrent / usenet - path must match your download client's volume exactly
# - /path/to/downloads:/path/to/downloads
+15
View File
@@ -0,0 +1,15 @@
services:
shelfmark:
image: ghcr.io/calibrain/shelfmark:latest
container_name: shelfmark
environment:
PUID: 1000
PGID: 1000
ports:
- 8084:8084
restart: unless-stopped
volumes:
- /path/to/books:/books # Default destination for book downloads
- /path/to/config:/config # App configuration
# Required for torrent / usenet - path must match your download client's volume exactly
# - /path/to/downloads:/path/to/downloads
-101
View File
@@ -1,101 +0,0 @@
"""Configuration settings for the book downloader application."""
import os
from pathlib import Path
import json
import env
from logger import setup_logger
logger = setup_logger(__name__)
for key, value in env.__dict__.items():
if not key.startswith('_'):
if key == "AA_DONATOR_KEY" and value.strip() != "":
value = "REDACTED"
logger.info(f"{key}: {value}")
with open("data/book-languages.json") as file:
_SUPPORTED_BOOK_LANGUAGE = json.load(file)
# Directory settings
BASE_DIR = Path(__file__).resolve().parent
logger.info(f"BASE_DIR: {BASE_DIR}")
if env.ENABLE_LOGGING:
env.LOG_DIR.mkdir(exist_ok=True)
# Create necessary directories
env.TMP_DIR.mkdir(exist_ok=True)
env.INGEST_DIR.mkdir(exist_ok=True)
CROSS_FILE_SYSTEM = os.stat(env.TMP_DIR).st_dev != os.stat(env.INGEST_DIR).st_dev
logger.info(f"STAT TMP_DIR: {os.stat(env.TMP_DIR)}")
logger.info(f"STAT INGEST_DIR: {os.stat(env.INGEST_DIR)}")
logger.info(f"CROSS_FILE_SYSTEM: {CROSS_FILE_SYSTEM}")
# Network settings
_custom_dns = env._CUSTOM_DNS.lower().strip()
_doh_server = ""
if _custom_dns == "google":
CUSTOM_DNS = ["8.8.8.8", "8.8.4.4", "2001:4860:4860:0000:0000:0000:0000:8888", "2001:4860:4860:0000:0000:0000:0000:8844"]
_doh_server = "https://dns.google/dns-query"
elif _custom_dns == "quad9":
CUSTOM_DNS = ["9.9.9.9", "149.112.112.112", "2620:00fe:0000:0000:0000:0000:0000:00fe", "2620:00fe:0000:0000:0000:0000:0000:0009"]
_doh_server = "https://dns.quad9.net/dns-query"
elif _custom_dns == "cloudflare":
CUSTOM_DNS = ["1.1.1.1", "1.0.0.1", "2606:4700:4700:0000:0000:0000:0000:1111", "2606:4700:4700:0000:0000:0000:0000:1001"]
_doh_server = "https://cloudflare-dns.com/dns-query"
elif _custom_dns == "opendns":
CUSTOM_DNS = ["208.67.222.222", "208.67.220.220", "2620:0119:0035:0000:0000:0000:0000:0035", "2620:0119:0053:0000:0000:0000:0000:0053"]
_doh_server = "https://doh.opendns.com/dns-query"
else:
_custom_dns_ip = _custom_dns.split(",")
CUSTOM_DNS = [dns.strip() for dns in _custom_dns_ip if dns.replace(":", "").replace(".", "").strip().isdigit()]
logger.info(f"CUSTOM_DNS: {CUSTOM_DNS}")
DOH_SERVER = _doh_server
if env.USE_DOH:
DOH_SERVER = _doh_server
else:
DOH_SERVER = ""
logger.info(f"DOH_SERVER: {DOH_SERVER}")
# Proxy settings
PROXIES = {}
if env.HTTP_PROXY:
PROXIES["http"] = env.HTTP_PROXY
if env.HTTPS_PROXY:
PROXIES["https"] = env.HTTPS_PROXY
logger.info(f"PROXIES: {PROXIES}")
# Anna's Archive settings
AA_BASE_URL = env._AA_BASE_URL
AA_AVAILABLE_URLS = ["https://annas-archive.org", "https://annas-archive.se", "https://annas-archive.li"]
AA_AVAILABLE_URLS.extend(env._AA_ADDITIONAL_URLS.split(","))
AA_AVAILABLE_URLS = [url.strip() for url in AA_AVAILABLE_URLS if url.strip()]
# File format settings
SUPPORTED_FORMATS = env._SUPPORTED_FORMATS.split(",")
logger.info(f"SUPPORTED_FORMATS: {SUPPORTED_FORMATS}")
# Complex language processing logic kept in config.py
BOOK_LANGUAGE = env._BOOK_LANGUAGE.split(',')
BOOK_LANGUAGE = [l for l in BOOK_LANGUAGE if l in [lang['code'] for lang in _SUPPORTED_BOOK_LANGUAGE]]
if len(BOOK_LANGUAGE) == 0:
BOOK_LANGUAGE = ['en']
# Custom script settings with validation logic
CUSTOM_SCRIPT = env._CUSTOM_SCRIPT
if CUSTOM_SCRIPT:
if not os.path.exists(CUSTOM_SCRIPT):
logger.warn(f"CUSTOM_SCRIPT {CUSTOM_SCRIPT} does not exist")
CUSTOM_SCRIPT = ""
elif not os.access(CUSTOM_SCRIPT, os.X_OK):
logger.warn(f"CUSTOM_SCRIPT {CUSTOM_SCRIPT} is not executable")
CUSTOM_SCRIPT = ""
# Debugging settings
if not env.USING_EXTERNAL_BYPASSER:
# Virtual display settings for debugging internal cloudflare bypasser
VIRTUAL_SCREEN_SIZE = (1024, 768)
RECORDING_DIR = env.LOG_DIR / "recording"
if env.DEBUG:
RECORDING_DIR.mkdir(parents=True, exist_ok=True)
+1 -1
View File
@@ -69,4 +69,4 @@
{ "language": "Uyghur", "code": "ug" },
{ "language": "Armenian", "code": "hy" },
{ "language": "Shan", "code": "shn" }
]
]
+25
View File
@@ -0,0 +1,25 @@
# Local development - External bypasser variant (lite)
services:
shelfmark-lite-dev:
extends:
file: ./compose/docker-compose.lite.yml
service: shelfmark-lite
build:
context: .
dockerfile: Dockerfile
target: shelfmark-lite
environment:
DEBUG: true
EXT_BYPASSER_URL: http://flaresolverr:8191
EXT_BYPASSER_PATH: /v1
EXT_BYPASSER_TIMEOUT: 60000
volumes:
- ./.local/config:/config
- ./.local/books:/books
- ./.local/log:/var/log/shelfmark
- ./.local/tmp:/tmp/shelfmark
# Required for torrent / usenet - path must match your download client's volume exactly
# - /path/to/downloads:/path/to/downloads
flaresolverr:
image: ghcr.io/flaresolverr/flaresolverr:latest
+20
View File
@@ -0,0 +1,20 @@
# Local development - Tor variant
services:
shelfmark-tor-dev:
extends:
file: ./compose/docker-compose.tor.yml
service: shelfmark-tor
build:
context: .
dockerfile: Dockerfile
target: shelfmark
environment:
DEBUG: true
USING_TOR: true
volumes:
- ./.local/config:/config
- ./.local/books:/books
- ./.local/log:/var/log/shelfmark
- ./.local/tmp:/tmp/shelfmark
# Required for torrent / usenet - path must match your download client's volume exactly
# - /path/to/downloads:/path/to/downloads
+17 -8
View File
@@ -1,16 +1,25 @@
# Local development - builds from source with debug enabled
services:
calibre-web-automated-book-downloader-dev:
shelfmark-dev:
extends:
file: ./docker-compose.yml
service: calibre-web-automated-book-downloader
file: ./compose/docker-compose.yml
service: shelfmark
build:
context: .
dockerfile: Dockerfile
target: cwa-bd
target: shelfmark
cap_add:
- SYS_PTRACE
environment:
DEBUG: true
USE_DOH: true
CUSTOM_DNS: cloudflare
# HIDE_LOCAL_AUTH: true
OIDC_AUTO_REDIRECT: true
volumes:
- /tmp/cwa-book-downloader:/tmp/cwa-book-downloader
- /tmp/cwa-book-downloader-log:/var/log/cwa-book-downloader
- ./.local/config:/config
- ./.local/books:/books
- ./.local/log:/var/log/shelfmark
- ./.local/tmp:/tmp/shelfmark
- ./shelfmark:/app/shelfmark:ro
- ./frontend-dist:/app/frontend-dist:ro
# Required for torrent / usenet - path must match your download client's volume exactly
# - /path/to/downloads:/path/to/downloads
-34
View File
@@ -1,34 +0,0 @@
services:
calibre-web-automated-book-downloader-extbp-dev:
container_name: cwa-bd-extbp-dev
extends:
file: ./docker-compose.extbp.yml
service: calibre-web-automated-book-downloader-extbp
build:
context: .
dockerfile: Dockerfile
target: cwa-bd-extbp
environment:
DEBUG: true
USE_DOH: true
CUSTOM_DNS: cloudflare
USE_CF_BYPASS: true # Enable Cloudflare bypass (default: true)
# External Cloudflare Bypass environment variables
EXT_BYPASSER_URL: "http://flaresolverr:8191" # URL of the external Cloudflare resolver service (used FlareSolverr)
EXT_BYPASSER_PATH: "/v1" # Path for external Cloudflare resolver API (default: /v1)
EXT_BYPASSER_TIMEOUT: 60000 # Timeout for external Cloudflare resolver requests (default: 60000)
volumes:
#- /tmp/cwa-book-downloader:/tmp/cwa-book-downloader
#- /tmp/cwa-book-downloader-log:/var/log/cwa-book-downloader
- ./deploy/ingest:/cwa-book-ingest
- ./deploy/log:/var/log/cwa-book-downloader
- ./deploy/tmp:/tmp/cwa-book-downloader
flaresolverr: # External Cloudflare resolver service
image: ghcr.io/flaresolverr/flaresolverr:v3.3.22
container_name: flaresolverr
environment:
LOG_LEVEL: info
LOG_HTML: false
CAPTCHA_SOLVER: none
TZ: Europe/Rome
-27
View File
@@ -1,27 +0,0 @@
services:
calibre-web-automated-book-downloader-extbp:
image: ghcr.io/calibrain/calibre-web-automated-book-downloader-extbp:latest
environment:
FLASK_PORT: 8084
LOG_LEVEL: info
BOOK_LANGUAGE: en
USE_BOOK_TITLE: true
TZ: America/New_York
UID: 1000
GID: 100
# CWA_DB_PATH: /auth/app.db # Uncomment to enable authentication
# SESSION_COOKIE_SECURE: 'true' # Set to 'true' if accessing ONLY via HTTPS
# DEBUG: 'true' # Enable debug mode (debug button, verbose logging)
EXT_BYPASSER_URL: http://flaresolverr:8191
ports:
- 8084:8084
restart: unless-stopped
volumes:
# This is where the books will be downloaded to, usually it would be
# the same as whatever you gave in "calibre-web-automated"
- /tmp/data/calibre-web/ingest:/cwa-book-ingest
# This is the location of CWA's app.db, which contains authentication
# details. Uncomment to enable authentication (also uncomment CWA_DB_PATH above)
#- /cwa/config/path/app.db:/auth/app.db:ro
flaresolverr:
image: ghcr.io/flaresolverr/flaresolverr:latest
+200
View File
@@ -0,0 +1,200 @@
# Test stack for download client development
# Includes shelfmark + all download clients on same network with shared volumes
#
# Usage:
# docker compose -f docker-compose.test-clients.yml up -d
# # Access shelfmark at http://localhost:8084
# # Configure clients in Settings > Prowlarr > Download Clients
#
# Web UIs:
# - shelfmark: http://localhost:8084
# - Prowlarr: http://localhost:9696 (no auth by default)
# - qBittorrent: http://localhost:8080 (check container logs for temp password)
# - Transmission: http://localhost:9091 (admin / admin)
# - Deluge: http://localhost:8112 (password: deluge)
# - NZBGet: http://localhost:6789 (nzbget / tegbzn6789)
# - NZBHydra: http://localhost:5076 (no auth by default)
# - SABnzbd: http://localhost:8085 (complete setup wizard for API key)
# - rTorrent: http://localhost:8000 (admin / admin - if auth enabled)
#
services:
shelfmark:
build:
context: .
dockerfile: Dockerfile
target: shelfmark
container_name: test-shelfmark
cap_add:
- SYS_PTRACE
environment:
TZ: UTC
DEBUG: "true"
# All client configuration is done via Settings UI
# Use Docker service names for URLs:
# - qBittorrent: http://qbittorrent:8080
# - Transmission: http://transmission:9091
# - Deluge Web UI: http://deluge:8112
# - NZBGet: http://nzbget:6789
# - NZBHydra: http://nzbhydra:5076
# - SABnzbd: http://sabnzbd:8080
# - rTorrent: http://rtorrent:80 (XMLRPC via HTTP) or rtorrent (port 5000 for SCGI)
ports:
- "8084:8084"
volumes:
# Config and state
- ./.local/test-clients/shelfmark/config:/config
- ./.local/test-clients/shelfmark/log:/var/log/shelfmark
# Book destination directory (where completed books go)
- ./.local/test-clients/books:/books
# Staging directory
- ./.local/test-clients/tmp:/tmp/shelfmark
# CRITICAL: Mount client download directories so shelfmark can access completed files
- ./.local/test-clients/downloads:/downloads
# Mount source code for hot-reload (no rebuild needed for Python changes)
- ./shelfmark:/app/shelfmark:ro
# Mount tests for running pytest in container
- ./tests:/app/tests:ro
- ./pyproject.toml:/app/pyproject.toml:ro
- ./uv.lock:/app/uv.lock:ro
# Mount client configs for integration tests to read credentials
- ./.local/test-clients/qbittorrent/config:/qbittorrent-config:ro
- ./.local/test-clients/sabnzbd/config:/sabnzbd-config:ro
depends_on:
- nzbget
- nzbhydra
- sabnzbd
- qbittorrent
- transmission
- deluge
- rtorrent
restart: unless-stopped
prowlarr:
image: lscr.io/linuxserver/prowlarr:latest
container_name: test-prowlarr
environment:
- PUID=1000
- PGID=1000
- TZ=UTC
volumes:
- ./.local/test-clients/prowlarr/config:/config
ports:
- "9696:9696"
restart: unless-stopped
nzbget:
image: lscr.io/linuxserver/nzbget:latest
container_name: test-nzbget
environment:
- PUID=1000
- PGID=1000
- TZ=UTC
volumes:
- ./.local/test-clients/nzbget/config:/config
- ./.local/test-clients/downloads:/downloads
- ./.local/test-clients/nzbget/custom-cont-init.d:/custom-cont-init.d:ro
ports:
- "6789:6789" # Web UI / JSON-RPC
restart: unless-stopped
sabnzbd:
image: lscr.io/linuxserver/sabnzbd:latest
container_name: test-sabnzbd
environment:
- PUID=1000
- PGID=1000
- TZ=UTC
volumes:
- ./.local/test-clients/sabnzbd/config:/config
- ./.local/test-clients/downloads:/downloads
ports:
- "8085:8080" # Web UI (external:internal)
restart: unless-stopped
qbittorrent:
image: lscr.io/linuxserver/qbittorrent:latest
container_name: test-qbittorrent
environment:
- PUID=1000
- PGID=1000
- TZ=UTC
- WEBUI_PORT=8080
volumes:
- ./.local/test-clients/qbittorrent/config:/config
- ./.local/test-clients/downloads:/downloads
- ./.local/test-clients/qbittorrent/custom-cont-init.d:/custom-cont-init.d:ro
ports:
- "8080:8080" # Web UI / API
- "6882:6881"
- "6882:6881/udp"
restart: unless-stopped
transmission:
image: lscr.io/linuxserver/transmission:latest
container_name: test-transmission
environment:
- PUID=1000
- PGID=1000
- TZ=UTC
- USER=admin
- PASS=admin
volumes:
- ./.local/test-clients/transmission/config:/config
- ./.local/test-clients/downloads:/downloads
ports:
- "9091:9091" # Web UI / RPC
- "51413:51413"
- "51413:51413/udp"
restart: unless-stopped
deluge:
image: lscr.io/linuxserver/deluge:latest
container_name: test-deluge
environment:
- PUID=1000
- PGID=1000
- TZ=UTC
- DELUGE_LOGLEVEL=error
volumes:
- ./.local/test-clients/deluge/config:/config
- ./.local/test-clients/downloads:/downloads
ports:
- "8112:8112" # Web UI
- "58846:58846" # Daemon RPC
- "6881:6881"
- "6881:6881/udp"
restart: unless-stopped
rtorrent:
image: crazymax/rtorrent-rutorrent:latest # linuxserver has deprecated their rtorrent image
container_name: test-rtorrent
environment:
- PUID=1000
- PGID=1000
- TZ=UTC
volumes:
- ./.local/test-clients/rtorrent/config:/config
- ./.local/test-clients/downloads:/downloads
ports:
- "8000:8000" # XMLRPC
- "8089:8080" # ruTorrent Web UI
- "9000:9000" # SCGI port
- "50000:50000" # Incoming connections
- "6881:6881/udp"
restart: unless-stopped
nzbhydra:
image: lscr.io/linuxserver/nzbhydra2:latest
container_name: nzbhydra
environment:
- PUID=1000
- PGID=1000
- TZ=Europe/London
volumes:
- ./.local/test-clients/nzbhydra/config:/config
- ./.local/test-clients/downloads:/downloads
ports:
- 5076:5076
restart: unless-stopped
-14
View File
@@ -1,14 +0,0 @@
services:
calibre-web-automated-book-downloader-tor-dev:
extends:
file: ./docker-compose.tor.yml
service: calibre-web-automated-book-downloader-tor
build:
context: .
dockerfile: Dockerfile
target: cwa-bd-tor
environment:
DEBUG: true
volumes:
- /tmp/cwa-book-downloader:/tmp/cwa-book-downloader
- /tmp/cwa-book-downloader-log:/var/log/cwa-book-downloader
-26
View File
@@ -1,26 +0,0 @@
services:
calibre-web-automated-book-downloader-tor:
image: ghcr.io/calibrain/calibre-web-automated-book-downloader-tor:latest
environment:
FLASK_PORT: 8084
LOG_LEVEL: info
BOOK_LANGUAGE: en
USE_BOOK_TITLE: true
TZ: America/New_York
USING_TOR: true
# CWA_DB_PATH: /auth/app.db # Uncomment to enable authentication
# SESSION_COOKIE_SECURE: 'true' # Set to 'true' if accessing ONLY via HTTPS
# DEBUG: 'true' # Enable debug mode (debug button, verbose logging)
cap_add:
- NET_ADMIN
- NET_RAW
ports:
- 8084:8084
restart: unless-stopped
volumes:
# This is where the books will be downloaded to, usually it would be
# the same as whatever you gave in "calibre-web-automated"
- /tmp/data/calibre-web/ingest:/cwa-book-ingest
# This is the location of CWA's app.db, which contains authentication
# details. Uncomment to enable authentication (also uncomment CWA_DB_PATH above)
#- /cwa/config/path/app.db:/auth/app.db:ro
-32
View File
@@ -1,32 +0,0 @@
services:
calibre-web-automated-book-downloader:
image: ghcr.io/calibrain/calibre-web-automated-book-downloader:latest
# Uncomment to build the image from the Dockerfile for local testing changes.
# Remember to comment out the image line above.
#build: .
container_name: calibre-web-automated-book-downloader
environment:
FLASK_PORT: 8084
LOG_LEVEL: info
BOOK_LANGUAGE: en
USE_BOOK_TITLE: true
TZ: America/New_York
UID: 1000
GID: 100
# CWA_DB_PATH: /auth/app.db # Uncomment to enable authentication (also uncomment volume below)
# CALIBRE_WEB_URL: http://localhost:8080 # Uncomment and add your custom library URL to enable "Go To Library" button in the Web UI
# SESSION_COOKIE_SECURE: 'true' # Set to 'true' if accessing ONLY via HTTPS
# DEBUG: 'true' # Enable debug mode (debug button, verbose logging)
# Queue management settings
MAX_CONCURRENT_DOWNLOADS: 3
DOWNLOAD_PROGRESS_UPDATE_INTERVAL: 5
ports:
- 8084:8084
restart: unless-stopped
volumes:
# This is where the books will be downloaded to, usually it would be
# the same as whatever you gave in "calibre-web-automated"
- /tmp/data/calibre-web/ingest:/cwa-book-ingest
# This is the location of CWA's app.db, which contains authentication
# details. Uncomment to enable authentication (also uncomment CWA_DB_PATH above)
#- /cwa/config/path/app.db:/auth/app.db:ro
+131
View File
@@ -0,0 +1,131 @@
# Directory and Volume Setup
This guide explains how to configure directories and Docker volumes for Shelfmark. It focuses on the difference between the destination folder and your download client paths, and how to make those paths line up inside containers.
## Conceptual Overview
```
DIRECT DOWNLOADS
Shelfmark downloads directly -> destination
TORRENT / USENET
Prowlarr -> Download client saves to <client path>
-> Shelfmark reads from <client path>
-> Shelfmark processes to destination
```
Key point: For torrent and usenet downloads, Shelfmark must see the same file path that your download client reports. The container path must match in both containers.
## Direct Download Volume Setup
If you plan to use Direct Download, it does not use an external download client. A simple two-folder setup is enough.
Required volumes:
| Container path | Purpose | Notes |
| --- | --- | --- |
| `/config` | Settings, database, cover cache | Configurable via `CONFIG_DIR` |
| `/books` | Destination folder for completed files | Configurable via `INGEST_DIR` and Settings -> Downloads -> Destination |
Example `docker-compose`:
```yaml
services:
shelfmark:
image: ghcr.io/calibrain/shelfmark:latest
volumes:
- /path/to/config:/config
- /path/to/books:/books
```
Notes:
- Point `/books` to your library ingest folder (Calibre-Web, Booklore, Audiobookshelf, etc) for automatic import.
- If you set Books Output Mode to Booklore (API), books are uploaded via API instead of written to `/books`. Audiobooks still use a destination folder.
- Ensure `PUID`/`PGID` (or legacy `UID`/`GID`) match the owner of the host directories.
- For non-root mode, start the container as `1000:1000`.
- On Kubernetes, set `runAsUser: 1000`, `runAsGroup: 1000`, and `runAsNonRoot: true` together.
- `PUID`/`PGID` keep the default root startup flow.
- In non-root mode, mounted paths must already be writable by `1000:1000`.
- `USING_TOR=true` requires root startup.
## Torrent / Usenet Setup
For torrents and usenet, your download client reports a path (for example `/data/torrents/books/MyBook.epub`). Shelfmark must be able to read that exact path inside its own container.
Required volumes:
| Container path | Purpose | Notes |
| --- | --- | --- |
| `/config` | Settings, database, cover cache | Configurable via `CONFIG_DIR` |
| `/books` | Destination folder for processed files | Configurable via `INGEST_DIR` |
| `<client path>` | Download client path | Must match the download client container path exactly |
Side-by-side example with qBittorrent:
```yaml
services:
shelfmark:
volumes:
- /path/to/config:/config
- /path/to/books:/books
- /path/to/downloads:/data/torrents # Must match client
qbittorrent:
volumes:
- /path/to/downloads:/data/torrents # Same container path
```
Host paths can be anything. The container path (for example `/data/torrents`) must be identical in both containers.
### Remote Path Mappings
If paths cannot match (different machines or a fixed setup), use Remote Path Mappings.
Where to configure:
- Settings -> Advanced -> Remote Path Mappings
Example:
- Client reports `/data/torrents/books/...`
- Shelfmark can see the same files at `/downloads/books/...`
- Add a mapping from Remote Path `/data/torrents` to Local Path `/downloads`
## File Processing Options
### Transfer Method (Torrent / Usenet Only)
Available methods:
- Copy (default). Works everywhere.
- Hardlink. Preserves seeding without duplicating files.
Hardlink requirements and behavior:
- Source and destination must be on the same filesystem.
- If hardlinking is enabled but not possible, Shelfmark falls back to copying.
- Archive extraction is disabled while hardlinking is enabled.
- Do not use hardlinking if your destination is a library ingest folder.
### File Organization
Shelfmark supports three organization modes for the destination:
- None. Keep original filenames from the source.
- Rename Only. Rename files using a template.
- Rename and Organize. Create folders and rename using templates. Do not use with ingest folders.
Configure templates in Settings -> Downloads. Template syntax details are documented separately.
## Common Mistakes
- "Download failed - file not found": Path mismatch between Shelfmark and the download client. Ensure container paths match or use Remote Path Mappings.
- "Permission denied": `PUID`/`PGID` do not match the host directories. Ensure Shelfmark can read the client path and write to the destination.
- "Permission denied" in non-root Docker/Kubernetes mode: ensure the mounted path is writable by UID/GID `1000:1000`, or switch back to root startup with `PUID`/`PGID`.
- "Hardlinks not working" or "Files being copied instead": Source and destination are on different filesystems. Move the destination or accept copy fallback.
- "Downloads work but library does not see them": Destination does not point to the library ingest folder. Check Settings -> Downloads -> Destination.
- CIFS/SMB shares: Use the `nobrl` mount option to avoid database lock errors. Example: `//server/share /mnt/share cifs nobrl,... 0 0`
## Related Documentation
- Environment Variables Reference: `docs/environment-variables.md`
- Custom Scripts: `docs/custom-scripts.md`
- Installation: `docs/installation.md`
- Troubleshooting: `docs/troubleshooting.md`
+195
View File
@@ -0,0 +1,195 @@
# Custom Scripts
Shelfmark can run an executable you provide after a download task completes successfully. The script runs after the selected output has finished (for example: transfer to the folder destination, or upload to Booklore).
## Quick Start (Recommended)
1. Put your script on the machine that runs Shelfmark.
1. Make it executable.
1. Set it in Shelfmark (Settings -> Advanced -> Custom Script Path).
Example:
```bash
chmod +x /path/to/your/scripts/post_process.sh
```
### Docker Users
If you run Shelfmark in Docker, the script must exist inside the container. The easiest way is to mount a folder of scripts, then point Shelfmark at the container path in the UI.
```yaml
services:
shelfmark:
image: ghcr.io/calibrain/shelfmark:latest
volumes:
- /path/to/your/scripts:/scripts:ro
```
Then set:
- Settings -> Advanced -> Custom Script Path: `/scripts/post_process.sh`
<details>
<summary>Docker Compose: Configure Via Environment Variables (Optional)</summary>
```yaml
services:
shelfmark:
environment:
- CUSTOM_SCRIPT=/scripts/post_process.sh
- CUSTOM_SCRIPT_PATH_MODE=absolute
- CUSTOM_SCRIPT_JSON_PAYLOAD=true
```
</details>
## Script Behaviour
When enabled, Shelfmark runs your script once per successful task:
```bash
<custom_script_path> "<target_path>"
```
- `$1` is always set to the target path.
- If **Custom Script JSON Payload** is enabled, Shelfmark writes a JSON document to stdin (UTF-8).
- If JSON payload is disabled, stdin is empty (EOF).
- Timeout: 300 seconds (5 minutes)
- Exit code: `0` = success; anything else = the task is marked as **Error**
- Concurrency: downloads can run in parallel, so your script may be invoked concurrently for different tasks.
- Runtime: the script runs inside the Shelfmark container (if you use Docker) under the same user as Shelfmark.
## The Target Path (`$1`)
Shelfmark chooses a "best single path" for the task:
- If the output produced exactly one local file: that file path.
- If the output produced multiple local files: a directory path (the common parent directory of those files).
What the target path refers to depends on the output mode:
- Folder output (`output.mode=folder`, `phase=post_transfer`): the final imported file or folder inside your destination.
- Booklore output (`output.mode=booklore`, `phase=post_upload`): the local file or folder that was uploaded (the destination is remote).
By default, `$1` is an absolute path inside the Shelfmark container (or on your host, if you are not using Docker).
## JSON Payload (stdin)
Configure in: Settings -> Advanced -> Custom Script JSON Payload
When enabled, Shelfmark sends a versioned JSON payload to your script via stdin (and still passes `$1`). This is the recommended way to write robust scripts, especially for multi-file imports (audiobooks) and output-specific context (like Booklore).
- The JSON payload always includes absolute paths in `paths.*`, even if you set Custom Script Path Mode to `relative` for `$1`.
- `output.mode` tells you which output ran.
- `output.details` is output-specific. For Booklore output, `output.details.booklore` includes connection details such as `base_url`, `library_id`, and `path_id`.
- `phase` indicates when the script is running. Current values: `post_transfer` (folder output), `post_upload` (Booklore output).
- `transfer` is only included for outputs that do a local transfer (for example the folder output).
If JSON payload is disabled, stdin is empty (EOF). Don't `cat` stdin unless you've enabled the payload.
Example payload shape:
```json
{
"version": 1,
"phase": "post_transfer",
"task": {
"task_id": "abc123",
"source": "direct",
"title": "Foundation",
"author": "Isaac Asimov"
},
"output": {
"mode": "folder",
"organization_mode": "organize"
},
"paths": {
"destination": "/data/library/books",
"target": "/data/library/books/Isaac Asimov/Foundation/Foundation.epub",
"final_paths": [
"/data/library/books/Isaac Asimov/Foundation/Foundation.epub"
]
},
"transfer": {
"op_counts": {"copy": 1, "move": 0, "hardlink": 0},
"use_hardlink": false,
"is_torrent": false,
"preserve_source": false
}
}
```
Example (bash + python3) (JSON payload must be enabled):
```bash
payload="$(cat)"
target="$1"
PAYLOAD="$payload" TARGET="$target" python3 - <<'PY'
import json
import os
import sys
payload = json.loads(os.environ["PAYLOAD"])
print(f"target={os.environ['TARGET']}", file=sys.stderr)
print(
f"mode={payload['output']['mode']} title={payload['task']['title']}",
file=sys.stderr,
)
for path in payload["paths"]["final_paths"]:
print(path, file=sys.stderr)
PY
```
Example (Python) (works whether JSON payload is enabled or not):
```python
#!/usr/bin/env python3
import json
import sys
target = sys.argv[1]
raw = sys.stdin.read()
payload = json.loads(raw) if raw.strip() else None
print(f"target={target}", file=sys.stderr)
if payload:
print(f"mode={payload['output']['mode']} phase={payload['phase']}", file=sys.stderr)
```
<details>
<summary>Advanced Options</summary>
### Absolute vs Relative Target Paths
Configure in: Settings -> Advanced -> Custom Script Path Mode
This setting controls what gets passed as `$1`:
- `absolute` (default): pass an absolute path.
- `relative`: pass a path relative to the output's "destination root", and run the script with `$PWD` set to that root.
For folder output, the destination root is your configured destination folder. For Booklore output, it's the local upload folder.
Example (folder destination is `/data/library/books`, and the imported file ended up in `Isaac Asimov/Foundation/Foundation.epub`):
```bash
# Absolute mode:
$PWD is unchanged
$1 = /data/library/books/Isaac Asimov/Foundation/Foundation.epub
# Relative mode:
$PWD = /data/library/books
$1 = Isaac Asimov/Foundation/Foundation.epub
```
Note: if the target is the destination folder itself, `relative` mode may pass `.`.
</details>
## Notes And Caveats
- **Hardlinks and torrents:** if you use hardlinking to keep seeding, avoid scripts that modify file contents, since hardlinked files share data with the seeding copy.
- **Booklore output mode:** scripts run after upload. `$1` will point at the local uploaded file (or staging folder).
+3
View File
@@ -0,0 +1,3 @@
# Developer Documentation
TODO
+654
View File
@@ -0,0 +1,654 @@
# Plugin Settings Integration Guide
This guide explains how to add configuration settings to plugins (Metadata Providers and Release Sources) so they appear in the Settings UI.
## Overview
The settings system uses a decorator-based registration pattern. Plugins register their settings when their module is imported, and the frontend dynamically renders the appropriate UI based on the schema provided by the backend.
**Key features:**
- Settings are defined in Python and automatically rendered in the React frontend
- Values persist across container restarts via JSON config files
- Changes take effect immediately without restart (unless marked otherwise)
## Quick Start
Add settings to your plugin in 3 steps:
```python
from shelfmark.core.settings_registry import (
register_settings,
TextField,
PasswordField,
ActionButton,
)
@register_settings(
name="my_plugin", # Unique identifier
display_name="My Plugin", # Shown in sidebar
icon="wrench", # Icon name
order=100, # Sort order (lower = higher in list)
group="metadata_providers" # Optional: group in sidebar
)
def my_plugin_settings():
return [
PasswordField(
key="MY_PLUGIN_API_KEY",
label="API Key",
description="Your API key from the provider",
required=True,
),
ActionButton(
key="test_connection",
label="Test Connection",
style="primary",
callback=_test_connection,
),
]
def _test_connection():
# Perform connection test
return {"success": True, "message": "Connected successfully!"}
```
## Available Field Types
### TextField
Single-line text input for strings.
```python
TextField(
key="MY_SETTING", # Config key
label="Setting Name", # Display label
description="Help text", # Optional description below field
default="", # Default value
placeholder="Enter value", # Placeholder text
max_length=100, # Optional max characters
required=False, # Is this field required?
requires_restart=False, # Does changing this need a restart?
show_when=None, # Conditional visibility (see below)
disabled_when=None, # Conditional disable (see below)
)
```
### PasswordField
Masked input for sensitive values (API keys, passwords). Values are never echoed back to the frontend.
```python
PasswordField(
key="API_KEY",
label="API Key",
description="Your secret API key",
placeholder="sk-...",
required=True,
)
```
### NumberField
Numeric input with optional min/max constraints.
```python
NumberField(
key="TIMEOUT",
label="Timeout (seconds)",
description="Connection timeout in seconds",
default=30,
min_value=5,
max_value=300,
step=1, # Increment step
required=False,
)
```
### CheckboxField
Toggle switch for boolean values.
```python
CheckboxField(
key="ENABLE_FEATURE",
label="Enable Feature",
description="Turn this feature on or off",
default=False,
)
```
### SelectField
Dropdown for single-choice selection.
```python
SelectField(
key="LOG_LEVEL",
label="Log Level",
description="Logging verbosity",
default="info",
options=[
{"value": "debug", "label": "Debug"},
{"value": "info", "label": "Info"},
{"value": "warning", "label": "Warning"},
{"value": "error", "label": "Error"},
],
)
```
### MultiSelectField
Multi-choice selection from a list of options.
```python
MultiSelectField(
key="SUPPORTED_FORMATS",
label="Supported Formats",
description="Select which formats to support",
default=["epub", "mobi"],
options=[
{"value": "epub", "label": "EPUB"},
{"value": "mobi", "label": "MOBI"},
{"value": "pdf", "label": "PDF"},
{"value": "azw3", "label": "AZW3"},
],
)
```
### ActionButton
Button that executes a callback function. Does not store a value.
```python
ActionButton(
key="test_connection", # Unique key for the action
label="Test Connection", # Button text
description="Test the API connection",
style="primary", # "default", "primary", or "danger"
callback=my_callback_fn, # Function to execute
)
def my_callback_fn():
"""Callback must return dict with 'success' and 'message' keys."""
try:
# Perform action
return {"success": True, "message": "Connection successful!"}
except Exception as e:
return {"success": False, "message": f"Failed: {str(e)}"}
```
### HeadingField
Display-only section heading with optional link. Does not store a value.
```python
HeadingField(
key="section_heading", # Unique key
title="Configuration", # Heading text
description="Configure the plugin settings below",
link_url="https://example.com/docs", # Optional link
link_text="View Documentation", # Link text
)
```
### CustomComponentField
Render a frontend-registered custom settings component while still using the
decorator-based schema.
```python
from shelfmark.core.settings_registry import CustomComponentField
CustomComponentField(
key="request_policy_editor",
component="request_policy_grid", # frontend registry key
label="Request Policy Rules",
description="Custom editor for policy defaults and matrix rules.",
value_fields=[
SelectField(key="REQUEST_POLICY_DEFAULT_EBOOK", label="Default Ebook Mode", default="download"),
SelectField(key="REQUEST_POLICY_DEFAULT_AUDIOBOOK", label="Default Audiobook Mode", default="download"),
TableField(key="REQUEST_POLICY_RULES", label="Rules", columns=_rule_columns, default=[]),
],
wrap_in_field_wrapper=True, # use standard FieldWrapper label/description layout
)
```
When `value_fields` is provided, those backing fields are included in
serialization/save/validation automatically and are hidden from the default renderer.
## Common Field Properties
All field types support these common properties:
| Property | Type | Default | Description |
|----------|------|---------|-------------|
| `key` | `str` | Required | Unique identifier for this setting |
| `label` | `str` | Required | Display label in the UI |
| `description` | `str` | `""` | Help text shown below the field |
| `default` | `Any` | `None` | Default value if not set |
| `required` | `bool` | `False` | Whether the field must have a value |
| `disabled` | `bool` | `False` | Disable the field (greyed out) |
| `disabled_reason` | `str` | `""` | Explanation shown when disabled |
| `requires_restart` | `bool` | `False` | Whether changes require container restart |
| `show_when` | `dict` | `None` | Conditional visibility (see below) |
| `disabled_when` | `dict` | `None` | Conditional disable (see below) |
| `hidden_in_ui` | `bool` | `False` | Hide from default renderer but keep in schema/save path |
## Conditional Visibility
Fields can be shown/hidden based on other field values using `show_when`:
```python
# Only show DNS servers field when custom DNS is selected
TextField(
key="CUSTOM_DNS_SERVERS",
label="DNS Servers",
description="Comma-separated DNS server IPs",
show_when={"field": "DNS_PROVIDER", "value": "manual"},
)
```
The field will only be visible when the referenced field has the specified value.
## Conditional Disable
Fields can be enabled/disabled based on other field values using `disabled_when`:
```python
# Disable timeout field when feature is disabled
NumberField(
key="FEATURE_TIMEOUT",
label="Timeout (seconds)",
description="Request timeout",
default=30,
disabled_when={
"field": "FEATURE_ENABLED",
"value": False,
"reason": "Enable the feature first"
},
)
```
The field will be greyed out with the specified reason when the condition is met.
## Settings Groups
Register a group to organize related settings tabs in the sidebar:
```python
from shelfmark.core.settings_registry import register_group
# Register a group (do this once, usually in a central config file)
register_group(
name="my_group",
display_name="My Group",
icon="folder",
order=50,
)
# Then register settings to the group
@register_settings(
name="plugin_a",
display_name="Plugin A",
icon="puzzle",
order=51,
group="my_group", # Assigns to the group
)
def plugin_a_settings():
return [...]
```
**Existing groups:**
- `direct_download` (order=20): For download-related settings
- `metadata_providers` (order=50): For metadata provider plugins
## Value Resolution Priority
Settings values are resolved in this order (highest priority first):
1. **Config File** - Stored in `CONFIG_DIR/plugins/<tab_name>.json`
2. **Field Default** - Value specified in the field definition
The `general` tab uses `CONFIG_DIR/settings.json` instead of the plugins subdirectory.
## Reading Setting Values
Use the `config` singleton to read setting values in your plugin code:
```python
from shelfmark.core.config import config
# Get a setting value with default fallback
api_key = config.get("MY_PLUGIN_API_KEY", "")
timeout = config.get("MY_PLUGIN_TIMEOUT", 30)
# Or access as attributes (raises AttributeError if not found)
api_key = config.MY_PLUGIN_API_KEY
# Check all cached settings
all_settings = config.get_all()
```
The config singleton:
- Automatically resolves values from config files with field defaults as fallback
- Caches values for performance
- Refreshes automatically when settings are updated via the UI
## Complete Example: Metadata Provider
Here's a complete example for a metadata provider plugin:
```python
# shelfmark/metadata_providers/my_provider.py
from shelfmark.metadata_providers.base import (
MetadataProvider,
register_provider,
)
from shelfmark.core.settings_registry import (
register_settings,
HeadingField,
TextField,
PasswordField,
CheckboxField,
ActionButton,
)
from shelfmark.core.config import config
def _test_connection():
"""Test API connection callback."""
api_key = config.get("MY_PROVIDER_API_KEY", "")
if not api_key:
return {"success": False, "message": "API key not configured"}
try:
# Perform actual connection test
# response = requests.get(...)
return {"success": True, "message": "Connected to My Provider API"}
except Exception as e:
return {"success": False, "message": f"Connection failed: {str(e)}"}
@register_settings(
name="my_provider",
display_name="My Provider",
icon="book",
order=53,
group="metadata_providers",
)
def my_provider_settings():
"""Define settings for this metadata provider."""
return [
HeadingField(
key="my_provider_heading",
title="My Provider",
description="A metadata provider for book information",
link_url="https://myprovider.com",
link_text="Visit My Provider",
),
PasswordField(
key="MY_PROVIDER_API_KEY",
label="API Key",
description="Your My Provider API key",
placeholder="Enter your API key",
required=True,
),
CheckboxField(
key="MY_PROVIDER_INCLUDE_COVERS",
label="Include Cover Images",
description="Fetch cover images when searching",
default=True,
),
TextField(
key="MY_PROVIDER_BASE_URL",
label="API Base URL",
description="Override the default API endpoint",
default="https://api.myprovider.com/v1",
required=False,
),
ActionButton(
key="test_connection",
label="Test Connection",
description="Verify your API key works",
style="primary",
callback=_test_connection,
),
]
@register_provider("my_provider")
class MyProvider(MetadataProvider):
"""My Provider metadata implementation."""
name = "my_provider"
display_name = "My Provider"
requires_auth = True
def __init__(self, api_key: str = None):
self.api_key = api_key or config.get("MY_PROVIDER_API_KEY", "")
self.base_url = config.get(
"MY_PROVIDER_BASE_URL",
"https://api.myprovider.com/v1"
)
def is_available(self) -> bool:
return bool(self.api_key)
def search(self, query: str):
# Implementation...
pass
def get_book(self, book_id: str):
# Implementation...
pass
```
## Complete Example: Release Source
Here's a complete example for a release source plugin:
```python
# shelfmark/release_sources/my_source.py
from shelfmark.release_sources.base import (
ReleaseSource,
DownloadHandler,
register_source,
register_handler,
)
from shelfmark.core.settings_registry import (
register_settings,
HeadingField,
TextField,
NumberField,
CheckboxField,
SelectField,
ActionButton,
)
from shelfmark.core.config import config
def _test_source():
"""Test source availability callback."""
base_url = config.get("MY_SOURCE_URL", "https://mysource.com")
try:
# Test connectivity
return {"success": True, "message": f"Source available at {base_url}"}
except Exception as e:
return {"success": False, "message": f"Source unavailable: {str(e)}"}
@register_settings(
name="my_source",
display_name="My Source",
icon="download",
order=25,
group="direct_download",
)
def my_source_settings():
"""Define settings for this release source."""
return [
HeadingField(
key="my_source_heading",
title="My Source Configuration",
description="Configure the My Source download provider",
),
CheckboxField(
key="MY_SOURCE_ENABLED",
label="Enable My Source",
description="Include My Source in download fallback chain",
default=True,
),
TextField(
key="MY_SOURCE_URL",
label="Source URL",
description="Base URL for the source",
default="https://mysource.com",
show_when={"field": "MY_SOURCE_ENABLED", "value": True},
),
NumberField(
key="MY_SOURCE_TIMEOUT",
label="Timeout (seconds)",
description="Request timeout",
default=30,
min_value=10,
max_value=120,
show_when={"field": "MY_SOURCE_ENABLED", "value": True},
),
SelectField(
key="MY_SOURCE_PRIORITY",
label="Priority",
description="Where in the fallback chain to try this source",
default="normal",
options=[
{"value": "high", "label": "High (try first)"},
{"value": "normal", "label": "Normal"},
{"value": "low", "label": "Low (try last)"},
],
show_when={"field": "MY_SOURCE_ENABLED", "value": True},
),
ActionButton(
key="test_source",
label="Test Source",
description="Check if the source is accessible",
style="primary",
callback=_test_source,
),
]
@register_source("my_source")
class MySource(ReleaseSource):
"""My Source release source implementation."""
name = "my_source"
display_name = "My Source"
def __init__(self):
self.enabled = config.get("MY_SOURCE_ENABLED", True)
self.base_url = config.get("MY_SOURCE_URL", "https://mysource.com")
self.timeout = config.get("MY_SOURCE_TIMEOUT", 30)
def is_available(self) -> bool:
return self.enabled
def search(self, book):
# Implementation...
pass
@register_handler("my_source")
class MySourceHandler(DownloadHandler):
"""Handler for downloading from My Source."""
name = "my_source"
def download(self, release, output_path):
# Implementation...
pass
```
## Best Practices
1. **Use descriptive keys**: Keys should be uppercase and prefixed with your plugin name (e.g., `MY_PLUGIN_API_KEY`)
2. **Provide helpful descriptions**: Include enough detail in descriptions to help users understand what each setting does
3. **Set sensible defaults**: Users should be able to get started without configuring everything
4. **Use conditional visibility**: Hide advanced options behind enabling checkboxes to reduce UI clutter
5. **Include a test button**: ActionButtons that test connections help users verify their configuration
6. **Mark restart-required settings**: Use `requires_restart=True` for settings that can't be applied live
7. **Group related settings**: Use HeadingField to visually separate sections, and put plugins in appropriate groups
8. **Handle missing values gracefully**: Always provide fallbacks when reading settings in your code
## API Reference
### Backend Routes
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/settings` | Get all settings tabs, groups, and values |
| GET | `/api/settings/<tab_name>` | Get a specific settings tab |
| PUT | `/api/settings/<tab_name>` | Update settings for a tab |
| POST | `/api/settings/<tab_name>/action/<action_key>` | Execute an action button callback |
### Response Format
**GET /api/settings**
```json
{
"groups": [
{"name": "direct_download", "displayName": "Direct Download", "icon": "download", "order": 20}
],
"tabs": [
{
"name": "my_plugin",
"displayName": "My Plugin",
"icon": "book",
"order": 53,
"group": "metadata_providers",
"fields": [
{
"type": "password",
"key": "MY_PLUGIN_API_KEY",
"label": "API Key",
"description": "Your API key",
"hasValue": true,
"value": "",
"required": true,
"disabled": false,
"requiresRestart": false
}
]
}
]
}
```
**PUT /api/settings/<tab_name>**
```json
// Request
{"MY_PLUGIN_API_KEY": "new-value", "MY_PLUGIN_TIMEOUT": 60}
// Response
{
"success": true,
"message": "Settings updated",
"updated": ["MY_PLUGIN_API_KEY", "MY_PLUGIN_TIMEOUT"],
"requiresRestart": false
}
```
**POST /api/settings/<tab_name>/action/<action_key>**
```json
// Response
{
"success": true,
"message": "Connection successful!"
}
```
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+23
View File
@@ -0,0 +1,23 @@
# Shelfmark Documentation
Shelfmark is a self-hosted interface for searching, requesting, and delivering books and audiobooks through the sources and services you choose to configure.
Use the guides below to set up the app, connect your library tools, and understand the main configuration areas.
## Getting Started
- [Installation](installation.md)
- [Directory and Volume Setup](configuration.md)
- [Environment Variables](environment-variables.md)
## Core Guides
- [Users & Requests](users-and-requests.md)
- [Reverse Proxy](reverse-proxy.md)
- [OIDC](oidc.md)
- [URL Search Parameters](url-search-parameters.md)
- [Custom Scripts](custom-scripts.md)
## Help
- [Troubleshooting](troubleshooting.md)
+33
View File
@@ -0,0 +1,33 @@
# Installation
Shelfmark is typically deployed with Docker Compose.
## Quick Start
1. Download the compose file from the repository:
```bash
curl -O https://raw.githubusercontent.com/calibrain/shelfmark/main/compose/docker-compose.yml
```
2. Start the service:
```bash
docker compose up -d
```
3. Open `http://localhost:8084`
4. Configure the sources, metadata providers, and delivery settings you want to use
## Next Steps
- For volume and path setup, see [Directory and Volume Setup](configuration.md)
- For environment-based setup, see [Environment Variables](environment-variables.md)
- For authentication and user management, see [Users & Requests](users-and-requests.md) and [OIDC](oidc.md)
## Notes
- Universal search is the default mode for new installs
- Direct Download is optional and must be enabled and configured before it can be used
- Torrent and usenet setups require matching download paths between Shelfmark and your download client
+50
View File
@@ -0,0 +1,50 @@
# OpenID Connect (OIDC) Authentication
## Callback URL
```
https://<your-shelfmark-domain>/api/auth/oidc/callback
```
With a subpath (`URL_BASE=/shelfmark/`):
```
https://<your-shelfmark-domain>/shelfmark/api/auth/oidc/callback
```
The callback URL is constructed from the incoming request, so your reverse proxy must forward `X-Forwarded-Proto` and `X-Forwarded-Host` correctly. PKCE (S256) is used automatically.
## Settings
Configure in **Settings → Security → Authentication Method → OIDC**.
| Setting | Description | Default |
|---------|-------------|---------|
| Discovery URL | `/.well-known/openid-configuration` endpoint | — |
| Client ID | OAuth2 client ID | — |
| Client Secret | OAuth2 client secret | — |
| Scopes | Scopes to request. The group claim is added automatically when admin group authorization is enabled | `openid email profile` |
| Group Claim Name | Claim containing user groups | `groups` |
| Admin Group Name | Group granted admin access. Leave empty for database-only roles | — |
| Use Admin Group for Authorization | Toggle group-based admin detection | `true` |
| 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.
## Environment Variables
These optional environment variables control login page behavior when OIDC is enabled.
| Variable | Description | Default |
|----------|-------------|---------|
| `HIDE_LOCAL_AUTH` | Hide the username/password login option, so only the OIDC button is shown | `false` |
| `OIDC_AUTO_REDIRECT` | Automatically redirect to the OIDC provider instead of showing the login page | `false` |
If both are enabled, users are redirected straight to the OIDC provider. On failure they return to the login page with an error message but no password fallback.
## Troubleshooting
- **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.
+168
View File
@@ -0,0 +1,168 @@
# Reverse Proxy & Subpath Hosting
Shelfmark can run behind a reverse proxy at the root path (recommended) or under a subpath like `/shelfmark`.
## Root path setup (Recommended)
If you can serve Shelfmark at the root path (`https://shelfmark.example.com/`), leave `URL_BASE` empty. This is the simplest option and avoids extra subpath configuration.
Define this once in your Nginx `http` block so websocket upgrades are only sent when the client actually requests them:
```nginx
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
```
```nginx
server {
listen 443 ssl;
server_name shelfmark.example.com;
location / {
proxy_pass http://shelfmark:8084;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
}
}
```
## Subpath setup
Running Shelfmark under a subpath like `/shelfmark` is supported without extra rewrite rules.
### 1. Set the base path in Shelfmark
- **UI**: Settings → Advanced → Base Path → `/shelfmark/`
- **Environment variable**: `URL_BASE=/shelfmark/`
### 2. Configure your reverse proxy
All Shelfmark paths (UI, API, assets, Socket.IO) are served under the base path. A single location block is enough.
---
### Without Authentication Proxy
**Complete Nginx configuration for subpath deployment:**
```nginx
location /shelfmark/ {
proxy_pass http://shelfmark:8084/shelfmark/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_read_timeout 86400;
proxy_send_timeout 86400;
proxy_buffering off;
}
```
---
### With Authentication Proxy (Authelia, Authentik, etc.)
Shelfmark supports Proxy Authentication. When enabled, Shelfmark trusts the authenticated user from headers set by your auth proxy.
#### Shelfmark Settings
Configure in Settings → Security:
| Setting | Value |
|---------|-------|
| Authentication Method | Proxy Authentication |
| Proxy Auth User Header | `Remote-User` |
| Proxy Auth Logout URL | `https://auth.example.com/logout` |
| Proxy Auth Admin Group Header | `Remote-Groups` |
| Proxy Auth Admin Group Name | `admins` (or your admin group) |
#### Nginx Configuration with Authelia
This example uses Authelia snippets. Adapt for your auth proxy.
**Authelia auth request snippet** (`/etc/nginx/snippets/authelia-authrequest.conf`):
```nginx
location /authelia {
internal;
proxy_pass http://authelia:9091/api/authz/auth-request;
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_set_header Host $host;
proxy_set_header X-Original-URL $scheme://$http_host$request_uri;
proxy_set_header X-Original-Method $request_method;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
```
**Authelia location snippet** (`/etc/nginx/snippets/authelia-location.conf`):
```nginx
auth_request /authelia;
auth_request_set $target_url $scheme://$http_host$request_uri;
auth_request_set $user $upstream_http_remote_user;
auth_request_set $groups $upstream_http_remote_groups;
auth_request_set $name $upstream_http_remote_name;
auth_request_set $email $upstream_http_remote_email;
proxy_set_header Remote-User $user;
proxy_set_header Remote-Groups $groups;
proxy_set_header Remote-Name $name;
proxy_set_header Remote-Email $email;
error_page 401 =302 https://auth.example.com/?rd=$target_url;
```
**Complete Nginx configuration with Authelia:**
```nginx
# Include Authelia auth endpoint in your server block
include /etc/nginx/snippets/authelia-authrequest.conf;
# Main shelfmark location
location /shelfmark/ {
include /etc/nginx/snippets/authelia-location.conf;
proxy_pass http://shelfmark:8084/shelfmark/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_read_timeout 86400;
proxy_send_timeout 86400;
proxy_buffering off;
}
```
---
## Troubleshooting false network errors
If login, settings saves, or downloads appear to fail in the browser but the action still completes on the server, check your proxy headers first.
- Do not force `Connection: upgrade` on every request. That can break normal `POST` and `PUT` responses while the backend still processes them.
- If your proxy UI does not support conditional websocket headers, remove the forced websocket headers entirely and let Shelfmark fall back to polling.
- Keep the standard forwarded headers: `Host`, `X-Forwarded-For`, `X-Forwarded-Proto`, and `X-Forwarded-Host` when using a subpath or OIDC.
This is especially relevant for Nginx Proxy Manager or custom advanced config snippets that add websocket headers globally.
---
## Health checks
Health checks work at `/shelfmark/api/health` when using a subpath configuration.
+3
View File
@@ -0,0 +1,3 @@
# Troubleshooting
TODO
+82
View File
@@ -0,0 +1,82 @@
# URL Search Parameters
You can trigger searches directly via URL by adding query parameters. This enables bookmarking searches and sharing links.
## Basic Usage
```
http://your-server:8084/?q=harry+potter
```
## Supported Parameters
| Parameter | Description | Example |
|-----------|-------------|---------|
| `q` or `query` | Main search query | `/?q=dune` |
| `author` | Filter by author name | `/?author=frank+herbert` |
| `title` | Filter by book title | `/?title=foundation` |
| `isbn` | Filter by ISBN | `/?isbn=978-0747532699` |
| `lang` | Filter by language (ISO 639-1 code) | `/?lang=en` |
| `format` | Filter by file format | `/?format=epub` |
| `content` | Filter by content type | `/?content=fiction` |
| `content_type` | Select media type (`ebook` or `audiobook`) in Universal mode only | `/?q=dune&content_type=audiobook` |
| `sort` | Sort order for results | `/?sort=newest` |
## Multiple Values
Some parameters support multiple values by repeating the parameter:
```
/?lang=en&lang=de&lang=fr
/?format=epub&format=mobi&format=azw3
```
## Examples
**Simple search:**
```
/?q=lord+of+the+rings
```
**Search with author filter:**
```
/?q=dune&author=frank+herbert
```
**Search with format and language:**
```
/?q=harry+potter&format=epub&lang=en
```
**Author search with multiple formats:**
```
/?author=stephen+king&format=epub&format=mobi
```
**Search with sort order:**
```
/?q=science+fiction&sort=newest
```
**Universal search as audiobook:**
```
/?q=dune&content_type=audiobook
```
## Search Mode Behavior
### Direct Mode
When Search Mode is set to Direct, all parameters are used to filter results from the configured direct source.
`content_type` is ignored in Direct mode.
### Universal Mode
`q`, `sort`, and `content_type` are used. Other parameters (author, title, format, etc.) are silently ignored since metadata providers have their own search capabilities.
## Notes
- URL parameters are read once on page load
- The URL is not updated when you perform searches manually
- Spaces should be encoded as `+` or `%20`
- Invalid or unknown parameters are silently ignored
+98
View File
@@ -0,0 +1,98 @@
# Users & Requests
Configure in **Settings → Users & Requests**.
## Authentication Methods
Shelfmark supports four authentication methods, configured in **Settings → Security**.
### Local
You create user accounts directly in Shelfmark with a username and password. At least one local admin account must exist before this mode can be enabled.
### Proxy Authentication
Your reverse proxy handles authentication and passes the username to Shelfmark via a header (e.g. `Remote-User`). Accounts are created automatically on first sign-in. If a local user with the same username already exists, the proxy identity will be linked to that account rather than creating a duplicate. Admin status can optionally be derived from a groups header.
### OIDC (OpenID Connect)
Users sign in through your identity provider. Accounts are created automatically on first login (unless auto-provisioning is disabled, in which case you need to pre-create them). If a local user with a matching verified email already exists, the OIDC identity will be linked to that account on first sign-in. Admin status can optionally be derived from a group claim.
A local admin account is required as a fallback. See [OIDC](oidc.md) for provider setup.
### Calibre-Web Database
User accounts are synced from your Calibre-Web `app.db`. If a local user with a matching email already exists, the CWA identity will be linked to that account. Roles are kept in sync with CWA. Users removed from CWA are cleaned up on the next sync.
Requires mounting your Calibre-Web `app.db` to `/auth/app.db`.
## Per-User Settings
Admins can configure per-user settings by editing a user in the user management panel. Non-admin users can also edit their own settings through **My Account** (accessible from the user menu). Admins control which sections are visible in My Account via the **Visible Self-Settings Sections** option.
There are three categories of per-user settings:
### Delivery Preferences
Override where a user's downloads are sent. Options depend on the global output mode configuration:
- **Output mode** — Folder, Email (SMTP), or BookLore (API)
- **Destination** — A custom folder path for this user's ebook downloads
- **Audiobook destination** — A custom folder path for audiobook downloads
- **BookLore library/path** — Per-user BookLore target (when using BookLore output mode)
- **Email recipient** — Per-user email address (when using Email output mode)
### Notifications
Users can configure personal notification routes, separate from the global notification settings. Each route targets a URL (e.g. an Apprise-compatible endpoint) and can be scoped to specific event types or all events.
### Request Policy (admin-only)
Admins can override the default ebook/audiobook modes and request rules for individual users. See [Per-User Overrides](#per-user-overrides) below.
---
## Requests
The request system controls whether users can download directly or need admin approval first.
### Policy Modes
Each content type (ebook, audiobook) has a default mode that sets the baseline:
| Mode | Behaviour |
|------|-----------|
| **Download** | Users download directly, no approval needed |
| **Request Release** | Users pick a specific release, then submit it for admin approval |
| **Request Book** | Users request the book itself — an admin picks the release and fulfils it |
| **Blocked** | No downloads or requests allowed |
### Settings
| Setting | Description | Default |
|---------|-------------|---------|
| Enable Requests | Master toggle. When off, everyone downloads directly | Off |
| Default Ebook Mode | Baseline mode for all ebook sources | Download |
| Default Audiobook Mode | Baseline mode for all audiobook sources | Download |
| Request Rules | Per-source overrides (see below) | None |
| Max Pending Requests Per User | Open request limit per user | 20 |
| Allow Notes on Requests | Let users attach a note when submitting | On |
### Request Rules
The rules matrix lets you override the mode for specific source + content type combinations. Rules can only be **equal to or more restrictive** than the content-type default — they cannot grant more access than the baseline.
For example, if the default ebook mode is "Download", a rule can restrict a specific source to "Request Release" or "Blocked", but not the other way around. If no rule matches, the content-type default applies.
### Per-User Overrides
Admins can override the default ebook/audiobook modes and request rules for individual users. Per-user rules are overlaid on the global rules, not replacing them.
### Request Lifecycle
1. User submits a request (book or release level, depending on the resolved policy mode)
2. Request appears in the admin request queue as **pending**
3. Admin either **fulfils** (queues a download) or **rejects** the request
4. For fulfilled requests, delivery state is tracked through the download pipeline
5. If delivery fails, an admin can reopen the request to try a different release
6. Users can cancel their own pending requests
-138
View File
@@ -1,138 +0,0 @@
"""Network operations manager for the book downloader application."""
import network
network.init()
import requests
import time
from io import BytesIO
from typing import Optional
from urllib.parse import urlparse
from tqdm import tqdm
from typing import Callable
from threading import Event
from logger import setup_logger
from config import PROXIES
from env import MAX_RETRY, DEFAULT_SLEEP, USE_CF_BYPASS, USING_EXTERNAL_BYPASSER
if USE_CF_BYPASS:
if USING_EXTERNAL_BYPASSER:
from cloudflare_bypasser_external import get_bypassed_page
else:
from cloudflare_bypasser import get_bypassed_page
logger = setup_logger(__name__)
def html_get_page(url: str, retry: int = MAX_RETRY, use_bypasser: bool = False, status_callback: Optional[Callable[[str], None]] = None) -> str:
"""Fetch HTML content from a URL with retry mechanism.
Args:
url: Target URL
retry: Number of retry attempts
use_bypasser: Whether to use Cloudflare bypasser
status_callback: Optional callback for status updates
Returns:
str: HTML content if successful, None otherwise
"""
response = None
try:
logger.debug(f"html_get_page: {url}, retry: {retry}, use_bypasser: {use_bypasser}")
if use_bypasser and USE_CF_BYPASS:
if status_callback:
status_callback("bypassing")
logger.info(f"GET Using Cloudflare Bypasser for: {url}")
return get_bypassed_page(url)
else:
logger.info(f"GET: {url}")
response = requests.get(url, proxies=PROXIES)
response.raise_for_status()
logger.debug(f"Success getting: {url}")
time.sleep(1)
return str(response.text)
except Exception as e:
if retry == 0:
logger.error_trace(f"Failed to fetch page: {url}, error: {e}")
return ""
if use_bypasser and USE_CF_BYPASS:
logger.warning(f"Exception while using cloudflare bypass for URL: {url}")
logger.warning(f"Exception: {e}")
logger.warning(f"Response: {response}")
elif response is not None and response.status_code == 404:
logger.warning(f"404 error for URL: {url}")
return ""
elif response is not None and response.status_code == 403:
logger.warning(f"403 detected for URL: {url}. Should retry using cloudflare bypass.")
return html_get_page(url, retry - 1, True, status_callback)
sleep_time = DEFAULT_SLEEP * (MAX_RETRY - retry + 1)
logger.warning(
f"Retrying GET {url} in {sleep_time} seconds due to error: {e}"
)
time.sleep(sleep_time)
return html_get_page(url, retry - 1, use_bypasser, status_callback)
def download_url(link: str, size: str = "", progress_callback: Optional[Callable[[float], None]] = None, cancel_flag: Optional[Event] = None) -> Optional[BytesIO]:
"""Download content from URL into a BytesIO buffer.
Args:
link: URL to download from
Returns:
BytesIO: Buffer containing downloaded content if successful
"""
try:
logger.info(f"Downloading from: {link}")
response = requests.get(link, stream=True, proxies=PROXIES)
response.raise_for_status()
total_size : float = 0.0
try:
# we assume size is in MB
total_size = float(size.strip().replace(" ", "").replace(",", ".").upper()[:-2].strip()) * 1024 * 1024
except:
total_size = float(response.headers.get('content-length', 0))
buffer = BytesIO()
# Initialize the progress bar with your guess
pbar = tqdm(total=total_size, unit='B', unit_scale=True, desc='Downloading')
for chunk in response.iter_content(chunk_size=1000):
buffer.write(chunk)
pbar.update(len(chunk))
if progress_callback is not None:
progress_callback(pbar.n * 100.0 / total_size)
if cancel_flag is not None and cancel_flag.is_set():
logger.info(f"Download cancelled: {link}")
return None
pbar.close()
if buffer.tell() * 0.1 < total_size * 0.9:
# Check the content of the buffer if its HTML or binary
if response.headers.get('content-type', '').startswith('text/html'):
logger.warn(f"Failed to download content for {link}. Found HTML content instead.")
return None
return buffer
except requests.exceptions.RequestException as e:
logger.error_trace(f"Failed to download from {link}: {e}")
return None
def get_absolute_url(base_url: str, url: str) -> str:
"""Get absolute URL from relative URL and base URL.
Args:
base_url: Base URL
url: Relative URL
"""
if url.strip() == "":
return ""
if url.strip("#") == "":
return ""
if url.startswith("http"):
return url
parsed_url = urlparse(url)
parsed_base = urlparse(base_url)
if parsed_url.netloc == "" or parsed_url.scheme == "":
parsed_url = parsed_url._replace(netloc=parsed_base.netloc, scheme=parsed_base.scheme)
return parsed_url.geturl()
+444 -75
View File
@@ -1,67 +1,247 @@
#!/bin/bash
LOG_DIR=${LOG_ROOT:-/var/log/}/cwa-book-downloader
mkdir -p $LOG_DIR
LOG_FILE=${LOG_DIR}/cwa-bd_entrypoint.log
# Cleanup any existing files or folders in the log directory
rm -rf $LOG_DIR/*
(
if [ "$USING_TOR" = "true" ]; then
./tor.sh
fi
)
exec 3>&1 4>&2
exec > >(tee -a $LOG_FILE) 2>&1
echo "Starting entrypoint script"
echo "Log file: $LOG_FILE"
set -e
is_truthy() {
case "${1,,}" in
true|yes|1|y) return 0 ;;
*) return 1 ;;
esac
}
ENABLE_LOGGING_VALUE="${ENABLE_LOGGING:-true}"
LOG_PIPE_DIR=""
LOG_PIPE=""
TEE_PID=""
FILE_LOGGING_ENABLED="false"
CURRENT_UID=$(id -u)
CURRENT_GID=$(id -g)
RUN_AS_NON_ROOT="false"
RUNTIME_TMP_DIR="${TMP_DIR:-/tmp/shelfmark}"
DEFAULT_RUNTIME_HOME="${RUNTIME_TMP_DIR}/home"
if [ "$CURRENT_UID" != "0" ]; then
RUN_AS_NON_ROOT="true"
fi
start_file_logging() {
local logfile="$1"
LOG_PIPE_DIR="$(mktemp -d)"
LOG_PIPE="${LOG_PIPE_DIR}/shelfmark-log.pipe"
mkfifo "$LOG_PIPE"
tee -a "$logfile" < "$LOG_PIPE" &
TEE_PID=$!
exec 3>&1 4>&2
exec > "$LOG_PIPE" 2>&1
}
stop_file_logging() {
if [ -z "${TEE_PID:-}" ]; then
return 0
fi
exec 1>&3 2>&4
exec 3>&- 4>&-
rm -f "$LOG_PIPE"
rmdir "$LOG_PIPE_DIR" 2>/dev/null || true
wait "$TEE_PID" 2>/dev/null || true
TEE_PID=""
}
if is_truthy "$ENABLE_LOGGING_VALUE"; then
LOG_DIR=${LOG_ROOT:-/var/log/}/shelfmark
if mkdir -p "$LOG_DIR" 2>/dev/null; then
LOG_FILE="${LOG_DIR}/shelfmark_entrypoint.log"
# Keep the previous entrypoint log instead of deleting all history on boot.
rotation_ok="true"
if [ -f "${LOG_FILE}.prev" ] && ! rm -f "${LOG_FILE}.prev"; then
echo "Warning: could not remove previous entrypoint log ${LOG_FILE}.prev, continuing without file logging" >&2
rotation_ok="false"
fi
if [ "$rotation_ok" = "true" ] && [ -f "$LOG_FILE" ] && ! mv "$LOG_FILE" "${LOG_FILE}.prev"; then
echo "Warning: could not rotate entrypoint log $LOG_FILE, continuing without file logging" >&2
rotation_ok="false"
fi
if [ "$rotation_ok" = "true" ]; then
FILE_LOGGING_ENABLED="true"
else
ENABLE_LOGGING_VALUE="false"
export ENABLE_LOGGING="false"
fi
else
echo "Warning: could not create log directory $LOG_DIR, continuing without file logging" >&2
ENABLE_LOGGING_VALUE="false"
export ENABLE_LOGGING="false"
fi
fi
if [ "$USING_TOR" = "true" ]; then
if [ "$RUN_AS_NON_ROOT" = "true" ]; then
echo "USING_TOR=true requires the container to start as root." >&2
echo "Non-root mode skips the privileged filesystem and network setup Tor depends on." >&2
exit 1
fi
./tor.sh
fi
if [ "$FILE_LOGGING_ENABLED" = "true" ]; then
start_file_logging "$LOG_FILE"
fi
echo "Starting entrypoint script"
if [ "$FILE_LOGGING_ENABLED" = "true" ]; then
echo "Log file: $LOG_FILE"
else
echo "File logging disabled (ENABLE_LOGGING=$ENABLE_LOGGING_VALUE)"
fi
PYTHON_BIN="/app/.venv/bin/python"
if [ ! -x "$PYTHON_BIN" ]; then
PYTHON_BIN="python3"
fi
# Defensive: some orchestrators (e.g. Unraid Dockhand templates) inject a default
# PATH that drops the venv bin directory baked in by the Dockerfile. Prepend it
# so subprocesses launched without an absolute path still resolve correctly.
case ":${PATH}:" in
*":/app/.venv/bin:"*) ;;
*) export PATH="/app/.venv/bin:${PATH}" ;;
esac
GUNICORN_BIN="/app/.venv/bin/gunicorn"
if [ ! -x "$GUNICORN_BIN" ]; then
GUNICORN_BIN="gunicorn"
fi
# Print build version
echo "Build version: $BUILD_VERSION"
echo "Release version: $RELEASE_VERSION"
# Configure timezone
if [ "$TZ" ]; then
echo "Setting timezone to $TZ"
ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
if [ "$RUN_AS_NON_ROOT" = "true" ]; then
echo "TZ is set to $TZ (non-root mode leaves /etc/localtime unchanged)"
else
echo "Setting timezone to $TZ"
ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
fi
fi
# Set UID if not set
if [ -z "$UID" ]; then
UID=1000
if [ "$RUN_AS_NON_ROOT" = "true" ]; then
RUN_UID="$CURRENT_UID"
RUN_GID="$CURRENT_GID"
USERNAME=$(getent passwd "$RUN_UID" 2>/dev/null | cut -d: -f1 || true)
if [ -z "$USERNAME" ]; then
USERNAME="$RUN_UID"
echo "No passwd entry found for UID $RUN_UID; using numeric identity"
fi
TARGET_USER_SPEC="${RUN_UID}:${RUN_GID}"
else
# Determine user ID with proper precedence:
# 1. PUID (LinuxServer.io standard - recommended)
# 2. UID (legacy, for backward compatibility with existing installs)
# 3. Default to 1000
#
# Note: $UID is a bash builtin that's always set. We use `printenv` to detect
# if UID was explicitly set as an environment variable (e.g., via docker-compose).
if [ -n "$PUID" ]; then
RUN_UID="$PUID"
echo "Using PUID=$RUN_UID"
elif printenv UID >/dev/null 2>&1; then
RUN_UID="$(printenv UID)"
echo "Using UID=$RUN_UID (legacy - consider migrating to PUID)"
else
RUN_UID=1000
echo "Using default UID=$RUN_UID"
fi
# Determine group ID with proper precedence:
# 1. PGID (LinuxServer.io standard - recommended)
# 2. GID (legacy, for backward compatibility with existing installs)
# 3. Default to 1000
if [ -n "$PGID" ]; then
RUN_GID="$PGID"
echo "Using PGID=$RUN_GID"
elif [ -n "$GID" ]; then
RUN_GID="$GID"
echo "Using GID=$RUN_GID (legacy - consider migrating to PGID)"
else
RUN_GID=1000
echo "Using default GID=$RUN_GID"
fi
if ! getent group "$RUN_GID" >/dev/null; then
echo "Adding group $RUN_GID with name appuser"
groupadd -g "$RUN_GID" appuser
fi
# Create user if it doesn't exist for this UID yet.
if ! getent passwd "$RUN_UID" >/dev/null; then
echo "Adding user $RUN_UID with name appuser"
useradd -u "$RUN_UID" -g "$RUN_GID" -d "$DEFAULT_RUNTIME_HOME" -s /sbin/nologin appuser
fi
# Get username for the UID (whether we just created it or it existed)
USERNAME=$(getent passwd "$RUN_UID" | cut -d: -f1)
if [ -z "$USERNAME" ]; then
USERNAME="$RUN_UID"
fi
TARGET_USER_SPEC="${RUN_UID}:${RUN_GID}"
fi
# Set GID if not set
if [ -z "$GID" ]; then
GID=100
fi
# Avoid unnecessary gosu hops when we're already running as the target user.
# Some nested LXC setups spin on root-to-root gosu invocations.
needs_user_switch() {
local current_uid
local current_gid
if ! getent group "$GID" >/dev/null; then
echo "Adding group $GID with name appuser"
groupadd -g "$GID" appuser
fi
current_uid=$(id -u)
current_gid=$(id -g)
# Create user if it doesn't exist
if ! id -u "$UID" >/dev/null 2>&1; then
echo "Adding user $UID with name appuser"
useradd -u "$UID" -g "$GID" -d /app -s /sbin/nologin appuser
fi
[ "$current_uid" != "$RUN_UID" ] || [ "$current_gid" != "$RUN_GID" ]
}
# Get username for the UID (whether we just created it or it existed)
USERNAME=$(getent passwd "$UID" | cut -d: -f1)
echo "Username for UID $UID is $USERNAME"
run_as_target_user() {
if needs_user_switch; then
gosu "$TARGET_USER_SPEC" "$@"
return $?
fi
"$@"
}
exec_as_target_user() {
if needs_user_switch; then
exec gosu "$TARGET_USER_SPEC" "$@"
fi
exec "$@"
}
test_write() {
folder=$1
test_file=$folder/calibre-web-automated-book-downloader_TEST_WRITE
mkdir -p $folder
(
echo 0123456789_TEST | sudo -E -u "$USERNAME" HOME=/app tee $test_file > /dev/null
)
FILE_CONTENT=$(cat $test_file || echo "")
rm -f $test_file
local folder=$1
local test_file="$folder/shelfmark_TEST_WRITE"
local FILE_CONTENT
local result
local result_text
if ! mkdir -p "$folder"; then
echo "Failed to create directory for write test: $folder"
return 1
fi
if ! run_as_target_user sh -c 'echo 0123456789_TEST > "$1"' _ "$test_file"; then
echo "Failed to write test file in $folder as $USERNAME"
return 1
fi
FILE_CONTENT=$(cat "$test_file" 2>/dev/null || echo "")
rm -f "$test_file"
[ "$FILE_CONTENT" = "0123456789_TEST" ]
result=$?
if [ $result -eq 0 ]; then
@@ -74,42 +254,208 @@ test_write() {
}
make_writable() {
folder=$1
local folder="$1"
local mode="${2:-tree}"
local did_full_chown=0
local is_writable
set +e
test_write $folder
test_write "$folder"
is_writable=$?
set -e
if [ $is_writable -eq 0 ]; then
echo "Folder $folder is writable, no need to change ownership"
else
echo "Folder $folder is not writable, changing ownership"
change_ownership $folder
chmod g+r,g+w $folder || echo "Failed to change group permissions for ${folder}, continuing..."
if [ "$mode" = "root" ]; then
echo "Folder $folder is not writable, fixing top-level ownership and permissions"
mkdir -p "$folder"
chown "${RUN_UID}:${RUN_GID}" "$folder" || echo "Failed to change ownership for ${folder}, continuing..."
chmod u+rwx "$folder" || echo "Failed to change owner permissions for ${folder}, continuing..."
else
echo "Folder $folder is not writable, changing ownership"
change_ownership "$folder"
chmod -R g+r,g+w "$folder" || echo "Failed to change group permissions for ${folder}, continuing..."
fi
did_full_chown=1
fi
test_write $folder || echo "Failed to test write to ${folder}, continuing..."
# Fix any misowned subdirectories/files (e.g., from previous runs as root)
if [ "$mode" = "tree" ] && [ "$did_full_chown" -eq 0 ] && [ -d "$folder" ]; then
echo "Checking for misowned files/directories in $folder"
# Stay on the same filesystem to avoid traversing mounted subpaths
# (for example read-only bind mounts under /app in dev setups).
find "$folder" -xdev -mindepth 1 \( ! -user "$RUN_UID" -o ! -group "$RUN_GID" \) \
-exec chown "$RUN_UID:$RUN_GID" {} + 2>/dev/null || true
fi
test_write "$folder" || echo "Failed to test write to ${folder}, continuing..."
}
fix_misowned() {
local folder="$1"
mkdir -p "$folder"
echo "Checking for misowned files/directories in $folder"
# Stay on the same filesystem to avoid traversing mounted subpaths
# (for example read-only bind mounts under /app in dev setups).
find "$folder" -xdev \( ! -user "$RUN_UID" -o ! -group "$RUN_GID" \) \
-exec chown "$RUN_UID:$RUN_GID" {} + 2>/dev/null || true
}
# Ensure proper ownership of application directories
change_ownership() {
folder=$1
mkdir -p $folder
echo "Changing ownership of $folder to $USERNAME:$GID"
chown -R "${UID}" "${folder}" || echo "Failed to change user ownership for ${folder}, continuing..."
chown -R ":${GID}" "${folder}" || echo "Failed to change group ownership for ${folder}, continuing..."
local folder="$1"
mkdir -p "$folder"
echo "Changing ownership of $folder to $USERNAME:$RUN_GID"
chown -R "${RUN_UID}:${RUN_GID}" "${folder}" || echo "Failed to change ownership for ${folder}, continuing..."
}
change_ownership /app
change_ownership /var/log/cwa-book-downloader
change_ownership /tmp/cwa-book-downloader
require_writable_dir() {
local folder="$1"
local label="${2:-Directory}"
# Test write to all folders
make_writable /cwa-book-ingest
if ! mkdir -p "$folder"; then
echo "Failed to create ${label} directory: $folder"
exit 1
fi
if ! test_write "$folder"; then
echo "${label} directory is not writable in non-root mode: $folder"
echo "Prepare ownership outside the container (for example with a pre-owned volume or Kubernetes fsGroup)."
exit 1
fi
}
resolve_runtime_home() {
local runtime_home
runtime_home=$(getent passwd "$RUN_UID" 2>/dev/null | cut -d: -f6 || true)
case "$runtime_home" in
""|/|/app|/nonexistent)
runtime_home="$DEFAULT_RUNTIME_HOME"
;;
esac
printf '%s\n' "$runtime_home"
}
ensure_tree_writable() {
local folder="$1"
make_writable "$folder"
if [ -d "$folder" ]; then
chmod -R u+rwX,g+rwX "$folder" || echo "Failed to relax permissions for ${folder}, continuing..."
fi
}
ensure_symlinked_dir() {
local link_path="$1"
local target_path="$2"
ensure_tree_writable "$target_path"
if [ -L "$link_path" ]; then
local current_target
current_target=$(readlink "$link_path" 2>/dev/null || echo "")
if [ "$current_target" = "$target_path" ]; then
echo "$link_path already points to $target_path"
return 0
fi
echo "Replacing symlink $link_path -> $current_target with $target_path"
rm -f "$link_path" || echo "Failed to replace symlink ${link_path}, continuing..."
elif [ -d "$link_path" ]; then
echo "Moving existing scratch files from $link_path to $target_path"
find "$link_path" -xdev -mindepth 1 -maxdepth 1 -exec mv -t "$target_path" {} + 2>/dev/null || true
ensure_tree_writable "$target_path"
if ! rmdir "$link_path" 2>/dev/null; then
echo "Could not replace $link_path with symlink, leaving existing directory in place"
ensure_tree_writable "$link_path"
return 0
fi
elif [ -e "$link_path" ]; then
echo "$link_path exists and is not a directory, leaving it in place"
return 0
fi
if [ ! -e "$link_path" ]; then
ln -s "$target_path" "$link_path" || echo "Failed to create symlink ${link_path}, continuing..."
fi
}
if [ "$RUN_AS_NON_ROOT" = "true" ]; then
require_writable_dir /tmp/shelfmark "Temporary"
if [ "${USING_EXTERNAL_BYPASSER}" != "true" ]; then
require_writable_dir /tmp/shelfmark/seleniumbase/downloaded_files "SeleniumBase downloads"
require_writable_dir /tmp/shelfmark/seleniumbase/archived_files "SeleniumBase archive"
fi
require_writable_dir "${CONFIG_DIR:-/config}" "Config"
else
fix_misowned /var/log/shelfmark
fix_misowned /tmp/shelfmark
# Keep SeleniumBase on its default /app-based paths, but redirect the scratch
# directories into /tmp so bypasser startup doesn't depend on image-layer writes.
if [ "${USING_EXTERNAL_BYPASSER}" != "true" ]; then
ensure_symlinked_dir /app/downloaded_files /tmp/shelfmark/seleniumbase/downloaded_files
ensure_symlinked_dir /app/archived_files /tmp/shelfmark/seleniumbase/archived_files
# Keep SeleniumBase's bundled drivers directory writable as well for
# compatibility with legacy UC code paths that still probe bundled assets.
set +e
SELENIUMBASE_DRIVERS_DIR=$("$PYTHON_BIN" -c "import pathlib, seleniumbase; print(pathlib.Path(seleniumbase.__file__).resolve().parent / 'drivers')" 2>/dev/null)
set -e
if [ -n "$SELENIUMBASE_DRIVERS_DIR" ] && [ -d "$SELENIUMBASE_DRIVERS_DIR" ]; then
change_ownership "$SELENIUMBASE_DRIVERS_DIR"
# If the legacy driver already exists, ensure it's executable for the runtime user.
if [ -f "${SELENIUMBASE_DRIVERS_DIR}/uc_driver" ]; then
chmod +x "${SELENIUMBASE_DRIVERS_DIR}/uc_driver" || echo "Failed to chmod uc_driver, continuing..."
fi
fi
fi
# Config is Shelfmark-owned state, so it keeps the thorough repair path.
make_writable "${CONFIG_DIR:-/config}" tree
# Fallback to root if config dir is still not writable (common on NAS/Unraid after upgrade from v0.4.0)
CONFIG_PATH=${CONFIG_DIR:-/config}
set +e
test_write "$CONFIG_PATH" >/dev/null 2>&1
config_ok=$?
set -e
if [ $config_ok -ne 0 ] && [ "$RUN_UID" != "0" ]; then
config_owner=$(stat -c '%u' "$CONFIG_PATH" 2>/dev/null || echo "unknown")
if [ "$config_owner" = "0" ]; then
echo ""
echo "========================================================"
echo "WARNING: Permission issue detected!"
echo ""
echo "Config directory is owned by root but PUID=$RUN_UID."
echo "This typically happens after upgrading from v0.4.0 where"
echo "PUID/PGID settings were not respected."
echo ""
echo "Falling back to running as root to prevent data loss."
echo ""
echo "To fix this permanently, run on your HOST machine:"
echo " chown -R $RUN_UID:$RUN_GID /path/to/config"
echo ""
echo "Then restart the container."
echo "========================================================"
echo ""
RUN_UID=0
RUN_GID=0
USERNAME=root
TARGET_USER_SPEC="0:0"
fi
fi
fi
# Always run Gunicorn (even when DEBUG=true) to ensure Socket.IO WebSocket
# upgrades work reliably on customer machines.
# Map app LOG_LEVEL (often DEBUG/INFO/...) to gunicorn's --log-level (lowercase).
gunicorn_loglevel=$([ "$DEBUG" = "true" ] && echo debug || echo "${LOG_LEVEL:-info}" | tr '[:upper:]' '[:lower:]')
command="gunicorn --log-level ${gunicorn_loglevel} --access-logfile - --error-logfile - --worker-class geventwebsocket.gunicorn.workers.GeventWebSocketWorker --workers 1 -t 300 -b ${FLASK_HOST:-0.0.0.0}:${FLASK_PORT:-8084} app:app"
command="${GUNICORN_BIN} --log-level ${gunicorn_loglevel} --access-logfile - --error-logfile - --worker-class geventwebsocket.gunicorn.workers.GeventWebSocketWorker --workers 1 -t 300 -b ${FLASK_HOST:-0.0.0.0}:${FLASK_PORT:-8084} shelfmark.main:app"
# If DEBUG and not using an external bypass
if [ "$DEBUG" = "true" ] && [ "$USING_EXTERNAL_BYPASSER" != "true" ]; then
@@ -117,7 +463,7 @@ if [ "$DEBUG" = "true" ] && [ "$USING_EXTERNAL_BYPASSER" != "true" ]; then
set -x
echo "vvvvvvvvvvvv DEBUG MODE vvvvvvvvvvvv"
echo "Starting Xvfb for debugging"
python3 -c "from pyvirtualdisplay import Display; Display(visible=False, size=(1440,1880)).start()"
"$PYTHON_BIN" -c "from pyvirtualdisplay import Display; Display(visible=False, size=(1440,1880)).start()"
id
free -h
uname -a
@@ -137,7 +483,7 @@ if [ "$DEBUG" = "true" ] && [ "$USING_EXTERNAL_BYPASSER" != "true" ]; then
--enable-logging --v=1 --log-level=0 \
--log-file=/tmp/chrome_entrypoint_test.log \
--crash-dumps-dir=/tmp/chrome_crash_dumps \
< /dev/null
< /dev/null
EXIT_CODE=$?
echo "Chrome exit code: $EXIT_CODE"
ls -lh /tmp/chrome_entrypoint_test.log
@@ -165,18 +511,41 @@ if [ "$DEBUG" = "true" ] && [ "$USING_EXTERNAL_BYPASSER" != "true" ]; then
echo "^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^"
fi
# Hacky way to verify /tmp has at least 1MB of space and is writable/readable
# Verify /tmp has at least 1MB of space and is writable/readable
echo "Verifying /tmp has enough space"
rm -f /tmp/test.cwa-bd
for i in {1..150000}; do printf "%04d\n" $i; done > /tmp/test.cwa-bd
sum=$(python3 -c "print(sum(int(l.strip()) for l in open('/tmp/test.cwa-bd').readlines()))")
[ "$sum" == 11250075000 ] && echo "Success: /tmp is writable" || (echo "Failure: /tmp is not writable" && exit 1)
rm /tmp/test.cwa-bd
rm -f /tmp/test.shelfmark
if dd if=/dev/zero of=/tmp/test.shelfmark bs=1M count=1 2>/dev/null && \
[ "$(wc -c < /tmp/test.shelfmark)" -eq 1048576 ]; then
rm -f /tmp/test.shelfmark
echo "Success: /tmp is writable and readable"
else
echo "Failure: /tmp is not writable or has insufficient space"
exit 1
fi
echo "Running command: '$command' as '$USERNAME' (debug=$is_debug)"
RUNTIME_HOME=$(resolve_runtime_home)
if [ "$RUN_AS_NON_ROOT" = "true" ]; then
require_writable_dir "$RUNTIME_HOME" "Home"
else
mkdir -p "$RUNTIME_HOME"
make_writable "$RUNTIME_HOME" tree
fi
# Stop logging
exec 1>&3 2>&4
exec 3>&- 4>&-
if [ "$RUN_AS_NON_ROOT" = "true" ]; then
echo "Startup mode: non-root"
elif [ "$RUN_UID" = "0" ] && [ "$RUN_GID" = "0" ]; then
echo "Startup mode: root"
else
echo "Startup mode: root bootstrap with privilege drop"
fi
echo "Runtime identity: $USERNAME (${RUN_UID}:${RUN_GID})"
exec sudo -E -u "$USERNAME" HOME=/app $command
echo "Running command: '$command' as '$USERNAME' (debug=${DEBUG:-false})"
# Set umask for file permissions (default: 0022 = files 644, dirs 755)
UMASK_VALUE=${UMASK:-0022}
echo "Setting umask to $UMASK_VALUE"
umask $UMASK_VALUE
stop_file_logging
exec_as_target_user env HOME="$RUNTIME_HOME" $command
-93
View File
@@ -1,93 +0,0 @@
import os
from pathlib import Path
def string_to_bool(s: str) -> bool:
return s.lower() in ["true", "yes", "1", "y"]
# Authentication and session settings
SESSION_COOKIE_SECURE_ENV = os.getenv("SESSION_COOKIE_SECURE", "false")
CWA_DB = os.getenv("CWA_DB_PATH")
CWA_DB_PATH = Path(CWA_DB) if CWA_DB else None
LOG_ROOT = Path(os.getenv("LOG_ROOT", "/var/log/"))
LOG_DIR = LOG_ROOT / "cwa-book-downloader"
TMP_DIR = Path(os.getenv("TMP_DIR", "/tmp/cwa-book-downloader"))
INGEST_DIR = Path(os.getenv("INGEST_DIR", "/cwa-book-ingest"))
INGEST_DIR_BOOK_FICTION = os.getenv("INGEST_DIR_BOOK_FICTION", "")
INGEST_DIR_BOOK_NON_FICTION = os.getenv("INGEST_DIR_BOOK_NON_FICTION", "")
INGEST_DIR_BOOK_UNKNOWN = os.getenv("INGEST_DIR_BOOK_UNKNOWN", "")
INGEST_DIR_MAGAZINE = os.getenv("INGEST_DIR_MAGAZINE", "")
INGEST_DIR_COMIC_BOOK = os.getenv("INGEST_DIR_COMIC_BOOK", "")
INGEST_DIR_AUDIOBOOK = os.getenv("INGEST_DIR_AUDIOBOOK", "")
INGEST_DIR_STANDARDS_DOCUMENT = os.getenv("INGEST_DIR_STANDARDS_DOCUMENT", "")
INGEST_DIR_MUSICAL_SCORE = os.getenv("INGEST_DIR_MUSICAL_SCORE", "")
INGEST_DIR_OTHER = os.getenv("INGEST_DIR_OTHER", "")
DOWNLOAD_PATHS = {
"book (fiction)": Path(INGEST_DIR_BOOK_FICTION) if INGEST_DIR_BOOK_FICTION else INGEST_DIR,
"book (non-fiction)": Path(INGEST_DIR_BOOK_NON_FICTION) if INGEST_DIR_BOOK_NON_FICTION else INGEST_DIR,
"book (unknown)": Path(INGEST_DIR_BOOK_UNKNOWN) if INGEST_DIR_BOOK_UNKNOWN else INGEST_DIR,
"magazine": Path(INGEST_DIR_MAGAZINE) if INGEST_DIR_MAGAZINE else INGEST_DIR,
"comic book": Path(INGEST_DIR_COMIC_BOOK) if INGEST_DIR_COMIC_BOOK else INGEST_DIR,
"audiobook": Path(INGEST_DIR_AUDIOBOOK) if INGEST_DIR_AUDIOBOOK else INGEST_DIR,
"standards document": Path(INGEST_DIR_STANDARDS_DOCUMENT) if INGEST_DIR_STANDARDS_DOCUMENT else INGEST_DIR,
"musical score": Path(INGEST_DIR_MUSICAL_SCORE) if INGEST_DIR_MUSICAL_SCORE else INGEST_DIR,
"other": Path(INGEST_DIR_OTHER) if INGEST_DIR_OTHER else INGEST_DIR,
}
STATUS_TIMEOUT = int(os.getenv("STATUS_TIMEOUT", "3600"))
USE_BOOK_TITLE = string_to_bool(os.getenv("USE_BOOK_TITLE", "false"))
MAX_RETRY = int(os.getenv("MAX_RETRY", "10"))
DEFAULT_SLEEP = int(os.getenv("DEFAULT_SLEEP", "5"))
USE_CF_BYPASS = string_to_bool(os.getenv("USE_CF_BYPASS", "true"))
HTTP_PROXY = os.getenv("HTTP_PROXY", "").strip()
HTTPS_PROXY = os.getenv("HTTPS_PROXY", "").strip()
AA_DONATOR_KEY = os.getenv("AA_DONATOR_KEY", "").strip()
_AA_BASE_URL = os.getenv("AA_BASE_URL", "auto").strip()
_AA_ADDITIONAL_URLS = os.getenv("AA_ADDITIONAL_URLS", "").strip()
_SUPPORTED_FORMATS = os.getenv("SUPPORTED_FORMATS", "epub,mobi,azw3,fb2,djvu,cbz,cbr").lower()
_BOOK_LANGUAGE = os.getenv("BOOK_LANGUAGE", "en").lower()
_CUSTOM_SCRIPT = os.getenv("CUSTOM_SCRIPT", "").strip()
FLASK_HOST = os.getenv("FLASK_HOST", "0.0.0.0")
FLASK_PORT = int(os.getenv("FLASK_PORT", "8084"))
DEBUG = string_to_bool(os.getenv("DEBUG", "false"))
PRIORITIZE_WELIB = string_to_bool(os.getenv("PRIORITIZE_WELIB", "false"))
ALLOW_USE_WELIB = string_to_bool(os.getenv("ALLOW_USE_WELIB", "true"))
# Version information from Docker build
BUILD_VERSION = os.getenv("BUILD_VERSION", "N/A")
RELEASE_VERSION = os.getenv("RELEASE_VERSION", "N/A")
# If debug is true, we want to log everything
if DEBUG:
LOG_LEVEL = "DEBUG"
else:
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper()
ENABLE_LOGGING = string_to_bool(os.getenv("ENABLE_LOGGING", "true"))
MAIN_LOOP_SLEEP_TIME = int(os.getenv("MAIN_LOOP_SLEEP_TIME", "5"))
MAX_CONCURRENT_DOWNLOADS = int(os.getenv("MAX_CONCURRENT_DOWNLOADS", "3"))
DOWNLOAD_PROGRESS_UPDATE_INTERVAL = int(os.getenv("DOWNLOAD_PROGRESS_UPDATE_INTERVAL", "5"))
DOCKERMODE = string_to_bool(os.getenv("DOCKERMODE", "false"))
_CUSTOM_DNS = os.getenv("CUSTOM_DNS", "").strip()
USE_DOH = string_to_bool(os.getenv("USE_DOH", "false"))
BYPASS_RELEASE_INACTIVE_MIN = int(os.getenv("BYPASS_RELEASE_INACTIVE_MIN", "5"))
# Logging settings
LOG_FILE = LOG_DIR / "cwa-book-downloader.log"
USING_EXTERNAL_BYPASSER = string_to_bool(os.getenv("USING_EXTERNAL_BYPASSER", "false"))
if USING_EXTERNAL_BYPASSER:
EXT_BYPASSER_URL = os.getenv("EXT_BYPASSER_URL", "http://flaresolverr:8191").strip()
EXT_BYPASSER_PATH = os.getenv("EXT_BYPASSER_PATH", "/v1").strip()
EXT_BYPASSER_TIMEOUT = int(os.getenv("EXT_BYPASSER_TIMEOUT", "60000"))
USING_TOR = string_to_bool(os.getenv("USING_TOR", "false"))
# If using Tor, we don't need to set custom DNS, use DOH, or proxy
if USING_TOR:
_CUSTOM_DNS = ""
USE_DOH = False
HTTP_PROXY = ""
HTTPS_PROXY = ""
# Calibre-Web URL for navigation button
CALIBRE_WEB_URL = os.getenv("CALIBRE_WEB_URL", "").strip()
+79 -29
View File
@@ -2,8 +2,8 @@
# Set up log paths
LOG_ROOT=${LOG_ROOT:-"/var/log"}
LOG_DIR="$LOG_ROOT/cwa-book-downloader"
OUTPUT_FILE_NAME="cwa-book-downloader-debug_BUILD-${BUILD_VERSION:-local}_RELEASE-${RELEASE_VERSION:-NA}_$(date +%Y%m%d-%H%M%S)"
LOG_DIR="$LOG_ROOT/shelfmark"
OUTPUT_FILE_NAME="shelfmark-debug_BUILD-${BUILD_VERSION:-local}_RELEASE-${RELEASE_VERSION:-NA}_$(date +%Y%m%d-%H%M%S)"
OUTPUT_FILE="/tmp/$OUTPUT_FILE_NAME.zip"
# Create LOG_DIR if it doesn't exist
@@ -18,17 +18,17 @@ echo "" >> "$LOG_DIR/system_info.txt"
# Add disk usage
echo "=== Disk Usage ===" >> "$LOG_DIR/system_info.txt"
df -h >> "$LOG_DIR/system_info.txt"
df -h >> "$LOG_DIR/system_info.txt" 2>&1
echo "" >> "$LOG_DIR/system_info.txt"
# Add memory info
echo "=== Memory Info ===" >> "$LOG_DIR/system_info.txt"
free -h >> "$LOG_DIR/system_info.txt"
free -h >> "$LOG_DIR/system_info.txt" 2>&1
echo "" >> "$LOG_DIR/system_info.txt"
# Add running processes
echo "=== Running Processes ===" >> "$LOG_DIR/system_info.txt"
ps aux >> "$LOG_DIR/system_info.txt"
ps aux >> "$LOG_DIR/system_info.txt" 2>&1
echo "" >> "$LOG_DIR/system_info.txt"
# Add network information using basic commands
@@ -37,17 +37,17 @@ echo "=== Network Information ===" > "$LOG_DIR/network_info.txt"
# Try to get basic connectivity information
echo "=== Basic Connectivity ===" >> "$LOG_DIR/network_info.txt"
echo "Hostname resolution:" >> "$LOG_DIR/network_info.txt"
cat /etc/hosts 2>/dev/null >> "$LOG_DIR/network_info.txt" || echo "Unable to read /etc/hosts" >> "$LOG_DIR/network_info.txt"
cat /etc/hosts >> "$LOG_DIR/network_info.txt" 2>&1 || echo "Unable to read /etc/hosts" >> "$LOG_DIR/network_info.txt"
echo "" >> "$LOG_DIR/network_info.txt"
echo "DNS configuration:" >> "$LOG_DIR/network_info.txt"
cat /etc/resolv.conf 2>/dev/null >> "$LOG_DIR/network_info.txt" || echo "Unable to read /etc/resolv.conf" >> "$LOG_DIR/network_info.txt"
cat /etc/resolv.conf >> "$LOG_DIR/network_info.txt" 2>&1 || echo "Unable to read /etc/resolv.conf" >> "$LOG_DIR/network_info.txt"
echo "" >> "$LOG_DIR/network_info.txt"
# Try to get interface information from /proc
echo "=== Network Interfaces (/proc) ===" >> "$LOG_DIR/network_info.txt"
if [ -f "/proc/net/dev" ]; then
cat /proc/net/dev >> "$LOG_DIR/network_info.txt"
cat /proc/net/dev >> "$LOG_DIR/network_info.txt" 2>&1
else
echo "Not available: /proc/net/dev not found" >> "$LOG_DIR/network_info.txt"
fi
@@ -55,9 +55,9 @@ echo "" >> "$LOG_DIR/network_info.txt"
# Try connectivity tests
echo "=== Internet Connectivity ===" >> "$LOG_DIR/network_info.txt"
ping -c 3 1.1.1.1 2>/dev/null >> "$LOG_DIR/network_info.txt" || echo "Ping command failed or not available" >> "$LOG_DIR/network_info.txt"
ping -c 3 1.1.1.1 >> "$LOG_DIR/network_info.txt" 2>&1 || echo "Ping command failed or not available" >> "$LOG_DIR/network_info.txt"
echo "" >> "$LOG_DIR/network_info.txt"
ping -c 3 one.one.one.one 2>/dev/null >> "$LOG_DIR/network_info.txt" || echo "DNS resolution test failed" >> "$LOG_DIR/network_info.txt"
ping -c 3 one.one.one.one >> "$LOG_DIR/network_info.txt" 2>&1 || echo "DNS resolution test failed" >> "$LOG_DIR/network_info.txt"
echo "" >> "$LOG_DIR/network_info.txt"
# Test IPv6 connectivity
@@ -77,7 +77,7 @@ echo "" >> "$LOG_DIR/network_info.txt"
# Try IPv6 connectivity test using Cloudflare's IPv6 DNS
echo "Testing IPv6 connectivity to Cloudflare DNS:" >> "$LOG_DIR/network_info.txt"
ping6 -c 3 2606:4700:4700::1111 2>/dev/null >> "$LOG_DIR/network_info.txt" || echo "IPv6 ping failed or not available" >> "$LOG_DIR/network_info.txt"
ping6 -c 3 2606:4700:4700::1111 >> "$LOG_DIR/network_info.txt" 2>&1 || echo "IPv6 ping failed or not available" >> "$LOG_DIR/network_info.txt"
echo "" >> "$LOG_DIR/network_info.txt"
# Test SSL connectivity
@@ -92,24 +92,36 @@ echo "" >> "$LOG_DIR/network_info.txt"
# Add installed packages
echo "=== Installed Python Packages ===" > "$LOG_DIR/packages.txt"
pip list 2>/dev/null >> "$LOG_DIR/packages.txt" || echo "pip not found" >> "$LOG_DIR/packages.txt"
pip list >> "$LOG_DIR/packages.txt" 2>&1 || echo "pip not found" >> "$LOG_DIR/packages.txt"
echo "" >> "$LOG_DIR/packages.txt"
# Check Permissions
echo "=== Permissions ===" > "$LOG_DIR/permissions.txt"
echo "ls -all /app" >> "$LOG_DIR/permissions.txt"
ls -all /app >> "$LOG_DIR/permissions.txt"
ls -all /app >> "$LOG_DIR/permissions.txt" 2>&1
echo "" >> "$LOG_DIR/permissions.txt"
echo "ls -all /cwa-book-ingest" >> "$LOG_DIR/permissions.txt"
ls -all /cwa-book-ingest >> "$LOG_DIR/permissions.txt"
echo "ls -all ${INGEST_DIR:-/books}" >> "$LOG_DIR/permissions.txt"
ls -all ${INGEST_DIR:-/books} >> "$LOG_DIR/permissions.txt" 2>&1
echo "" >> "$LOG_DIR/permissions.txt"
echo "ls -all /var/log/cwa-book-downloader" >> "$LOG_DIR/permissions.txt"
ls -all /var/log/cwa-book-downloader >> "$LOG_DIR/permissions.txt"
echo "ls -all /var/log/shelfmark" >> "$LOG_DIR/permissions.txt"
ls -all /var/log/shelfmark >> "$LOG_DIR/permissions.txt" 2>&1
echo "" >> "$LOG_DIR/permissions.txt"
echo "ls -all /tmp/cwa-book-downloader" >> "$LOG_DIR/permissions.txt"
ls -all /tmp/cwa-book-downloader >> "$LOG_DIR/permissions.txt"
echo "ls -all /tmp/shelfmark" >> "$LOG_DIR/permissions.txt"
ls -all /tmp/shelfmark >> "$LOG_DIR/permissions.txt" 2>&1
echo "" >> "$LOG_DIR/permissions.txt"
# Check Iptables (NAT)
echo "=== IPtables NAT Rules ===" > "$LOG_DIR/iptables_nat.txt"
iptables -t nat -L -v -n >> "$LOG_DIR/iptables_nat.txt" 2>&1
# Check DNS Resolution details
echo "=== DNS Resolution Test ===" > "$LOG_DIR/dns_test.txt"
echo "Resolving google.com:" >> "$LOG_DIR/dns_test.txt"
nslookup google.com >> "$LOG_DIR/dns_test.txt" 2>&1
echo "" >> "$LOG_DIR/dns_test.txt"
echo "Resolving check.torproject.org:" >> "$LOG_DIR/dns_test.txt"
nslookup check.torproject.org >> "$LOG_DIR/dns_test.txt" 2>&1
# Check if running in Docker
echo "=== Container Info ===" > "$LOG_DIR/container_info.txt"
@@ -122,19 +134,58 @@ else
fi
# Add environment variables (redacting sensitive info)
env | grep -v -E "(AA_DONATOR_KEY)" | sort > "$LOG_DIR/environment.txt"
env | grep -v -E "(AA_DONATOR_KEY|HARDCOVER_API_KEY|_KEY=|_SECRET=|_PASSWORD=|_TOKEN=)" | sort > "$LOG_DIR/environment.txt"
echo "--- HTTPBin ---" > $LOG_DIR/network_info.txt
curl -s https://httpbin.org/get >> $LOG_DIR/network_info.txt
ehco ""
# Add configuration files (redacting sensitive values)
CONFIG_DIR=${CONFIG_DIR:-"/config"}
if [ -d "$CONFIG_DIR" ]; then
mkdir -p "$LOG_DIR/config"
# Copy and redact main settings file
if [ -f "$CONFIG_DIR/settings.json" ]; then
# Redact sensitive fields (API keys, passwords, tokens)
sed -E 's/("(AA_DONATOR_KEY|HARDCOVER_API_KEY|[^"]*_KEY|[^"]*_SECRET|[^"]*_PASSWORD|[^"]*_TOKEN)"[[:space:]]*:[[:space:]]*")[^"]+"/\1[REDACTED]"/g' \
"$CONFIG_DIR/settings.json" > "$LOG_DIR/config/settings.json" 2>/dev/null
fi
# Copy and redact plugin config files
if [ -d "$CONFIG_DIR/plugins" ]; then
mkdir -p "$LOG_DIR/config/plugins"
for config_file in "$CONFIG_DIR/plugins"/*.json; do
if [ -f "$config_file" ]; then
filename=$(basename "$config_file")
sed -E 's/("(AA_DONATOR_KEY|HARDCOVER_API_KEY|[^"]*_KEY|[^"]*_SECRET|[^"]*_PASSWORD|[^"]*_TOKEN)"[[:space:]]*:[[:space:]]*")[^"]+"/\1[REDACTED]"/g' \
"$config_file" > "$LOG_DIR/config/plugins/$filename" 2>/dev/null
fi
done
fi
echo "Configuration files copied (sensitive values redacted)" >> "$LOG_DIR/container_info.txt"
else
echo "Config directory not found at $CONFIG_DIR" >> "$LOG_DIR/container_info.txt"
fi
echo "--- HTTPBin ---" >> $LOG_DIR/network_info.txt
curl -s https://httpbin.org/get >> $LOG_DIR/network_info.txt 2>&1
echo "" >> $LOG_DIR/network_info.txt
echo "--- HowsMySSL ---" >> $LOG_DIR/network_info.txt
curl -s https://www.howsmyssl.com/a/check >> $LOG_DIR/network_info.txt
ehco ""
curl -s https://www.howsmyssl.com/a/check >> $LOG_DIR/network_info.txt 2>&1
echo "" >> $LOG_DIR/network_info.txt
echo "--- IPInfo ---" >> $LOG_DIR/network_info.txt
curl -s https://ipinfo.io >> $LOG_DIR/network_info.txt
ehco ""
curl -s https://ipinfo.io >> $LOG_DIR/network_info.txt 2>&1
echo "" >> $LOG_DIR/network_info.txt
echo "--- Cloudflare Trace ---" >> $LOG_DIR/network_info.txt
curl -s https://1.1.1.1/cdn-cgi/trace >> $LOG_DIR/network_info.txt
curl -s https://1.1.1.1/cdn-cgi/trace >> $LOG_DIR/network_info.txt 2>&1
# Copy Tor logs if they exist
if [ -f "/var/log/tor/notices.log" ]; then
cp "/var/log/tor/notices.log" "$LOG_DIR/tor_notices.log"
fi
# Copy Supervisor logs if they exist
if [ -d "/var/log/supervisor" ]; then
cp -rf "/var/log/supervisor/" "$LOG_DIR/supervisor/"
fi
# Create the zip file directly from LOG_DIR
ln -s "$LOG_DIR" /tmp/$OUTPUT_FILE_NAME
@@ -148,4 +199,3 @@ else
echo "Failed to create debug archive"
exit 1
fi
-108
View File
@@ -1,108 +0,0 @@
"""Centralized logging configuration for the book downloader application."""
import logging
import sys
from pathlib import Path
from logging.handlers import RotatingFileHandler
from env import LOG_FILE, ENABLE_LOGGING, LOG_LEVEL
from typing import Any
class CustomLogger(logging.Logger):
"""Custom logger class with additional error_trace method."""
def error_trace(self, msg: Any, *args: Any, **kwargs: Any) -> None:
"""Log an error message with full stack trace."""
self.log_resource_usage()
kwargs.pop('exc_info', None)
self.error(msg, *args, exc_info=True, **kwargs)
def warning_trace(self, msg: Any, *args: Any, **kwargs: Any) -> None:
"""Log a warning message with full stack trace."""
self.log_resource_usage()
kwargs.pop('exc_info', None)
self.warning(msg, *args, exc_info=True, **kwargs)
def info_trace(self, msg: Any, *args: Any, **kwargs: Any) -> None:
"""Log an info message with full stack trace."""
self.log_resource_usage()
kwargs.pop('exc_info', None)
self.info(msg, *args, exc_info=True, **kwargs)
def debug_trace(self, msg: Any, *args: Any, **kwargs: Any) -> None:
"""Log a debug message with full stack trace."""
self.log_resource_usage()
kwargs.pop('exc_info', None)
self.debug(msg, *args, exc_info=True, **kwargs)
def log_resource_usage(self):
import psutil
memory = psutil.virtual_memory()
available_mb = memory.available / (1024 * 1024)
memory_used_mb = memory.used / (1024 * 1024)
cpu_percent = psutil.cpu_percent()
self.debug(f"Container Memory: Available={available_mb:.2f} MB, Used={memory_used_mb:.2f} MB, CPU: {cpu_percent:.2f}%")
def setup_logger(name: str, log_file: Path = LOG_FILE) -> CustomLogger:
"""Set up and configure a logger instance.
Args:
name: The name of the logger instance
log_file: Optional path to log file. If None, logs only to stdout/stderr
Returns:
CustomLogger: Configured logger instance with error_trace method
"""
# Register our custom logger class
logging.setLoggerClass(CustomLogger)
# Create logger as CustomLogger instance
logger = CustomLogger(name)
log_level = logging.INFO
if LOG_LEVEL == "DEBUG":
log_level = logging.DEBUG
elif LOG_LEVEL == "INFO":
log_level = logging.INFO
elif LOG_LEVEL == "WARNING":
log_level = logging.WARNING
elif LOG_LEVEL == "ERROR":
log_level = logging.ERROR
elif LOG_LEVEL == "CRITICAL":
log_level = logging.CRITICAL
logger.setLevel(log_level)
formatter = logging.Formatter(
'%(asctime)s - %(name)s - %(levelname)s - %(filename)s:%(lineno)d - %(message)s'
)
# Console handler for Docker output
console_handler = logging.StreamHandler(sys.stdout)
console_handler.setFormatter(formatter)
console_handler.setLevel(log_level)
console_handler.addFilter(lambda record: record.levelno < logging.ERROR) # Only allow logs below ERROR to stdout
logger.addHandler(console_handler)
# Error handler for stderr
error_handler = logging.StreamHandler(sys.stderr)
error_handler.setLevel(logging.ERROR) # Error and above go to stderr
error_handler.setFormatter(formatter)
logger.addHandler(error_handler)
# File handler if log file is specified
try:
if ENABLE_LOGGING:
# Create log directory if it doesn't exist
log_dir = log_file.parent
log_dir.mkdir(parents=True, exist_ok=True)
file_handler = RotatingFileHandler(
log_file,
maxBytes=10485760, # 10MB
backupCount=5
)
file_handler.setFormatter(formatter)
logger.addHandler(file_handler)
except Exception as e:
logger.error_trace(f"Failed to create log file: {e}", exc_info=True)
return logger
-356
View File
@@ -1,356 +0,0 @@
"""Data structures and models used across the application."""
from dataclasses import dataclass, field
from typing import Dict, List, Optional, Tuple
from enum import Enum
from datetime import datetime, timedelta
from threading import Lock, Event
from pathlib import Path
import queue
import time
from env import INGEST_DIR, STATUS_TIMEOUT
class QueueStatus(str, Enum):
"""Enum for possible book queue statuses."""
QUEUED = "queued"
RESOLVING = "resolving"
BYPASSING = "bypassing"
DOWNLOADING = "downloading"
VERIFYING = "verifying"
INGESTING = "ingesting"
COMPLETE = "complete"
AVAILABLE = "available"
ERROR = "error"
DONE = "done"
CANCELLED = "cancelled"
@dataclass
class QueueItem:
"""Queue item with priority and metadata."""
book_id: str
priority: int
added_time: float
def __lt__(self, other):
"""Compare items for priority queue (lower priority number = higher precedence)."""
if self.priority != other.priority:
return self.priority < other.priority
return self.added_time < other.added_time
@dataclass
class BookInfo:
"""Data class representing book information."""
id: str
title: str
preview: Optional[str] = None
author: Optional[str] = None
publisher: Optional[str] = None
year: Optional[str] = None
language: Optional[str] = None
content: Optional[str] = None
format: Optional[str] = None
size: Optional[str] = None
info: Optional[Dict[str, List[str]]] = None
description: Optional[str] = None
download_urls: List[str] = field(default_factory=list)
download_path: Optional[str] = None
priority: int = 0
progress: Optional[float] = None
class BookQueue:
"""Thread-safe book queue manager with priority support and cancellation."""
def __init__(self) -> None:
self._queue: queue.PriorityQueue[QueueItem] = queue.PriorityQueue()
self._lock = Lock()
self._status: dict[str, QueueStatus] = {}
self._book_data: dict[str, BookInfo] = {}
self._status_timestamps: dict[str, datetime] = {} # Track when each status was last updated
self._status_timeout = timedelta(seconds=STATUS_TIMEOUT) # 1 hour timeout
self._cancel_flags: dict[str, Event] = {} # Cancellation flags for active downloads
self._active_downloads: dict[str, bool] = {} # Track currently downloading books
def add(self, book_id: str, book_data: BookInfo, priority: int = 0) -> None:
"""Add a book to the queue with specified priority.
Args:
book_id: Unique identifier for the book
book_data: Book information
priority: Priority level (lower number = higher priority)
"""
with self._lock:
# Don't add if already exists and not in error/done state
if book_id in self._status and self._status[book_id] not in [QueueStatus.ERROR, QueueStatus.DONE, QueueStatus.CANCELLED]:
return
book_data.priority = priority
queue_item = QueueItem(book_id, priority, time.time())
self._queue.put(queue_item)
self._book_data[book_id] = book_data
self._update_status(book_id, QueueStatus.QUEUED)
def get_next(self) -> Optional[Tuple[str, Event]]:
"""Get next book ID from queue with cancellation flag.
Returns:
Tuple of (book_id, cancel_flag) or None if queue is empty
"""
try:
queue_item = self._queue.get_nowait()
book_id = queue_item.book_id
with self._lock:
# Check if book was cancelled while in queue
if book_id in self._status and self._status[book_id] == QueueStatus.CANCELLED:
return self.get_next() # Recursively get next non-cancelled item
# Create cancellation flag for this download
cancel_flag = Event()
self._cancel_flags[book_id] = cancel_flag
self._active_downloads[book_id] = True
return book_id, cancel_flag
except queue.Empty:
return None
def _update_status(self, book_id: str, status: QueueStatus) -> None:
"""Internal method to update status and timestamp."""
self._status[book_id] = status
self._status_timestamps[book_id] = datetime.now()
def update_status(self, book_id: str, status: QueueStatus) -> None:
"""Update status of a book in the queue."""
with self._lock:
self._update_status(book_id, status)
# Clean up active download tracking when finished
if status in [QueueStatus.COMPLETE, QueueStatus.AVAILABLE, QueueStatus.ERROR, QueueStatus.DONE, QueueStatus.CANCELLED]:
self._active_downloads.pop(book_id, None)
self._cancel_flags.pop(book_id, None)
def update_download_path(self, book_id: str, download_path: str) -> None:
"""Update the download path of a book in the queue."""
with self._lock:
if book_id in self._book_data:
self._book_data[book_id].download_path = download_path
def update_progress(self, book_id: str, progress: float) -> None:
"""Update download progress for a book."""
with self._lock:
if book_id in self._book_data:
self._book_data[book_id].progress = progress
def get_status(self) -> Dict[QueueStatus, Dict[str, BookInfo]]:
"""Get current queue status."""
self.refresh()
with self._lock:
result: Dict[QueueStatus, Dict[str, BookInfo]] = {status: {} for status in QueueStatus}
for book_id, status in self._status.items():
if book_id in self._book_data:
result[status][book_id] = self._book_data[book_id]
return result
def get_queue_order(self) -> List[Dict[str, any]]:
"""Get current queue order for display."""
with self._lock:
queue_items = []
# Get items from priority queue without removing them
temp_items = []
while not self._queue.empty():
try:
item = self._queue.get_nowait()
temp_items.append(item)
if item.book_id in self._book_data:
book_info = self._book_data[item.book_id]
queue_items.append({
'id': item.book_id,
'title': book_info.title,
'author': book_info.author,
'priority': item.priority,
'added_time': item.added_time,
'status': self._status.get(item.book_id, QueueStatus.QUEUED)
})
except queue.Empty:
break
# Put items back in queue
for item in temp_items:
self._queue.put(item)
return sorted(queue_items, key=lambda x: (x['priority'], x['added_time']))
def cancel_download(self, book_id: str) -> bool:
"""Cancel a download and mark it as cancelled.
Args:
book_id: Book identifier to cancel
Returns:
bool: True if cancellation was successful
"""
with self._lock:
current_status = self._status.get(book_id)
# Allow cancellation during any active state
if current_status in [QueueStatus.RESOLVING, QueueStatus.BYPASSING, QueueStatus.DOWNLOADING, QueueStatus.VERIFYING, QueueStatus.INGESTING]:
# Signal active download to stop
if book_id in self._cancel_flags:
self._cancel_flags[book_id].set()
self._update_status(book_id, QueueStatus.CANCELLED)
return True
elif current_status == QueueStatus.QUEUED:
# Remove from queue and mark as cancelled
self._update_status(book_id, QueueStatus.CANCELLED)
return True
return False
def set_priority(self, book_id: str, new_priority: int) -> bool:
"""Change the priority of a queued book.
Args:
book_id: Book identifier
new_priority: New priority level (lower = higher priority)
Returns:
bool: True if priority was successfully changed
"""
with self._lock:
if book_id not in self._status or self._status[book_id] != QueueStatus.QUEUED:
return False
# Remove book from queue and re-add with new priority
temp_items = []
found = False
while not self._queue.empty():
try:
item = self._queue.get_nowait()
if item.book_id == book_id:
# Create new item with updated priority
new_item = QueueItem(book_id, new_priority, item.added_time)
temp_items.append(new_item)
found = True
# Update book data priority
if book_id in self._book_data:
self._book_data[book_id].priority = new_priority
else:
temp_items.append(item)
except queue.Empty:
break
# Put all items back
for item in temp_items:
self._queue.put(item)
return found
def reorder_queue(self, book_priorities: Dict[str, int]) -> bool:
"""Bulk reorder queue by setting new priorities.
Args:
book_priorities: Dict mapping book_id to new priority
Returns:
bool: True if reordering was successful
"""
with self._lock:
# Extract all items from queue
all_items = []
while not self._queue.empty():
try:
item = self._queue.get_nowait()
# Update priority if specified
if item.book_id in book_priorities:
new_priority = book_priorities[item.book_id]
item = QueueItem(item.book_id, new_priority, item.added_time)
# Update book data priority
if item.book_id in self._book_data:
self._book_data[item.book_id].priority = new_priority
all_items.append(item)
except queue.Empty:
break
# Put all items back with updated priorities
for item in all_items:
self._queue.put(item)
return True
def get_active_downloads(self) -> List[str]:
"""Get list of currently active download book IDs."""
with self._lock:
return list(self._active_downloads.keys())
def clear_completed(self) -> int:
"""Remove all completed, errored, or cancelled books from tracking.
Returns:
int: Number of books removed
"""
with self._lock:
to_remove = []
for book_id, status in self._status.items():
if status in [QueueStatus.COMPLETE, QueueStatus.DONE, QueueStatus.AVAILABLE, QueueStatus.ERROR, QueueStatus.CANCELLED]:
to_remove.append(book_id)
removed_count = len(to_remove)
for book_id in to_remove:
self._status.pop(book_id, None)
self._status_timestamps.pop(book_id, None)
self._book_data.pop(book_id, None)
self._cancel_flags.pop(book_id, None)
self._active_downloads.pop(book_id, None)
return removed_count
def refresh(self) -> None:
"""Remove any books that are done downloading or have stale status."""
with self._lock:
current_time = datetime.now()
# Create a list of items to remove to avoid modifying dict during iteration
to_remove = []
for book_id, status in self._status.items():
path = self._book_data[book_id].download_path
if path and not Path(path).exists():
self._book_data[book_id].download_path = None
path = None
# Check for completed downloads
if status == QueueStatus.AVAILABLE:
if not path:
self._update_status(book_id, QueueStatus.DONE)
# Check for stale status entries
last_update = self._status_timestamps.get(book_id)
if last_update and (current_time - last_update) > self._status_timeout:
if status in [QueueStatus.COMPLETE, QueueStatus.DONE, QueueStatus.ERROR, QueueStatus.AVAILABLE, QueueStatus.CANCELLED]:
to_remove.append(book_id)
# Remove stale entries
for book_id in to_remove:
del self._status[book_id]
del self._status_timestamps[book_id]
if book_id in self._book_data:
del self._book_data[book_id]
def set_status_timeout(self, hours: int) -> None:
"""Set the status timeout duration in hours."""
with self._lock:
self._status_timeout = timedelta(hours=hours)
# Global instance of BookQueue
book_queue = BookQueue()
@dataclass
class SearchFilters:
isbn: Optional[List[str]] = None
author: Optional[List[str]] = None
title: Optional[List[str]] = None
lang: Optional[List[str]] = None
sort: Optional[str] = None
content: Optional[List[str]] = None
format: Optional[List[str]] = None
-350
View File
@@ -1,350 +0,0 @@
"""Network operations manager for the book downloader application."""
import requests
import urllib.request
from typing import Sequence, Tuple, Any, Union, cast, List, Optional, Callable
import socket
import dns.resolver
from socket import AddressFamily, SocketKind
import urllib.parse
import ssl
import ipaddress
from logger import setup_logger
from config import PROXIES, AA_BASE_URL, CUSTOM_DNS, AA_AVAILABLE_URLS, DOH_SERVER
import config
logger = setup_logger(__name__)
# Common helper functions for DNS resolution
def _decode_host(host: Union[str, bytes, None]) -> str:
"""Convert host to string, handling bytes and None cases."""
if host is None:
return ""
if isinstance(host, bytes):
return host.decode('utf-8')
return str(host)
def _decode_port(port: Union[str, bytes, int, None]) -> int:
"""Convert port to integer, handling various input types."""
if port is None:
return 0
if isinstance(port, (str, bytes)):
return int(port)
return int(port)
def _is_local_address(host_str: str) -> bool:
"""Check if an address is local and should bypass custom DNS."""
"""Check if an address is local or private and should bypass custom DNS."""
# Localhost checks
if (host_str == 'localhost' or
host_str.startswith('127.') or
host_str == '::1' or
host_str == '0.0.0.0'):
return True
# IPv4 private ranges (RFC 1918)
if (host_str.startswith('10.') or
(host_str.startswith('172.') and
len(host_str.split('.')) > 1 and
16 <= int(host_str.split('.')[1]) <= 31) or
host_str.startswith('192.168.')):
return True
# IPv6 private ranges
if (host_str.startswith('fc') or
host_str.startswith('fd') or # Unique local addresses (fc00::/7)
host_str.startswith('fe80:')): # Link-local addresses (fe80::/10)
return True
return False
def _is_ip_address(host_str: str) -> bool:
"""Check if a string is a valid IP address (IPv4 or IPv6)."""
try:
ipaddress.ip_address(host_str)
return True
except ValueError:
return False
# Store the original getaddrinfo function
original_getaddrinfo = socket.getaddrinfo
class DoHResolver:
"""DNS over HTTPS resolver implementation."""
def __init__(self, provider_url: str, hostname: str, ip: str):
"""Initialize DoH resolver with specified provider."""
self.base_url = provider_url.lower().strip()
self.hostname = hostname # Store the hostname for hostname-based skipping
self.ip = ip # Store IP for direct connections
self.session = requests.Session()
# Different headers based on provider
if 'google' in self.base_url:
self.session.headers.update({
'Accept': 'application/json',
})
else:
self.session.headers.update({
'Accept': 'application/dns-json',
})
def resolve(self, hostname: str, record_type: str) -> List[str]:
"""Resolve a hostname using DoH.
Args:
hostname: The hostname to resolve
record_type: The DNS record type (A or AAAA)
Returns:
List of resolved IP addresses
"""
# Check if hostname is already an IP address, no need to resolve
if _is_ip_address(hostname):
logger.debug(f"Skipping DoH resolution for IP address: {hostname}")
return [hostname]
# Check if hostname is a private IP address, and skip DoH if it is
if _is_local_address(hostname):
logger.debug(f"Skipping DoH resolution for private IP: {hostname}")
return [hostname]
# Skip resolution for the DoH server itself to prevent recursion
if hostname == self.hostname:
logger.debug(f"Skipping DoH resolution for DoH server itself: {hostname}")
return [self.ip]
try:
params = {
'name': hostname,
'type': 'AAAA' if record_type == 'AAAA' else 'A'
}
response = self.session.get(
self.base_url,
params=params,
proxies=PROXIES,
timeout=5
)
response.raise_for_status()
data = response.json()
if 'Answer' not in data:
logger.warning(f"DoH resolution failed for {hostname}: {data}")
return []
# Extract IP addresses from the response
answers = [answer['data'] for answer in data['Answer']
if answer.get('type') == (28 if record_type == 'AAAA' else 1)]
logger.debug(f"Resolved {hostname} to {len(answers)} addresses using DoH: {answers}")
return answers
except Exception as e:
logger.warning(f"DoH resolution failed for {hostname}: {e}")
return []
def create_custom_resolver():
"""Create a custom DNS resolver using the configured DNS servers."""
custom_resolver = dns.resolver.Resolver()
custom_resolver.nameservers = CUSTOM_DNS
return custom_resolver
def resolve_with_custom_dns(resolver, hostname: str, record_type: str) -> List[str]:
"""Resolve hostname using custom DNS resolver.
Args:
resolver: The DNS resolver to use
hostname: The hostname to resolve
record_type: The DNS record type (A or AAAA)
Returns:
List of resolved IP addresses
"""
try:
answers = resolver.resolve(hostname, record_type)
return [str(answer) for answer in answers]
except Exception as e:
logger.debug(f"{record_type} resolution failed for {hostname}: {e}")
return []
def create_custom_getaddrinfo(
resolve_ipv4: Callable[[str], List[str]],
resolve_ipv6: Callable[[str], List[str]],
skip_check: Optional[Callable[[str], bool]] = None
):
"""Create a custom getaddrinfo function that uses the provided resolvers.
Args:
resolve_ipv4: Function to resolve IPv4 addresses
resolve_ipv6: Function to resolve IPv6 addresses
skip_check: Optional function to check if custom resolution should be skipped
Returns:
A custom getaddrinfo function
"""
def custom_getaddrinfo(
host: Union[str, bytes, None],
port: Union[str, bytes, int, None],
family: int = 0,
type: int = 0,
proto: int = 0,
flags: int = 0
) -> Sequence[Tuple[AddressFamily, SocketKind, int, str, Tuple[Any, ...]]]:
host_str = _decode_host(host)
port_int = _decode_port(port)
# Skip custom resolution for IP addresses, local addresses, or if skip check passes
if _is_ip_address(host_str) or _is_local_address(host_str) or (skip_check and skip_check(host_str)):
logger.debug(f"Using system DNS for IP address or local/private address: {host_str}")
return original_getaddrinfo(host, port, family, type, proto, flags)
results: list[Tuple[AddressFamily, SocketKind, int, str, Tuple[Any, ...]]] = []
try:
# Try IPv6 first if family allows it
if family == 0 or family == socket.AF_INET6:
logger.debug(f"Resolving IPv6 address for {host_str}")
ipv6_answers = resolve_ipv6(host_str)
for answer in ipv6_answers:
results.append((socket.AF_INET6, cast(SocketKind, type), proto, '', (answer, port_int, 0, 0)))
if ipv6_answers:
logger.debug(f"Found {len(ipv6_answers)} IPv6 addresses for {host_str}")
# Then try IPv4
if family == 0 or family == socket.AF_INET:
logger.debug(f"Resolving IPv4 address for {host_str}")
ipv4_answers = resolve_ipv4(host_str)
for answer in ipv4_answers:
results.append((socket.AF_INET, cast(SocketKind, type), proto, '', (answer, port_int)))
if ipv4_answers:
logger.debug(f"Found {len(ipv4_answers)} IPv4 addresses for {host_str}")
if results:
logger.debug(f"Resolved {host_str} to {len(results)} addresses")
return results
except Exception as e:
logger.warning(f"Custom DNS resolution failed for {host_str}: {e}, falling back to system DNS")
# Fall back to system DNS if custom resolution fails
try:
return original_getaddrinfo(host, port, family, type, proto, flags)
except Exception as e:
logger.error(f"System DNS resolution also failed for {host_str}: {e}")
# Last resort: Try to connect to the hostname directly
if family == 0 or family == socket.AF_INET:
logger.warning(f"Using direct hostname as last resort for {host_str}")
return [(socket.AF_INET, cast(SocketKind, type), proto, '', (host_str, port_int))]
else:
raise # Re-raise the exception if we can't provide a last resort
return custom_getaddrinfo
def init_doh_resolver(doh_server: str = DOH_SERVER):
"""Initialize DNS over HTTPS resolver.
Args:
doh_server: The DoH server URL
"""
# Pre-resolve the DoH server hostname to prevent recursion
url = urllib.parse.urlparse(doh_server)
server_hostname = url.hostname if url.hostname else ''
# Use system DNS for DoH server to prevent circular dependencies
try:
# Temporarily restore original getaddrinfo to resolve DoH server
temp_getaddrinfo = socket.getaddrinfo
socket.getaddrinfo = original_getaddrinfo
server_ip = socket.gethostbyname(server_hostname)
logger.info(f"DoH server {server_hostname} resolved to IP: {server_ip}")
# Restore custom getaddrinfo if it was previously set
socket.getaddrinfo = temp_getaddrinfo
except Exception as e:
logger.error(f"Failed to resolve DoH server {server_hostname}: {e}")
# Fall back to a known public DNS if resolution fails
server_ip = "1.1.1.1"
logger.info(f"Using fallback IP for DoH server: {server_ip}")
# Create DoH resolver
doh_resolver = DoHResolver(doh_server, server_hostname, server_ip)
# Create resolver functions
def resolve_ipv4(hostname: str) -> List[str]:
return doh_resolver.resolve(hostname, 'A')
def resolve_ipv6(hostname: str) -> List[str]:
return doh_resolver.resolve(hostname, 'AAAA')
# Skip DoH resolution for the DoH server itself, IP addresses, and private addresses
def skip_doh(hostname: str) -> bool:
return (hostname == server_hostname or
hostname == server_ip or
_is_ip_address(hostname) or
_is_local_address(hostname))
# Replace socket.getaddrinfo with our DoH-enabled version
socket.getaddrinfo = cast(Any, create_custom_getaddrinfo(
resolve_ipv4, resolve_ipv6, skip_doh
))
logger.info("DoH resolver successfully configured and activated")
return doh_resolver
def init_custom_resolver():
"""Initialize custom DNS resolver using configured DNS servers."""
custom_resolver = create_custom_resolver()
# Create resolver functions
def resolve_ipv4(hostname: str) -> List[str]:
return resolve_with_custom_dns(custom_resolver, hostname, 'A')
def resolve_ipv6(hostname: str) -> List[str]:
return resolve_with_custom_dns(custom_resolver, hostname, 'AAAA')
# Replace socket.getaddrinfo with our custom resolver
socket.getaddrinfo = cast(Any, create_custom_getaddrinfo(resolve_ipv4, resolve_ipv6))
logger.info("Custom DNS resolver successfully configured and activated")
return custom_resolver
# Initialize DNS resolvers based on configuration
def init_dns_resolvers():
"""Initialize DNS resolvers based on configuration."""
if len(CUSTOM_DNS) > 0:
init_custom_resolver()
if DOH_SERVER:
init_doh_resolver()
# Initialize DNS resolvers
init_dns_resolvers()
# Check available AA_BASE_URLs if set to auto
if AA_BASE_URL == "auto":
logger.info(f"AA_BASE_URL: auto, checking available urls {AA_AVAILABLE_URLS}")
for url in AA_AVAILABLE_URLS:
try:
response = requests.get(url, proxies=PROXIES)
if response.status_code == 200:
AA_BASE_URL = url
break
except Exception as e:
logger.error_trace(f"Error checking {url}: {e}")
if AA_BASE_URL == "auto":
AA_BASE_URL = AA_AVAILABLE_URLS[0]
config.AA_BASE_URL = AA_BASE_URL
logger.info(f"AA_BASE_URL: {AA_BASE_URL}")
# Configure urllib opener with appropriate headers
opener = urllib.request.build_opener()
opener.addheaders = [
('User-agent', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) '
'AppleWebKit/537.36 (KHTML, like Gecko) '
'Chrome/129.0.0.0 Safari/537.3')
]
urllib.request.install_opener(opener)
# Need an empty function to be called by downloader.py
def init():
pass
+22
View File
@@ -0,0 +1,22 @@
{
"name": "shelfmark",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"devDependencies": {
"baseline-browser-mapping": "^2.9.19"
}
},
"node_modules/baseline-browser-mapping": {
"version": "2.9.19",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz",
"integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"baseline-browser-mapping": "dist/cli.js"
}
}
}
}
+5
View File
@@ -0,0 +1,5 @@
{
"devDependencies": {
"baseline-browser-mapping": "^2.9.19"
}
}
+172
View File
@@ -0,0 +1,172 @@
[project]
name = "shelfmark"
version = "0.1.0"
description = "Shelfmark - Book Downloader"
requires-python = ">=3.14"
dependencies = [
"flask",
"flask-cors",
"flask-socketio",
"python-socketio",
"requests[socks]",
"defusedxml",
"beautifulsoup4",
"tqdm",
"dnspython",
"gunicorn",
"gevent",
"gevent-websocket",
"psutil",
"emoji",
"rarfile",
"qbittorrent-api",
"transmission-rpc",
"authlib>=1.7.0,<1.8",
"apprise>=1.9.0",
]
[project.optional-dependencies]
browser = [
"pyvirtualdisplay",
"pyautogui",
"seleniumbase==4.48.2",
"python-xlib",
]
[dependency-groups]
dev = [
"basedpyright>=1.39.3",
"prek",
"pytest",
"pytest-cov",
"pytest-xdist>=3.8.0",
"ruff==0.15.11",
"vulture>=2.14",
]
[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = ["test_*.py", "*_test.py"]
python_classes = ["Test*"]
python_functions = ["test_*"]
addopts = [
"-v",
"--tb=short",
"-n",
"auto",
]
markers = [
"integration: marks tests that require running services (deselect with '-m \"not integration\"')",
"slow: marks tests as slow (deselect with '-m \"not slow\"')",
"e2e: marks end-to-end tests that require the full application stack",
]
[tool.ruff]
line-length = 100
extend-exclude = [".local"]
[tool.ruff.lint]
select = [
"F", "I", "UP", "B", "C4", "SIM", "PTH", "RET", "PIE", "FURB", "PERF", "TRY",
"A", "DTZ", "N",
"BLE001",
"ANN001", "ANN002", "ANN003", "ANN201", "ANN202", "ANN204",
"E402",
"ERA001",
"E731",
"S101",
"S110",
"S105", "S108",
"S311", "S324",
"S607", "S608",
"G003", "G004",
"PGH003",
"PLC0414",
"PLR1714",
"PLW1510",
"PLW2901",
"PLW0108",
"PT028",
"PYI034",
"Q000",
"RUF005", "RUF012", "RUF013", "RUF059", "RUF100",
"TC001", "TC002", "TC003",
]
ignore = ["D", "EM", "FBT", "PLR2004", "UP035", "TRY003", "E501", "TD002", "S104", "S603"]
[tool.ruff.lint.per-file-ignores]
"scripts/**/*.py" = [
"BLE001",
"S",
"TRY",
]
"tests/**/*.py" = [
"ANN",
"BLE001",
"B010",
"B017",
"B028",
"DTZ",
"E402",
"E731",
"ERA001",
"FURB",
"G003",
"G004",
"PERF",
"PIE",
"PLC0414",
"PLW0108",
"PLW1510",
"PLW2901",
"PTH",
"PT028",
"PYI034",
"Q000",
"RET",
"RUF012",
"S",
"SIM",
"TC001",
"TC002",
"TC003",
"TRY",
"UP028",
]
[tool.basedpyright]
include = ["shelfmark"]
exclude = [".local", "tests", "**/__pycache__", "**/node_modules"]
pythonVersion = "3.14"
typeCheckingMode = "standard"
[tool.vulture]
paths = ["shelfmark"]
exclude = [".local", "tests"]
ignore_decorators = [
"@app.route",
"@app.before_request",
"@app.after_request",
"@app.errorhandler",
"@socketio.on",
"@register_provider",
"@register_provider_kwargs",
"@register_settings",
"@register_source",
"@register_handler",
"@register_client",
"@register_output",
]
min_confidence = 90
sort_by_size = true
[tool.coverage.run]
source = ["shelfmark"]
branch = true
[tool.coverage.report]
show_missing = true
skip_empty = true
[tool.uv]
package = false
+196 -280
View File
@@ -1,345 +1,261 @@
# 📚 Calibre-Web-Automated-Book-Downloader
# 📚 Shelfmark: Book Search & Request Tool
![Calibre-Web Automated Book Downloader](src/frontend/public/logo.png 'Calibre-Web Automated Book Downloader')
<img src="src/frontend/public/logo.png" alt="Shelfmark" width="200">
An intuitive web interface for searching and requesting book downloads, designed to work seamlessly with [Calibre-Web-Automated](https://github.com/crocodilestick/Calibre-Web-Automated). This project streamlines the process of downloading books and preparing them for integration into your Calibre library.
Shelfmark is a self-hosted web interface for searching and requesting books and audiobooks across multiple sources. Bring your own sources, metadata providers, and download clients to build a single hub for your digital library. Supports multiple users with a built-in request system, so you can share your instance with others and let them browse and request books on their own.
Works great alongside the following library tools, with support for automatic imports:
- [Calibre](https://calibre-ebook.com/)
- [Calibre-Web](https://github.com/janeczku/calibre-web)
- [Calibre-Web-Automated](https://github.com/crocodilestick/Calibre-Web-Automated)
- [Grimmory](https://github.com/grimmory-tools/grimmory)
- [Audiobookshelf](https://github.com/advplyr/audiobookshelf)
## ✨ Features
- 🌐 User-friendly web interface for book search and download
- 🔄 Automated download to your specified ingest folder
- 🔌 Seamless integration with Calibre-Web-Automated
- 📖 Support for multiple book formats (epub, mobi, azw3, fb2, djvu, cbz, cbr)
- 🛡️ Cloudflare bypass capability for reliable downloads
- 🐳 Docker-based deployment for quick setup
- **One-Stop Interface** - A clean, modern UI to search, browse, and download from multiple configured sources in one place
- **Multiple Sources** - Configurable web, torrent, usenet, and IRC source support
- **Audiobook Support** - Full audiobook search and download with dedicated processing
- **Flexible Search** - Search metadata providers (Hardcover, Open Library, Google Books) for rich book and audiobook discovery, or query configured sources directly
- **Multi-User & Requests** - Share your instance with others, let users browse and request books, and manage approvals with configurable notifications
- **Authentication** - Built-in login, OIDC single sign-on, proxy auth, and Calibre-Web database support
- **Real-Time Progress** - Unified download queue with live status updates across all sources
- **Network Flexibility** - Configurable proxy support, DNS settings, and optional Cloudflare handling for protected sources
## 🖼️ Screenshots
![Main search interface Screenshot](README_images/search.png 'Main search interface')
**Home screen**
![Home screen](README_images/homescreen.png 'Home screen')
![Details modal Screenshot placeholder](README_images/details.png 'Details modal')
**Search results**
![Search results](README_images/search-results.png 'Search results')
![Download queue Screenshot placeholder](README_images/downloading.png 'Download queue')
**Multi-source downloads**
![Multi-source downloads](README_images/multi-source.png 'Multi-source downloads')
**Download queue**
![Download queue](README_images/downloads.png 'Download queue')
## 🚀 Quick Start
### Prerequisites
- Docker
- Docker Compose
- A running instance of [Calibre-Web-Automated](https://github.com/crocodilestick/Calibre-Web-Automated) (recommended)
- Docker & Docker Compose
### Installation Steps
1. Get the docker-compose.yml:
### Installation
1. Download the [docker-compose file](compose/docker-compose.yml):
```bash
curl -O https://raw.githubusercontent.com/calibrain/calibre-web-automated-book-downloader/refs/heads/main/docker-compose.yml
curl -O https://raw.githubusercontent.com/calibrain/shelfmark/main/compose/docker-compose.yml
```
2. Start the service:
```bash
docker compose up -d
```
3. Access the web interface at `http://localhost:8084`
3. Open `http://localhost:8084`
## ⚙️ Configuration
Open the web interface, then configure the sources and settings you want to use.
### Environment Variables
#### Application Settings
| Variable | Description | Default Value |
| ----------------- | ----------------------- | ------------------ |
| `FLASK_PORT` | Web interface port | `8084` |
| `FLASK_HOST` | Web interface binding | `0.0.0.0` |
| `DEBUG` | Debug mode toggle | `false` |
| `INGEST_DIR` | Book download directory | `/cwa-book-ingest` |
| `TZ` | Container timezone | `UTC` |
| `UID` | Runtime user ID | `1000` |
| `GID` | Runtime group ID | `100` |
| `CWA_DB_PATH` | Calibre-Web's database | None |
| `ENABLE_LOGGING` | Enable log file | `true` |
| `LOG_LEVEL` | Log level to use | `info` |
| `SESSION_COOKIE_SECURE` | Secure cookie enforcement - Use for HTTPS connections only | `false` |
| `CALIBRE_WEB_URL` | Custom WebUI library link | None |
If you wish to enable authentication, you must set `CWA_DB_PATH` to point to Calibre-Web's `app.db`, in order to match the username and password.
Set `CALIBRE_WEB_URL` to your Calibre-Web / Booklore base URL. A ‘Go to library’ button will appear in the Web UI for quick access while downloading, and it also provides library access when CWA-BD is installed as a mobile PWA.
If logging is enabled, log folder default location is `/var/log/cwa-book-downloader`
Available log levels: `DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL`. Higher levels show fewer messages.
Note that if using TOR, the TZ will be calculated automatically based on IP.
#### Download Settings
| Variable | Description | Default Value |
| ---------------------- | --------------------------------------------------------- | --------------------------------- |
| `MAX_RETRY` | Maximum retry attempts | `3` |
| `DEFAULT_SLEEP` | Retry delay (seconds) | `5` |
| `MAIN_LOOP_SLEEP_TIME` | Processing loop delay (seconds) | `5` |
| `SUPPORTED_FORMATS` | Supported book formats | `epub,mobi,azw3,fb2,djvu,cbz,cbr` |
| `BOOK_LANGUAGE` | Preferred language for books | `en` |
| `AA_DONATOR_KEY` | Optional Donator key for Anna's Archive fast download API | `` |
| `USE_BOOK_TITLE` | Use book title as filename instead of ID | `false` |
| `PRIORITIZE_WELIB` | When downloading, download from WELIB first instead of AA | `false` |
| `ALLOW_USE_WELIB` | Allow usage of welib for downloading books if found there | `true` |
If you change `BOOK_LANGUAGE`, you can add multiple comma separated languages, such as `en,fr,ru` etc.
Use the following environment variables to set specific folders in which to download
different content types (Book, Magazine, Comic, etc.):
| Variable | Description | Default Value |
|---------------------------------|--------------------------------|---------------|
| `INGEST_DIR_BOOK_FICTION` | Book (fiction) folder name | `` |
| `INGEST_DIR_BOOK_NON_FICTION` | Book (non-fiction) folder name | `` |
| `INGEST_DIR_BOOK_UNKNOWN` | Book (unknown) folder name | `` |
| `INGEST_DIR_MAGAZINE` | Magazine folder name | `` |
| `INGEST_DIR_COMIC_BOOK` | Comic book folder name | `` |
| `INGEST_DIR_AUDIOBOOK` | Audiobook folder name | `` |
| `INGEST_DIR_STANDARDS_DOCUMENT` | Standards document folder name | `` |
| `INGEST_DIR_MUSICAL_SCORE` | Musical score folder name | `` |
If no specific path is set for a content type the default is `INGEST_DIR`.
Remember to map the specified paths to where your instance of Calibre-Web-Automated (CWA) will find them, e.g.:
```
volumes:
- /tmp/data/calibre-web/comicbook-ingest:/cwa-comicbook-ingest
```
if `INGEST_DIR_COMIC_BOOK=/cwa-comicbook-ingest` and your CWA is configured to use `/tmp/data/calibre-web/comicbook-ingest`
for comic books.
#### AA
| Variable | Description | Default Value |
| ---------------------- | --------------------------------------------------------- | --------------------------------- |
| `AA_BASE_URL` | Base URL of Annas-Archive (could be changed for a proxy) | `https://annas-archive.org` |
| `USE_CF_BYPASS` | Disable CF bypass and use alternative links instead | `true` |
If you are a donator on AA, you can use your Key in `AA_DONATOR_KEY` to speed up downloads and bypass the wait times.
If disabling the cloudflare bypass, you will be using alternative download hosts, such as libgen or z-lib, but they usually have a delay before getting the more recent books and their collection is not as big as aa's. But this setting should work for the majority of books.
#### Network Settings
| Variable | Description | Default Value |
| ---------------------- | ------------------------------- | ----------------------- |
| `AA_ADDITIONAL_URLS` | Proxy URLs for AA (, separated) | `` |
| `HTTP_PROXY` | HTTP proxy URL | `` |
| `HTTPS_PROXY` | HTTPS proxy URL | `` |
| `CUSTOM_DNS` | Custom DNS IP | `` |
| `USE_DOH` | Use DNS over HTTPS | `false` |
For proxy configuration, you can specify URLs in the following format:
```bash
# Basic proxy
HTTP_PROXY=http://proxy.example.com:8080
HTTPS_PROXY=http://proxy.example.com:8080
# Proxy with authentication
HTTP_PROXY=http://username:password@proxy.example.com:8080
HTTPS_PROXY=http://username:password@proxy.example.com:8080
```
The `CUSTOM_DNS` setting supports two formats:
1. **Custom DNS Servers**: A comma-separated list of DNS server IP addresses
- Example: `127.0.0.53,127.0.1.53` (useful for PiHole)
- Supports both IPv4 and IPv6 addresses in the same string
2. **Preset DNS Providers**: Use one of these predefined options:
- `google` - Google DNS
- `quad9` - Quad9 DNS
- `cloudflare` - Cloudflare DNS
- `opendns` - OpenDNS
For users experiencing ISP-level website blocks (such as Virgin Media in the UK), using alternative DNS providers like Cloudflare may help bypass these restrictions
If a `CUSTOM_DNS` is specified from the preset providers, you can also set a `USE_DOH=true` to force using DNS over HTTPS,
which might also help in certain network situations. Note that only `google`, `quad9`, `cloudflare` and `opendns` are
supported for now, and any other value in `CUSTOM_DNS` will make the `USE_DOH` flag ignored.
Try something like this :
```bash
CUSTOM_DNS=cloudflare
USE_DOH=true
```
#### Custom configuration
| Variable | Description | Default Value |
| ---------------------- | ----------------------------------------------------------- | ----------------------- |
| `CUSTOM_SCRIPT` | Path to an executable script that tuns after each download | `` |
If `CUSTOM_SCRIPT` is set, it will be executed after each successful download but before the file is moved to the ingest directory. This allows for custom processing like format conversion or validation.
The script is called with the full path of the downloaded file as its argument. Important notes:
- The script must preserve the original filename for proper processing
- The file can be modified or even deleted if needed
- The file will be moved to `/cwa-book-ingest` after the script execution (if not deleted)
You can specify these configuration in this format :
```
environment:
- CUSTOM_SCRIPT=/scripts/process-book.sh
volumes:
- local/scripts/custom_script.sh:/scripts/process-book.sh
```
### Volume Configuration
### Volume Setup
```yaml
volumes:
- /your/local/path:/cwa-book-ingest
- /cwa/config/path/app.db:/auth/app.db:ro
- /your/config/path:/config # Config, database, and artwork cache directory
- /your/download/path:/books # Downloaded books
- /client/path:/client/path # Optional: For Torrent/Usenet downloads, match your client directory exactly.
```
**Note** - If your library volume is on a cifs share, you will get a "database locked" error until you add **nobrl** to your mount line in your fstab file. e.g. //192.168.1.1/Books /media/books cifs credentials=.smbcredentials,uid=1000,gid=1000,iocharset=utf8,**nobrl** - See https://github.com/crocodilestick/Calibre-Web-Automated/issues/64#issuecomment-2712769777
Mount should align with your Calibre-Web-Automated ingest folder.
> **Tip**: Point the download volume to your CWA or Grimmory ingest folder for automatic import.
## Variants:
> **Note**: CIFS shares require `nobrl` mount option to avoid database lock errors.
### 🧅 Tor Variant
### Non-root container mode
This application also offers a variant that routes all its traffic through the Tor network. This can be useful for enhanced privacy or bypassing network restrictions.
- Start the container as `1000:1000` with Docker `user: "1000:1000"` or `docker run --user 1000:1000`.
- For Kubernetes, set `runAsUser: 1000`, `runAsGroup: 1000`, and `runAsNonRoot: true` together.
- `PUID`/`PGID` keep the default root startup flow.
- Mounted paths must already be writable by `1000:1000`.
- `USING_TOR=true` requires root startup.
To use the Tor variant:
## ⚙️ Configuration
1. Get the Tor-specific docker-compose file:
```bash
curl -O https://raw.githubusercontent.com/calibrain/calibre-web-automated-book-downloader/refs/heads/main/docker-compose.tor.yml
```
2. Start the service using this file:
```bash
docker compose -f docker-compose.tor.yml up -d
```
### Search Modes
**Important Considerations for Tor:**
**Direct**
- Queries configured sources directly
* **Capabilities:** This variant requires the `NET_ADMIN` and `NET_RAW` Docker capabilities to configure `iptables` for transparent Tor proxying.
* **Timezone:** When running in Tor mode, the container will attempt to determine the timezone based on the Tor exit node's IP address and set it automatically. This will override the `TZ` environment variable if it is set.
* **Network Settings:** Custom DNS, DoH, and HTTP(S) proxy settings (`CUSTOM_DNS`, `USE_DOH`, `HTTP_PROXY`, `HTTPS_PROXY`) are ignored when using the Tor variant, as all traffic goes through Tor.
**Universal** (recommended)
- Search via metadata providers (Hardcover, Open Library, Google Books) for richer results
- Aggregates releases from multiple configured sources
- Full audiobook support
### External Cloudflare resolver variant
### Environment Variables
This variant allows the application to use an external service to bypass Cloudflare protection, instead of relying on the built-in bypasser. This is useful if you already have a dedicated Cloudflare resolver (such as [FlareSolverr](https://github.com/FlareSolverr/FlareSolverr) or compatible services like [ByParr](https://github.com/ThePhaseless/Byparr)) running elsewhere.
Environment variables work for initial setup and Docker deployments. They serve as defaults that can be overridden in the web interface.
#### How it works:
| Variable | Description | Default |
|----------|-------------|---------|
| `FLASK_PORT` | Web interface port | `8084` |
| `INGEST_DIR` | Book download directory | `/books` |
| `TZ` | Container timezone | `UTC` |
| `PUID` / `PGID` | Runtime user/group for the default root-startup flow (also supports legacy `UID`/`GID`) | `1000` / `1000` |
| `SEARCH_MODE` | `direct` or `universal` | `universal` |
| `USING_TOR` | Enable Tor routing (requires root startup) | `false` |
- When enabled, all requests that require Cloudflare bypass are sent to your external resolver service.
- The application communicates with the resolver using its API.
- This approach can improve reliability and performance, especially if your external resolver is optimized or shared across multiple applications.
See the full [Environment Variables Reference](docs/environment-variables.md) for all available options.
#### Configuration
Some of the additional options available in Settings:
- **Prowlarr** - Configure indexers and download clients to download books and audiobooks
- **Additional audiobook sources** - Configure additional sources for audiobook discovery
- **IRC** - Add details for IRC book sources and download directly from the UI
- **Library Link** - Add a link to your Calibre-Web or Grimmory instance in the UI header
- **File processing** - Customiseable download paths, file renaming and directory creation with template-based renaming
- **Network Settings** - Custom proxy support (SOCKS5 + HTTP/S) and configurable DNS
- **Format & Language** - Filter downloads by preferred formats, languages and sorting order
- **Metadata Providers** - Configure API keys for Hardcover, Open Library, etc.
| Variable | Description | Default Value |
| ---------------------- | ----------------------------------------------------------- | ----------------------- |
| `EXT_BYPASSER_URL` | The full URL of your external resolver (required) | |
| `EXT_BYPASSER_PATH` | API path for the resolver (usually `/v1`) | `/v1` |
| `EXT_BYPASSER_TIMEOUT` | Timeout for page loading (in milliseconds) | `60000` |
## 🐳 Docker Variants
#### Important
This feature follows the same configuration of the built-in Cloudflare bypasser, so you should turn on the `USE_CF_BYPASS` configuration to enable it.
#### To use the External Cloudflare resolver variant:
1. Get the extbp-specific docker-compose file:
```bash
curl -O https://raw.githubusercontent.com/calibrain/calibre-web-automated-book-downloader/refs/heads/main/docker-compose.extbp.yml
```
2. Start the service using this file:
```bash
docker compose -f docker-compose.extbp.yml up -d
```
#### Compatibility:
This feature is designed to work with any resolver that implements the `FlareSolverr` API schema, including `ByParr` and similar projects.
#### Benefits:
- Centralizes Cloudflare bypass logic for easier maintenance.
- Can leverage more powerful or distributed resolver infrastructure.
- Reduces load on the main application container.
## 🏗️ Architecture
The application consists of a Flask backend with a React-based frontend:
### Backend
- **Flask Application**: Python-based backend (`app.py`, `backend.py`) providing REST API and WebSocket support
- **Download Manager**: Handles book search, download requests, and queue management (`downloader.py`, `book_manager.py`)
- **Network Layer**: Cloudflare bypass and proxy support (`cloudflare_bypasser.py`, `network.py`)
### Frontend
- **React + TypeScript**: Modern web interface built with Vite (`src/frontend`)
- **Real-time Updates**: WebSocket integration for live download status
- **Responsive UI**: TailwindCSS-based design for mobile and desktop
For frontend development, use the provided Makefile:
### Standard
```bash
make install # Install dependencies
make dev # Start development server
make build # Build for production
```
If you run the docker compose file, the frontend will be built and served automatically. But if you run the frontend dev server it will supercede the docker compose frontend.
## 🏥 Health Monitoring
Built-in health checks monitor:
- Web interface availability
- Download service status
- Cloudflare bypass service connection
Checks run every 30 seconds with a 30-second timeout and 3 retries.
You can enable by adding this to your compose :
```
HEALTHCHECK --interval=30s --timeout=30s --start-period=5s --retries=3 \
CMD curl -s http://localhost:8084/api/status || exit 1
docker compose up -d
```
## 📝 Logging
The full-featured image with all network capabilities included.
Logs are available in:
#### Tor Routing
Optional Tor support for network privacy:
```bash
curl -O https://raw.githubusercontent.com/calibrain/shelfmark/main/compose/docker-compose.tor.yml
docker compose -f docker-compose.tor.yml up -d
```
- Container: `/var/logs/cwa-book-downloader.log`
- Docker logs: Access via `docker logs`
**Notes:**
- Requires root startup
- Requires `NET_ADMIN` and `NET_RAW` capabilities
- Timezone is auto-detected from Tor exit node
- Custom DNS/proxy settings are ignored when Tor is active
## 🤝 Contributing
### Lite
A lighter image without the built-in browser automation. Ideal for:
Contributions are welcome! Feel free to submit a Pull Request.
- **External services** - Already running FlareSolverr or similar for other applications
- **Alternative sources** - Using Prowlarr, IRC, or other configured sources
- **Audiobooks** - Using Shelfmark primarily for audiobooks
## 📄 License
```bash
curl -O https://raw.githubusercontent.com/calibrain/shelfmark/main/compose/docker-compose.lite.yml
docker compose -f docker-compose.lite.yml up -d
```
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
If you need browser-based access with the Lite image, configure an external resolver in Settings.
## ⚠️ Important Disclaimers
## 🔐 Authentication
### Copyright Notice
Authentication is optional but recommended for shared or exposed instances. Multiple authentication methods are available in Settings:
While this tool can access various sources including those that might contain copyrighted material (e.g., Anna's Archive), it is designed for legitimate use only. Users are responsible for:
**1. Single Username/Password**
- Ensuring they have the right to download requested materials
- Respecting copyright laws and intellectual property rights
- Using the tool in compliance with their local regulations
**2. Proxy (Forward) Authentication**
### Duplicate Downloads Warning
Proxy auth trusts headers set by your reverse proxy (e.g. `X-Auth-User`). Ensure Shelfmark is not directly exposed, and configure your proxy to strip/overwrite these headers for all inbound requests.
Please note that the current version:
**3. OIDC (OpenID Connect)**
- Does not check for existing files in the download directory
- Does not verify if books already exist in your Calibre database
- Exercise caution when requesting multiple books to avoid duplicates
Integrate with your identity provider (Authelia, Authentik, Keycloak, etc.) for single sign-on. Supports PKCE flow, auto-discovery, group-based admin mapping, and auto-provisioning of new users.
## 💬 Support
**4. Calibre-Web Database**
For issues or questions, please file an issue on the GitHub repository.
If you're running Calibre-Web, you can reuse its user database by mounting it:
```yaml
volumes:
- /path/to/calibre-web/app.db:/auth/app.db:ro
```
### Multi-User Support
With any authentication method enabled, Shelfmark supports multi-user management with admin/user roles. Users can have per-user settings for download destinations, email recipients, and notification preferences. Non-admin users only see their own downloads and can submit book requests for admin review. Admins can configure request policies per source to control whether users can download directly, must submit a request, or are blocked entirely.
## Project Scope
Shelfmark is a manual search and download tool, the entry point to your book library, not a library manager. It finds books, downloads them, and sends them to a configured destination. That's the full scope.
Shelfmark intentionally does not:
- **Track or manage your library** - it doesn't know or care what you already own
- **Integrate with library software** - what happens after delivery is up to your library tool
- **Monitor authors, series, or new releases** - there is no background automation
- **Queue future downloads** - if a book isn't available now, Shelfmark won't watch for it
These are non-goals, not missing features.
## Contributing
Shelfmark's core feature set is complete. Development focuses on stability, bug fixes, quality-of-life improvements, and refining the search experience. Contributions in these areas are welcome, please file issues or submit pull requests on GitHub.
Feature requests that fall outside the project scope (library integration, automation, collection management) will be closed. If you're unsure whether something fits, open a discussion first.
## Health Monitoring
The application exposes a health endpoint at `/api/health` (no authentication required). Add a health check to your compose:
```yaml
healthcheck:
test: ["CMD", "curl", "-sf", "http://localhost:8084/api/health"]
interval: 30s
timeout: 30s
retries: 3
```
## Logging
Logs are available via:
- `docker logs <container-name>`
- `/var/log/shelfmark/` inside the container (when `ENABLE_LOGGING=true`)
Log level is configurable via Settings or `LOG_LEVEL` environment variable.
## Development
```bash
# Quality checks
make checks # Run ALL static analysis (frontend + Python)
make python-checks # Run Ruff, BasedPyright, and Vulture
make install-python-dev # Sync Python runtime + dev tools with uv
# Frontend development
make install # Install dependencies
make dev # Start Vite dev server (localhost:5173)
make build # Production build
make frontend-typecheck # TypeScript checks
# Backend (Docker)
make up # Start backend via docker-compose.dev.yml
make down # Stop services
make refresh # Rebuild and restart
make restart # Restart container
```
The frontend dev server proxies to the backend on port 8084.
## License
MIT License - see [LICENSE](LICENSE) for details.
## ⚠️ Disclaimer
Shelfmark is a search interface that displays results from external metadata providers and sources. It does not host, store, or distribute any content. The developers are not responsible for how the tool is used or what is accessed through it.
Users are solely responsible for:
- Ensuring they have the legal right to download any material they access
- Complying with copyright laws and intellectual property rights in their jurisdiction
- Understanding and accepting the terms of any sources they configure
Use of this tool is entirely at your own risk.
## Support
For issues or questions, please [file an issue](https://github.com/calibrain/shelfmark/issues) on GitHub.
-13
View File
@@ -1,13 +0,0 @@
flask
flask-cors
flask-socketio
python-socketio
requests[socks]
beautifulsoup4
tqdm
dnspython
gunicorn
gevent
gevent-websocket
psutil
emoji
-4
View File
@@ -1,4 +0,0 @@
pyvirtualdisplay
pyautogui
seleniumbase>=4.41.1
python-xlib
+246
View File
@@ -0,0 +1,246 @@
#!/usr/bin/env bash
set -euo pipefail
LATEST_IMAGE="${LATEST_IMAGE:-ghcr.io/calibrain/shelfmark:latest}"
LEGACY_IMAGE="${LEGACY_IMAGE:-ghcr.io/calibrain/shelfmark:v1.0.2}"
WAIT_SECONDS="${WAIT_SECONDS:-5}"
STARTUP_TIMEOUT_SECONDS="${STARTUP_TIMEOUT_SECONDS:-120}"
require_cmd() {
command -v "$1" >/dev/null 2>&1 || {
echo "Missing required command: $1" >&2
exit 1
}
}
cleanup() {
local name="$1"
docker rm -f "$name" >/dev/null 2>&1 || true
}
wait_for_startup() {
local name="$1"
local elapsed=0
while [ "$elapsed" -lt "$STARTUP_TIMEOUT_SECONDS" ]; do
if ! docker inspect "$name" >/dev/null 2>&1; then
echo "Container $name no longer exists" >&2
return 1
fi
if [ "$(docker inspect -f '{{.State.Status}}' "$name" 2>/dev/null)" != "running" ]; then
echo "Container $name exited before startup completed" >&2
docker logs --tail 120 "$name" 2>&1 || true
return 1
fi
if docker exec "$name" sh -lc "getent passwd 1000 >/dev/null 2>&1 && ps -eo comm,args | awk '\$1 == \"gunicorn\" && index(\$0, \"shelfmark.main:app\") { found=1 } END { exit(found ? 0 : 1) }'" >/dev/null 2>&1; then
return 0
fi
sleep 1
elapsed=$((elapsed + 1))
done
echo "Timed out waiting for $name to finish startup" >&2
docker logs --tail 120 "$name" 2>&1 || true
return 1
}
start_container() {
local name="$1"
local image="$2"
local pre_entrypoint_script="${3:-}"
cleanup "$name"
if [ -n "$pre_entrypoint_script" ]; then
docker run -d \
--name "$name" \
--entrypoint sh \
-e PUID=1000 \
-e PGID=1000 \
-e TZ=UTC \
"$image" \
-lc "$pre_entrypoint_script
exec /app/entrypoint.sh" >/dev/null
else
docker run -d \
--name "$name" \
-e PUID=1000 \
-e PGID=1000 \
-e TZ=UTC \
"$image" >/dev/null
sleep "$WAIT_SECONDS"
fi
wait_for_startup "$name"
}
run_probe() {
local name="$1"
local mode="${2:-default}"
docker exec -u 1000:1000 -e PROBE_MODE="$mode" "$name" sh -lc 'python3 - <<'"'"'PY'"'"'
import asyncio
import os
import shelfmark.bypass.internal_bypasser as ib
async def run_probe():
driver = None
probe_mode = os.environ.get("PROBE_MODE", "default")
if probe_mode == "proxy_auth" and hasattr(ib, "_get_proxy_string"):
ib._get_proxy_string = lambda _url: "user:pass@127.0.0.1:8888"
if hasattr(ib, "_create_cdp_browser"):
try:
driver = await ib._create_cdp_browser("https://example.com")
profile = getattr(getattr(driver, "config", None), "user_data_dir", "")
print(f"PROBE=OK mode={probe_mode} fn=_create_cdp_browser profile={profile}")
except Exception as e:
print(f"PROBE=ERR mode={probe_mode} fn=_create_cdp_browser type={type(e).__name__} msg={e}")
finally:
if driver and hasattr(ib, "_close_cdp_driver"):
await ib._close_cdp_driver(driver)
return
if hasattr(ib, "_create_driver"):
try:
driver = await ib._create_driver()
print(f"PROBE=OK mode={probe_mode} fn=_create_driver driver_type={type(driver).__name__}")
except Exception as e:
print(f"PROBE=ERR mode={probe_mode} fn=_create_driver type={type(e).__name__} msg={e}")
finally:
if driver and hasattr(ib, "_quit_driver"):
await ib._quit_driver(driver)
return
print(f"PROBE=ERR mode={probe_mode} fn=unknown type=RuntimeError msg=no supported startup function found")
asyncio.run(run_probe())
PY'
}
show_logs() {
local name="$1"
docker logs --tail 80 "$name" 2>&1 | tail -n 20
}
scenario_latest_baseline() {
local name="sb-lab-latest-baseline"
echo
echo "== latest baseline =="
start_container "$name" "$LATEST_IMAGE"
run_probe "$name"
cleanup "$name"
}
scenario_latest_drivers_readonly() {
local name="sb-lab-latest-drivers"
echo
echo "== latest drivers readonly =="
start_container "$name" "$LATEST_IMAGE" '
chown -R root:root /usr/local/lib/python3.10/site-packages/seleniumbase/drivers &&
chmod -R a-w /usr/local/lib/python3.10/site-packages/seleniumbase/drivers &&
ls -ld /usr/local/lib/python3.10/site-packages/seleniumbase/drivers
'
run_probe "$name"
cleanup "$name"
}
scenario_latest_proxy_auth_baseline() {
local name="sb-lab-latest-proxy-baseline"
echo
echo "== latest proxy auth baseline =="
start_container "$name" "$LATEST_IMAGE"
run_probe "$name" "proxy_auth"
cleanup "$name"
}
scenario_latest_downloads_readonly() {
local name="sb-lab-latest-downloads"
echo
echo "== latest downloaded_files readonly =="
start_container "$name" "$LATEST_IMAGE" '
mkdir -p /app/downloaded_files &&
touch /app/downloaded_files/pipfinding.lock /app/downloaded_files/proxy_dir.lock &&
chown -R root:root /app/downloaded_files &&
chmod -R a-w /app/downloaded_files &&
find /app/downloaded_files -maxdepth 2 -printf "%M %u:%g %p\n"
'
run_probe "$name"
show_logs "$name"
cleanup "$name"
}
scenario_latest_proxy_auth_downloads_readonly() {
local name="sb-lab-latest-proxy-downloads"
echo
echo "== latest proxy auth with readonly downloaded_files =="
start_container "$name" "$LATEST_IMAGE" '
mkdir -p /app/downloaded_files &&
touch /app/downloaded_files/pipfinding.lock /app/downloaded_files/proxy_dir.lock &&
chown 1000:1000 /app/downloaded_files/pipfinding.lock /app/downloaded_files/proxy_dir.lock &&
chmod 0666 /app/downloaded_files/pipfinding.lock /app/downloaded_files/proxy_dir.lock &&
chown root:root /app/downloaded_files &&
chmod 0555 /app/downloaded_files &&
ls -ld /app/downloaded_files &&
ls -la /app/downloaded_files
'
run_probe "$name" "proxy_auth"
show_logs "$name"
cleanup "$name"
}
scenario_latest_bind_mount_readonly() {
local name="sb-lab-latest-bind-ro"
local bind_dir
bind_dir="$(mktemp -d /tmp/sb-lab-bind.XXXXXX)"
echo
echo "== latest readonly bind mount for downloaded_files =="
chmod 0555 "$bind_dir"
cleanup "$name"
docker run -d \
--name "$name" \
-e PUID=1000 \
-e PGID=1000 \
-e TZ=UTC \
--mount "type=bind,src=${bind_dir},target=/app/downloaded_files,readonly" \
"$LATEST_IMAGE" >/dev/null
wait_for_startup "$name"
run_probe "$name"
show_logs "$name"
cleanup "$name"
rm -rf "$bind_dir"
}
scenario_legacy_drivers_readonly() {
local name="sb-lab-legacy-drivers"
echo
echo "== legacy drivers readonly =="
start_container "$name" "$LEGACY_IMAGE" '
chown -R root:root /usr/local/lib/python3.10/site-packages/seleniumbase/drivers &&
chmod -R a-w /usr/local/lib/python3.10/site-packages/seleniumbase/drivers &&
ls -ld /usr/local/lib/python3.10/site-packages/seleniumbase/drivers
'
run_probe "$name"
show_logs "$name"
cleanup "$name"
}
main() {
require_cmd docker
scenario_latest_baseline
scenario_latest_drivers_readonly
scenario_latest_proxy_auth_baseline
scenario_latest_downloads_readonly
scenario_latest_proxy_auth_downloads_readonly
scenario_latest_bind_mount_readonly
scenario_legacy_drivers_readonly
}
main "$@"
+431
View File
@@ -0,0 +1,431 @@
#!/usr/bin/env python3
"""Generate markdown documentation for environment variables from the settings registry.
This script extracts all settings that support environment variable configuration
and generates a comprehensive markdown file documenting each option.
Usage:
python scripts/generate_env_docs.py [--output path/to/output.md]
The generated documentation includes:
- Environment variable name
- Description
- Type (string, number, boolean, etc.)
- Default value
- Organizational grouping by settings tab/group
"""
import argparse
import sys
from pathlib import Path
from typing import Any
# Add project root to path
project_root = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(project_root))
def get_field_type_name(field: Any) -> str:
"""Get a human-readable type name for a field."""
from shelfmark.core.settings_registry import (
CheckboxField,
MultiSelectField,
NumberField,
OrderableListField,
PasswordField,
SelectField,
TagListField,
TextField,
)
if isinstance(field, CheckboxField):
return "boolean"
if isinstance(field, NumberField):
return "number"
if isinstance(field, SelectField):
return "string (choice)"
if isinstance(field, MultiSelectField):
return "string (comma-separated)"
if isinstance(field, TagListField):
return "string (comma-separated)"
if isinstance(field, OrderableListField):
return "JSON array"
if isinstance(field, PasswordField):
return "string (secret)"
if isinstance(field, TextField):
return "string"
return "string"
def format_default_value(field: Any) -> str:
"""Format the default value for display."""
default = field.default
if default is None:
return "_none_"
if isinstance(default, bool):
return f"`{str(default).lower()}`"
if isinstance(default, (int, float)):
return f"`{default}`"
if isinstance(default, str):
if default == "":
return "_empty string_"
return f"`{default}`"
if isinstance(default, list):
if not default:
return "_empty list_"
# For simple lists, show comma-separated values
if all(isinstance(item, str) for item in default):
return f"`{','.join(default)}`"
# For complex lists (e.g., OrderableListField defaults), summarize
return "_see UI for defaults_"
return f"`{default}`"
def get_select_options(field: Any) -> list[str] | None:
"""Get the available options for a SelectField.
Returns options formatted as 'value (label)' or just 'value' if they match,
so users know the actual values to use in environment variables.
"""
from shelfmark.core.settings_registry import SelectField
if not isinstance(field, SelectField):
return None
options = field.options
if callable(options):
try:
options = options()
except Exception:
return None
if not options:
return None
result = []
for opt in options:
value = opt.get("value", "")
label = opt.get("label", "")
# Format as "value (label)" unless they're the same or value is empty
if value == "":
result.append(f'`""` ({label})')
elif value == label or not label:
result.append(f"`{value}`")
else:
result.append(f"`{value}` ({label})")
return result
def _generate_bootstrap_env_docs() -> list[str]:
"""Generate documentation for bootstrap environment variables from env.py."""
# These are environment variables defined in env.py that are used before
# the settings registry is available
bootstrap_vars = [
{
"name": "CONFIG_DIR",
"description": "Directory for storing configuration files and plugin settings.",
"type": "string (path)",
"default": "/config",
},
{
"name": "LOG_ROOT",
"description": "Root directory for log files.",
"type": "string (path)",
"default": "/var/log/",
},
{
"name": "TMP_DIR",
"description": "Staging directory for downloads before moving to destination.",
"type": "string (path)",
"default": "/tmp/shelfmark",
},
{
"name": "ENABLE_LOGGING",
"description": "Enable file logging under LOG_ROOT/shelfmark/ (including shelfmark.log and startup logs).",
"type": "boolean",
"default": "true",
},
{
"name": "FLASK_HOST",
"description": "Host address for the Flask web server.",
"type": "string",
"default": "0.0.0.0",
},
{
"name": "FLASK_PORT",
"description": "Port number for the Flask web server.",
"type": "number",
"default": "8084",
},
{
"name": "SESSION_COOKIE_SECURE",
"description": "Enable secure cookies (requires HTTPS).",
"type": "boolean",
"default": "false",
},
{
"name": "CWA_DB_PATH",
"description": "Path to the Calibre-Web database for authentication integration.",
"type": "string (path)",
"default": "/auth/app.db",
},
{
"name": "DOCKERMODE",
"description": "Indicates the application is running inside a Docker container.",
"type": "boolean",
"default": "false",
},
{
"name": "ONBOARDING",
"description": "Show the onboarding wizard on first run. Set to false to skip (useful for ephemeral storage).",
"type": "boolean",
"default": "true",
},
]
lines = [
"## Bootstrap Configuration",
"",
"These environment variables are used at startup before the settings system loads. They typically configure paths and server settings.",
"",
"| Variable | Description | Type | Default |",
"|----------|-------------|------|---------|",
]
lines.extend(
f"| `{var['name']}` | {var['description']} | {var['type']} | `{var['default']}` |"
for var in bootstrap_vars
)
lines.append("")
lines.append("<details>")
lines.append("<summary>Detailed descriptions</summary>")
lines.append("")
for var in bootstrap_vars:
lines.append(f"#### `{var['name']}`")
lines.append("")
lines.append(var["description"])
lines.append("")
lines.append(f"- **Type:** {var['type']}")
lines.append(f"- **Default:** `{var['default']}`")
lines.append("")
lines.append("</details>")
lines.append("")
return lines
def generate_env_docs() -> str:
"""Generate markdown documentation for all environment variables."""
# Import settings modules to ensure all settings are registered
import shelfmark.config.security
import shelfmark.config.settings
import shelfmark.metadata_providers.googlebooks
import shelfmark.metadata_providers.hardcover
import shelfmark.metadata_providers.openlibrary
import shelfmark.release_sources.irc.settings
import shelfmark.release_sources.prowlarr.settings # noqa: F401
from shelfmark.core.settings_registry import (
get_all_groups,
get_all_settings_tabs,
)
tabs = get_all_settings_tabs()
groups = {g.name: g for g in get_all_groups()}
# Organize tabs by group
grouped_tabs: dict[str | None, list] = {None: []}
for group_name in groups:
grouped_tabs[group_name] = []
for tab in tabs:
group_name = tab.group
if group_name not in grouped_tabs:
grouped_tabs[group_name] = []
grouped_tabs[group_name].append(tab)
# Build markdown output
lines = [
"# Environment Variables",
"",
"This document lists all configuration options that can be set via environment variables.",
"",
"> **Auto-generated** - Do not edit manually. Run `python scripts/generate_env_docs.py` to regenerate.",
"",
"## Table of Contents",
"",
]
# Generate TOC
toc_entries = [
"- [Bootstrap Configuration](#bootstrap-configuration)",
]
# Ungrouped tabs first
for tab in grouped_tabs.get(None, []):
anchor = tab.display_name.lower().replace(" ", "-")
toc_entries.append(f"- [{tab.display_name}](#{anchor})")
# Then grouped tabs
for group_name, group in groups.items():
group_tabs = grouped_tabs.get(group_name, [])
if group_tabs:
anchor = group.display_name.lower().replace(" ", "-")
toc_entries.append(f"- [{group.display_name}](#{anchor})")
for tab in group_tabs:
sub_anchor = f"{group.display_name}-{tab.display_name}".lower().replace(" ", "-")
toc_entries.append(f" - [{tab.display_name}](#{sub_anchor})")
lines.extend(toc_entries)
lines.append("")
lines.append("---")
lines.append("")
# Add bootstrap environment variables documentation
lines.extend(_generate_bootstrap_env_docs())
# Generate documentation for ungrouped tabs
for tab in grouped_tabs.get(None, []):
lines.extend(_generate_tab_docs(tab))
# Generate documentation for grouped tabs
for group_name, group in groups.items():
group_tabs = grouped_tabs.get(group_name, [])
if not group_tabs:
continue
lines.append(f"## {group.display_name}")
lines.append("")
for tab in group_tabs:
lines.extend(_generate_tab_docs(tab, group_prefix=group.display_name))
return "\n".join(lines)
def _generate_tab_docs(tab: Any, group_prefix: str | None = None) -> list[str]:
"""Generate documentation for a single settings tab."""
from shelfmark.core.settings_registry import iter_value_fields
lines = []
# Section header
if group_prefix:
lines.append(f"### {group_prefix}: {tab.display_name}")
else:
lines.append(f"## {tab.display_name}")
lines.append("")
# Collect env-supported fields
env_fields = [
field for field in iter_value_fields(tab) if getattr(field, "env_supported", True)
]
if not env_fields:
lines.append("_No environment variables for this section._")
lines.append("")
return lines
# Generate table
lines.append("| Variable | Description | Type | Default |")
lines.append("|----------|-------------|------|---------|")
for field in env_fields:
env_var = field.get_env_var_name()
description = field.description or field.label
# Clean up description for table (remove newlines, escape pipes)
description = description.replace("\n", " ").replace("|", "\\|").strip()
field_type = get_field_type_name(field)
default = format_default_value(field)
lines.append(f"| `{env_var}` | {description} | {field_type} | {default} |")
lines.append("")
# Add detailed documentation for each field
lines.append("<details>")
lines.append("<summary>Detailed descriptions</summary>")
lines.append("")
for field in env_fields:
env_var = field.get_env_var_name()
lines.append(f"#### `{env_var}`")
lines.append("")
lines.append(f"**{field.label}**")
lines.append("")
if field.description:
lines.append(field.description)
lines.append("")
lines.append(f"- **Type:** {get_field_type_name(field)}")
lines.append(f"- **Default:** {format_default_value(field)}")
if getattr(field, "required", False):
lines.append("- **Required:** Yes")
if getattr(field, "requires_restart", False):
lines.append("- **Requires restart:** Yes")
# Show options for SelectField
options = get_select_options(field)
if options:
lines.append(f"- **Options:** {', '.join(options)}")
# Show constraints for NumberField
from shelfmark.core.settings_registry import NumberField
if isinstance(field, NumberField):
constraints = []
if field.min_value is not None:
constraints.append(f"min: {field.min_value}")
if field.max_value is not None:
constraints.append(f"max: {field.max_value}")
if constraints:
lines.append(f"- **Constraints:** {', '.join(constraints)}")
lines.append("")
lines.append("</details>")
lines.append("")
return lines
def main() -> None:
parser = argparse.ArgumentParser(
description="Generate markdown documentation for environment variables"
)
parser.add_argument(
"--output",
"-o",
type=Path,
default=project_root / "docs" / "environment-variables.md",
help="Output file path (default: docs/environment-variables.md)",
)
parser.add_argument(
"--stdout",
action="store_true",
help="Print to stdout instead of file",
)
args = parser.parse_args()
docs = generate_env_docs()
if args.stdout:
print(docs)
else:
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(docs)
print(f"Generated: {args.output}")
if __name__ == "__main__":
main()
+566
View File
@@ -0,0 +1,566 @@
#!/usr/bin/env python3
"""
Test script for download client implementations.
Usage:
1. Start the test stack:
docker compose -f docker-compose.test-clients.yml up -d
2. Wait for containers to initialize (first run takes ~30s)
3. Run this script to verify clients are accessible:
uv run python scripts/test_clients.py
4. Access cwabd at http://localhost:8084
- Go to Settings > Prowlarr > Download Clients
- Select a client from the dropdown
- Click "Test Connection" to verify
Web UIs:
- cwabd: http://localhost:8084
- qBittorrent: http://localhost:8080
- Transmission: http://localhost:9091
- Deluge: http://localhost:8112
- NZBGet: http://localhost:6789
- SABnzbd: http://localhost:8085
- rTorrent: http://localhost:8000 (web ui http://localhost:8089 via ruTorrent)
Prerequisites (for running this script locally):
uv sync --locked
First-Time Setup:
qBittorrent:
- Check container logs for temporary password: docker logs test-qbittorrent
- Login at http://localhost:8080, change password to something known
- Default username is 'admin'
Transmission:
- No setup needed, credentials pre-configured (admin/admin)
Deluge:
- Access Web UI at http://localhost:8112 (default password: deluge)
NZBGet:
- No setup needed, credentials pre-configured (admin/admin)
SABnzbd:
- Complete the setup wizard at http://localhost:8085
- API key will be auto-detected by this script
- In cwabd, copy API key from SABnzbd Config > General
"""
import sys
import time
from pathlib import Path
from typing import Any
# Test configuration - matches docker-compose.test-clients.yml
CONFIG = {
# Usenet clients
"nzbget": {
"url": "http://localhost:6789",
"username": "admin",
"password": "admin",
},
"sabnzbd": {
"url": "http://localhost:8085",
"api_key": None, # Will be read from config on first run
},
# Torrent clients
"qbittorrent": {
"url": "http://localhost:8080",
"username": "admin",
"password": "5NCngsHXm", # Temp password from: docker logs test-qbittorrent | grep password
},
"transmission": {
"url": "http://localhost:9091",
"username": "admin",
"password": "admin",
},
"deluge": {
"url": "http://localhost:8112",
"password": "deluge",
},
"rtorrent": {
"url": "http://localhost:8000/RPC2",
},
}
# Test magnet link (Ubuntu ISO - legal, small metadata)
TEST_MAGNET = "magnet:?xt=urn:btih:3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0&dn=ubuntu-22.04.3-live-server-amd64.iso"
def test_nzbget() -> bool:
"""Test NZBGet connection."""
import requests
print("\n" + "=" * 50)
print("Testing NZBGet")
print("=" * 50)
url = CONFIG["nzbget"]["url"]
username = CONFIG["nzbget"]["username"]
password = CONFIG["nzbget"]["password"]
try:
# Test connection via JSON-RPC
rpc_url = f"{url}/jsonrpc"
response = requests.post(
rpc_url,
json={"method": "version", "params": []},
auth=(username, password),
timeout=10,
)
response.raise_for_status()
result = response.json()
version = result.get("result", "unknown")
print(f" Connected to NZBGet {version}")
# Test status
response = requests.post(
rpc_url,
json={"method": "status", "params": []},
auth=(username, password),
timeout=10,
)
status = response.json().get("result", {})
print(f" Server state: {'Paused' if status.get('ServerPaused') else 'Running'}")
print(f" Downloads in queue: {status.get('DownloadedSizeMB', 0)} MB downloaded")
print(" SUCCESS: NZBGet is working!")
return True
except requests.exceptions.ConnectionError:
print(" ERROR: Could not connect to NZBGet")
print(" Is the container running? docker ps | grep nzbget")
return False
except Exception as e:
print(f" ERROR: {e}")
return False
def test_sabnzbd() -> bool:
"""Test SABnzbd connection."""
import requests
print("\n" + "=" * 50)
print("Testing SABnzbd")
print("=" * 50)
url = CONFIG["sabnzbd"]["url"]
api_key = CONFIG["sabnzbd"]["api_key"]
# Try to get API key from config if not set
if not api_key:
try:
ini_path = Path(".local/test-clients/sabnzbd/config/sabnzbd.ini")
if ini_path.exists():
with ini_path.open() as f:
for line in f:
if line.startswith("api_key"):
api_key = line.split("=")[1].strip()
print(f" Found API key in config: {api_key[:8]}...")
break
except Exception as e:
print(f" Could not read API key from config: {e}")
if not api_key:
print(" ERROR: No API key configured")
print(" Please access http://localhost:8085 and complete initial setup")
print(" Then copy the API key from Config > General")
return False
try:
# Test connection
response = requests.get(
f"{url}/api",
params={"apikey": api_key, "mode": "version", "output": "json"},
timeout=10,
)
response.raise_for_status()
result = response.json()
version = result.get("version", "unknown")
print(f" Connected to SABnzbd {version}")
# Test queue status
response = requests.get(
f"{url}/api",
params={"apikey": api_key, "mode": "queue", "output": "json"},
timeout=10,
)
queue = response.json().get("queue", {})
print(f" Queue status: {queue.get('status', 'unknown')}")
print(f" Items in queue: {len(queue.get('slots', []))}")
print(" SUCCESS: SABnzbd is working!")
return True
except requests.exceptions.ConnectionError:
print(" ERROR: Could not connect to SABnzbd")
print(" Is the container running? docker ps | grep sabnzbd")
return False
except Exception as e:
print(f" ERROR: {e}")
return False
def test_qbittorrent() -> bool:
"""Test qBittorrent connection."""
print("\n" + "=" * 50)
print("Testing qBittorrent")
print("=" * 50)
try:
import qbittorrentapi
url = CONFIG["qbittorrent"]["url"]
username = CONFIG["qbittorrent"]["username"]
password = CONFIG["qbittorrent"]["password"]
# Parse URL for host/port
from urllib.parse import urlparse
parsed = urlparse(url)
client = qbittorrentapi.Client(
host=parsed.hostname,
port=parsed.port or 8080,
username=username,
password=password,
)
# Test connection
client.auth_log_in()
version = client.app.version
print(f" Connected to qBittorrent {version}")
# Get torrent list
torrents = client.torrents_info()
print(f" Active torrents: {len(torrents)}")
# Test adding a torrent (then remove it)
print(" Testing add/remove torrent...")
result = client.torrents_add(urls=TEST_MAGNET, is_paused=True)
if result == "Ok.":
# Wait a moment for it to be added
time.sleep(1)
torrents = client.torrents_info()
if torrents:
test_torrent = torrents[-1] # Most recently added
print(f" Added test torrent: {test_torrent.name[:50]}...")
print(f" Status: {test_torrent.state}")
# Remove it
client.torrents_delete(torrent_hashes=test_torrent.hash, delete_files=True)
print(" Removed test torrent")
else:
print(f" Add result: {result}")
print(" SUCCESS: qBittorrent is working!")
return True
except ImportError:
print(" ERROR: qbittorrent-api not installed")
print(" Run: uv sync --locked")
return False
except Exception as e:
print(f" ERROR: {e}")
if "Forbidden" in str(e) or "401" in str(e):
print("\n Authentication failed. Check password:")
print(" 1. docker logs test-qbittorrent | grep password")
print(" 2. Login to http://localhost:8080 and set a known password")
return False
def test_transmission() -> bool:
"""Test Transmission connection."""
print("\n" + "=" * 50)
print("Testing Transmission")
print("=" * 50)
try:
from urllib.parse import urlparse
from transmission_rpc import Client
url = CONFIG["transmission"]["url"]
parsed = urlparse(url)
client = Client(
host=parsed.hostname,
port=parsed.port or 9091,
username=CONFIG["transmission"]["username"],
password=CONFIG["transmission"]["password"],
)
# Test connection
session = client.get_session()
print(f" Connected to Transmission {session.version}")
# Get torrent list
torrents = client.get_torrents()
print(f" Active torrents: {len(torrents)}")
# Test adding a torrent (then remove it)
print(" Testing add/remove torrent...")
torrent = client.add_torrent(TEST_MAGNET, paused=True)
print(f" Added test torrent: {torrent.name[:50]}...")
# Get status
status = client.get_torrent(torrent.id)
print(f" Status: {status.status} ({status.percent_done * 100:.1f}%)")
# Remove it
client.remove_torrent(torrent.id, delete_data=True)
print(" Removed test torrent")
print(" SUCCESS: Transmission is working!")
return True
except ImportError:
print(" ERROR: transmission-rpc not installed")
print(" Run: uv sync --locked")
return False
except Exception as e:
print(f" ERROR: {e}")
return False
def test_deluge() -> bool:
"""Test Deluge Web UI (JSON-RPC) connection."""
import requests
print("\n" + "=" * 50)
print("Testing Deluge")
print("=" * 50)
base_url = CONFIG["deluge"]["url"].rstrip("/")
password = CONFIG["deluge"]["password"]
rpc_url = f"{base_url}/json"
def rpc_call(session: requests.Session, rpc_id: int, method: str, *params: Any) -> Any:
payload = {"id": rpc_id, "method": method, "params": list(params)}
resp = session.post(rpc_url, json=payload, timeout=10)
resp.raise_for_status()
data = resp.json()
if data.get("error"):
err = data["error"]
if isinstance(err, dict):
raise Exception(err.get("message") or str(err))
raise Exception(str(err))
return data.get("result")
try:
session = requests.Session()
# Authenticate to Deluge Web
if rpc_call(session, 1, "auth.login", password) is not True:
raise Exception("Authentication failed (check Deluge Web UI password)")
# Ensure Deluge Web is connected to a daemon
if rpc_call(session, 2, "web.connected") is not True:
hosts = rpc_call(session, 3, "web.get_hosts") or []
if not hosts:
raise Exception(
"Deluge Web UI isn't connected to Deluge core (no hosts configured). "
"Add/connect a daemon in Deluge Web UI → Connection Manager."
)
host_id = hosts[0][0]
for entry in hosts:
if (
isinstance(entry, list)
and len(entry) >= 2
and entry[1] in {"127.0.0.1", "localhost"}
):
host_id = entry[0]
break
rpc_call(session, 4, "web.connect", host_id)
if rpc_call(session, 5, "web.connected") is not True:
raise Exception(
"Deluge Web UI couldn't connect to Deluge core. "
"Check Deluge Web UI → Connection Manager."
)
version = rpc_call(session, 6, "daemon.info")
print(f" Connected to Deluge {version}")
torrents = rpc_call(session, 7, "core.get_torrents_status", {}, ["name"]) or {}
print(f" Active torrents: {len(torrents)}")
# Test adding a torrent (then remove it)
print(" Testing add/remove torrent...")
torrent_id = rpc_call(
session, 8, "core.add_torrent_magnet", TEST_MAGNET, {"add_paused": True}
)
if torrent_id:
torrent_id = str(torrent_id)
print(f" Added test torrent: {torrent_id[:20]}...")
status = (
rpc_call(session, 9, "core.get_torrent_status", torrent_id, ["state", "progress"])
or {}
)
state = status.get("state", "unknown") if isinstance(status, dict) else "unknown"
progress = status.get("progress", 0) if isinstance(status, dict) else 0
print(f" Status: {state} ({progress:.1f}%)")
rpc_call(session, 10, "core.remove_torrent", torrent_id, True)
print(" Removed test torrent")
else:
print(" WARNING: Could not add test torrent")
print(" SUCCESS: Deluge is working!")
return True
except requests.exceptions.ConnectionError:
print(" ERROR: Could not connect to Deluge Web UI")
print(" Is the container running? docker ps | grep deluge")
return False
except requests.exceptions.Timeout:
print(" ERROR: Deluge Web UI connection timed out")
return False
except Exception as e:
print(f" ERROR: {e}")
if "auth" in str(e).lower() or "login" in str(e).lower():
print(" Check Deluge Web UI password (default: deluge)")
return False
def test_rtorrent() -> bool:
"""Test rTorrent connection."""
print("\n" + "=" * 50)
print("Testing rTorrent")
print("=" * 50)
try:
import xmlrpc.client
url = "http://localhost:8000/RPC2"
client = xmlrpc.client.ServerProxy(url)
# Test connection
version = client.system.library_version()
print(f" Connected to rTorrent {version}")
# default download directory test
default_dir = client.directory.default()
print(f" Default download directory: {default_dir}")
# Get torrent list
torrents = client.download_list()
print(f" Active torrents: {len(torrents)}")
# Test adding a torrent (then remove it)
print(" Testing add/remove torrent...")
label = "automated"
commands = []
if label:
commands.append(f"d.custom1.set={label}")
download_dir = "/downloads"
if download_dir:
commands.append(f"d.directory_base.set={download_dir}")
# rtorrent is weird in that it doesn't return the torrent ID/hash on add
client.load.start("", TEST_MAGNET, ";".join(commands))
# but we know that it is 3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0 from the magnet link
torrent_id = "3B245504CF5F11BBDBE1201CEA6A6BF45AEE1BC0" # rtorrent uses uppercase hashes
print(f" Added test torrent: {torrent_id}")
torrents = client.download_list()
print(f" Active torrents: {len(torrents)}")
torrent_list = client.d.multicall.filtered(
"",
"default",
f"equal={{d.hash=,cat={torrent_id}}}d.hash=",
"d.state=",
"d.completed_bytes=",
"d.size_bytes=",
"d.down.rate=",
"d.up.rate=",
"d.custom1=",
"d.complete=",
)
torrent = torrent_list[0]
if not torrent:
print(" ERROR: Could not find added torrent in list")
return False
# let's test the base path call
details = client.d.multicall.filtered(
"",
"default",
f"equal=d.hash=,cat={torrent_id}",
"d.base_path=",
)
base_path = details[0][0] if details else None
print(f" Base path: {base_path}")
client.d.erase(torrent_id)
print(" Removed test torrent")
print(" SUCCESS: rTorrent is working!")
return True
except ImportError:
print(" ERROR: xmlrpc.client not available")
return False
except Exception as e:
print(f" ERROR: {e}")
if "Connection refused" in str(e):
print(" Is the container running? docker ps | grep rtorrent")
return False
def main() -> int:
print("Download Client Test Suite")
print("=" * 50)
print("Make sure containers are running:")
print(" docker compose -f docker-compose.test-clients.yml up -d")
results = {}
# Test usenet clients
print("\n" + "=" * 50)
print("USENET CLIENTS")
print("=" * 50)
results["nzbget"] = test_nzbget()
results["sabnzbd"] = test_sabnzbd()
# Test torrent clients
print("\n" + "=" * 50)
print("TORRENT CLIENTS")
print("=" * 50)
results["qbittorrent"] = test_qbittorrent()
results["transmission"] = test_transmission()
results["deluge"] = test_deluge()
results["rtorrent"] = test_rtorrent()
# Summary
print("\n" + "=" * 50)
print("SUMMARY")
print("=" * 50)
for client, success in results.items():
status = "PASS" if success else "FAIL"
print(f" {client}: {status}")
passed = sum(results.values())
total = len(results)
print(f"\n Total: {passed}/{total} passed")
return 0 if passed == total else 1
if __name__ == "__main__":
sys.exit(main())
+1
View File
@@ -0,0 +1 @@
"""Shelfmark - book search and download service."""
+23
View File
@@ -0,0 +1,23 @@
"""Package entry point for `python -m shelfmark`."""
from shelfmark.config.env import FLASK_HOST, FLASK_PORT
from shelfmark.core.config import config
from shelfmark.main import app, socketio
def _resolve_debug_flag(value: object) -> bool:
"""Normalize DEBUG config values for Flask-SocketIO startup."""
if isinstance(value, bool):
return value
if isinstance(value, str):
return value.strip().lower() in {"1", "true", "yes", "on"}
return bool(value)
if __name__ == "__main__":
socketio.run(
app,
host=FLASK_HOST,
port=FLASK_PORT,
debug=_resolve_debug_flag(config.get("DEBUG", False)),
)
+1
View File
@@ -0,0 +1 @@
"""API module - WebSocket handling."""
+218
View File
@@ -0,0 +1,218 @@
"""WebSocket manager for real-time status updates."""
from __future__ import annotations
import logging
import threading
from typing import TYPE_CHECKING, Any
from flask_socketio import SocketIO, join_room, leave_room
if TYPE_CHECKING:
from collections.abc import Callable
from flask import Flask
logger = logging.getLogger(__name__)
class WebSocketManager:
"""Manages WebSocket connections and broadcasts."""
def __init__(self) -> None:
"""Initialize in-memory connection and room tracking."""
self.socketio: SocketIO | None = None
self._enabled = False
self._connection_count = 0
self._connection_lock = threading.Lock()
self._user_rooms: dict[str, int] = {} # room_name -> ref count
self._sid_rooms: dict[str, str] = {} # sid -> room_name
self._rooms_lock = threading.Lock()
self._queue_status_fn: Callable | None = None # Reference to queue_status()
def init_app(self, app: Flask, socketio: SocketIO) -> None:
"""Initialize the WebSocket manager with Flask-SocketIO instance."""
self.socketio = socketio
self._enabled = True
logger.info("WebSocket manager initialized")
def client_connected(self) -> None:
"""Track a new client connection. Call this from the connect event handler."""
with self._connection_lock:
self._connection_count += 1
current_count = self._connection_count
logger.debug("Client connected. Active connections: %s", current_count)
def client_disconnected(self) -> None:
"""Track a client disconnection. Call this from the disconnect event handler."""
with self._connection_lock:
self._connection_count = max(0, self._connection_count - 1)
current_count = self._connection_count
logger.debug("Client disconnected. Active connections: %s", current_count)
def is_enabled(self) -> bool:
"""Check if WebSocket is enabled and ready."""
return self._enabled and self.socketio is not None
def _get_socketio(self) -> SocketIO | None:
if not self._enabled:
return None
return self.socketio
def set_queue_status_fn(self, fn: Callable) -> None:
"""Set the queue_status function reference for per-room filtering."""
self._queue_status_fn = fn
def _increment_user_room_locked(self, room: str) -> None:
self._user_rooms[room] = self._user_rooms.get(room, 0) + 1
def _decrement_user_room_locked(self, room: str) -> None:
count = self._user_rooms.get(room, 1) - 1
if count <= 0:
self._user_rooms.pop(room, None)
else:
self._user_rooms[room] = count
def _set_sid_room_locked(self, sid: str, room: str | None) -> None:
current_room = self._sid_rooms.get(sid)
if current_room == room:
return
if current_room is not None:
leave_room(current_room, sid=sid)
if current_room.startswith("user_"):
self._decrement_user_room_locked(current_room)
self._sid_rooms.pop(sid, None)
if room is not None:
join_room(room, sid=sid)
self._sid_rooms[sid] = room
if room.startswith("user_"):
self._increment_user_room_locked(room)
def sync_user_room(
self,
sid: str,
is_admin: bool,
db_user_id: int | None = None,
) -> None:
"""Ensure a SID is in exactly one room matching the current session scope."""
room: str | None = None
if is_admin:
room = "admins"
elif db_user_id is not None:
room = f"user_{db_user_id}"
with self._rooms_lock:
self._set_sid_room_locked(sid, room)
def join_user_room(
self,
sid: str,
is_admin: bool,
db_user_id: int | None = None,
) -> None:
"""Join the appropriate room based on user role."""
self.sync_user_room(sid, is_admin=is_admin, db_user_id=db_user_id)
def leave_user_room(
self,
sid: str,
*,
is_admin: bool = False,
db_user_id: int | None = None,
) -> None:
"""Leave whichever room the SID currently belongs to."""
del is_admin, db_user_id # Backward-compatible signature; routing is SID-based.
with self._rooms_lock:
self._set_sid_room_locked(sid, None)
def broadcast_status_update(self, status_data: dict[str, Any]) -> None:
"""Broadcast status update to all connected clients, filtered by user room."""
socketio = self._get_socketio()
if socketio is None:
return
try:
# Admins (and no-auth users) get full status
socketio.emit("status_update", status_data, to="admins")
# Each user room gets filtered status
with self._rooms_lock:
active_rooms = list(self._user_rooms.keys())
if active_rooms and self._queue_status_fn:
for room in active_rooms:
self._broadcast_status_update_to_room(room)
logger.debug("Broadcasted status update to all rooms")
except Exception:
logger.exception("Error broadcasting status update")
def _broadcast_status_update_to_room(self, room: str) -> None:
"""Broadcast status update to one user room."""
socketio = self._get_socketio()
if socketio is None:
return
try:
# Extract user_id from room name "user_123"
uid = int(room.split("_", 1)[1])
filtered = self._queue_status_fn(user_id=uid) if self._queue_status_fn else None
if filtered is not None:
socketio.emit("status_update", filtered, to=room)
except Exception:
logger.exception("Failed to send status update for room %s", room)
def broadcast_download_progress(
self, book_id: str, progress: float, status: str, user_id: int | None = None
) -> None:
"""Broadcast download progress update for a specific book."""
socketio = self._get_socketio()
if socketio is None:
return
try:
data = {"book_id": book_id, "progress": progress, "status": status}
# Admins always see all progress
socketio.emit("download_progress", data, to="admins")
# If task belongs to a specific user, send to their room too
if user_id is not None:
room = f"user_{user_id}"
with self._rooms_lock:
if room in self._user_rooms:
socketio.emit("download_progress", data, to=room)
logger.debug("Broadcasted progress for book %s: %s%%", book_id, progress)
except Exception:
logger.exception("Error broadcasting download progress")
def broadcast_search_status(
self,
source: str,
provider: str,
book_id: str,
message: str,
phase: str = "searching",
) -> None:
"""Broadcast search status update for a release source search."""
socketio = self._get_socketio()
if socketio is None:
return
try:
data = {
"source": source,
"provider": provider,
"book_id": book_id,
"message": message,
"phase": phase,
}
socketio.emit("search_status", data)
except Exception:
logger.exception("Error broadcasting search status")
# Global WebSocket manager instance
ws_manager = WebSocketManager()
+5
View File
@@ -0,0 +1,5 @@
"""Cloudflare bypass utilities."""
class BypassCancelledError(Exception):
"""Raised when a bypass operation is cancelled."""
+173
View File
@@ -0,0 +1,173 @@
"""External Cloudflare bypasser using FlareSolverr."""
import random
import time
from typing import TYPE_CHECKING
import requests
from shelfmark.bypass import BypassCancelledError
from shelfmark.core.config import config
from shelfmark.core.logger import setup_logger
from shelfmark.core.utils import normalize_http_url
from shelfmark.download.network import get_ssl_verify
if TYPE_CHECKING:
from threading import Event
from shelfmark.download import network
logger = setup_logger(__name__)
_RNG = random.SystemRandom()
# Timeout constants (seconds)
CONNECT_TIMEOUT = 10
MAX_READ_TIMEOUT = 120
READ_TIMEOUT_BUFFER = 15
# Retry settings
MAX_RETRY = 5
BACKOFF_BASE = 1.0
BACKOFF_CAP = 10.0
def _coerce_config_str(value: object, default: str) -> str:
"""Return a string config value or a safe default."""
if isinstance(value, str):
return value
return default
def _coerce_timeout_ms(value: object, default: int) -> int:
"""Return a positive timeout in milliseconds or the default."""
if isinstance(value, bool):
return default
if isinstance(value, int) and value > 0:
return value
return default
def _fetch_via_bypasser(target_url: str) -> str | None:
"""Make a single request to the external bypasser service. Returns HTML or None."""
raw_bypasser_url = _coerce_config_str(
config.get("EXT_BYPASSER_URL", "http://flaresolverr:8191"),
"http://flaresolverr:8191",
)
bypasser_path = _coerce_config_str(config.get("EXT_BYPASSER_PATH", "/v1"), "/v1")
bypasser_timeout = _coerce_timeout_ms(config.get("EXT_BYPASSER_TIMEOUT", 60000), 60000)
bypasser_url = normalize_http_url(raw_bypasser_url)
if not bypasser_url or not bypasser_path:
logger.error(
"External bypasser not configured. Check EXT_BYPASSER_URL and EXT_BYPASSER_PATH."
)
return None
read_timeout = min((bypasser_timeout / 1000) + READ_TIMEOUT_BUFFER, MAX_READ_TIMEOUT)
try:
response = requests.post(
f"{bypasser_url}{bypasser_path}",
headers={"Content-Type": "application/json"},
json={
"cmd": "request.get",
"url": target_url,
"maxTimeout": bypasser_timeout,
},
timeout=(CONNECT_TIMEOUT, read_timeout),
verify=get_ssl_verify(bypasser_url),
)
response.raise_for_status()
result = response.json()
status = result.get("status", "unknown")
message = result.get("message", "")
logger.debug("External bypasser response for '%s': %s - %s", target_url, status, message)
if status != "ok":
logger.warning(
"External bypasser failed for '%s': %s - %s",
target_url,
status,
message,
)
return None
solution = result.get("solution")
html = solution.get("response", "") if solution else ""
if not html:
logger.warning("External bypasser returned empty response for '%s'", target_url)
return None
except requests.exceptions.Timeout:
logger.warning(
"External bypasser timed out for '%s' (connect: %ss, read: %.0fs)",
target_url,
CONNECT_TIMEOUT,
read_timeout,
)
except requests.exceptions.RequestException as e:
logger.warning("External bypasser request failed for '%s': %s", target_url, e)
except (KeyError, TypeError, ValueError) as e:
logger.warning("External bypasser returned malformed response for '%s': %s", target_url, e)
else:
return html
return None
def _check_cancelled(cancel_flag: Event | None, context: str) -> None:
"""Check if operation was cancelled and raise exception if so."""
if cancel_flag and cancel_flag.is_set():
logger.info("External bypasser cancelled %s", context)
msg = "Bypass cancelled"
raise BypassCancelledError(msg)
def _sleep_with_cancellation(seconds: float, cancel_flag: Event | None) -> None:
"""Sleep for the specified duration, checking for cancellation each second."""
for _ in range(int(seconds)):
_check_cancelled(cancel_flag, "during backoff")
time.sleep(1)
remaining = seconds - int(seconds)
if remaining > 0:
time.sleep(remaining)
def get_bypassed_page(
url: str,
selector: network.AAMirrorSelector | None = None,
cancel_flag: Event | None = None,
) -> str | None:
"""Fetch HTML via external bypasser with retries and mirror rotation."""
from shelfmark.download import network as network_module
sel = selector or network_module.AAMirrorSelector()
for attempt in range(1, MAX_RETRY + 1):
_check_cancelled(cancel_flag, "by user")
attempt_url = sel.rewrite(url)
result = _fetch_via_bypasser(attempt_url)
if result:
return result
if attempt == MAX_RETRY:
break
delay = min(BACKOFF_CAP, BACKOFF_BASE * (2 ** (attempt - 1))) + _RNG.random()
logger.info(
"External bypasser attempt %s/%s failed, retrying in %.1fs",
attempt,
MAX_RETRY,
delay,
)
_sleep_with_cancellation(delay, cancel_flag)
new_base, action = sel.next_mirror_or_rotate_dns()
if action in ("mirror", "dns") and new_base:
logger.info("Rotated %s for retry", action)
return None
+70
View File
@@ -0,0 +1,70 @@
"""Browser fingerprint profile management for bypass stealth."""
import random
from shelfmark.core.logger import setup_logger
logger = setup_logger(__name__)
COMMON_RESOLUTIONS = [
(1920, 1080, 0.35),
(1366, 768, 0.18),
(1536, 864, 0.10),
(1440, 900, 0.08),
(1280, 720, 0.07),
(1600, 900, 0.06),
(1280, 800, 0.05),
(2560, 1440, 0.04),
(1680, 1050, 0.04),
(1920, 1200, 0.03),
]
# Current screen size (module-level singleton)
_current_screen_size: tuple[int, int] | None = None
_RNG = random.SystemRandom()
def get_screen_size() -> tuple[int, int]:
"""Return the current synthetic screen size, generating one if needed."""
global _current_screen_size
if _current_screen_size is None:
_current_screen_size = _generate_screen_size()
logger.debug(
"Generated initial screen size: %sx%s",
_current_screen_size[0],
_current_screen_size[1],
)
return _current_screen_size
def rotate_screen_size() -> tuple[int, int]:
"""Rotate to a new synthetic screen size and return it."""
global _current_screen_size
old_size = _current_screen_size
_current_screen_size = _generate_screen_size()
width, height = _current_screen_size
if old_size:
logger.info(
"Rotated screen size: %sx%s -> %sx%s",
old_size[0],
old_size[1],
width,
height,
)
else:
logger.info("Generated screen size: %sx%s", width, height)
return _current_screen_size
def clear_screen_size() -> None:
"""Clear the cached synthetic screen size."""
global _current_screen_size
_current_screen_size = None
def _generate_screen_size() -> tuple[int, int]:
resolutions = [(w, h) for w, h, _ in COMMON_RESOLUTIONS]
weights = [weight for _, _, weight in COMMON_RESOLUTIONS]
return _RNG.choices(resolutions, weights=weights)[0]
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
"""Configuration module - environment variables and settings."""
+203
View File
@@ -0,0 +1,203 @@
"""Helpers for Booklore settings validation, option loading, and connection tests."""
from __future__ import annotations
from typing import Any
from shelfmark.core.config import config
from shelfmark.core.logger import setup_logger
from shelfmark.download.outputs.booklore import (
BookloreConfig,
BookloreError,
booklore_list_libraries,
booklore_login,
)
logger = setup_logger(__name__)
_BOOKLORE_OPTIONS_CACHE: dict[str, Any] = {
"key": None,
"library_options": [],
"path_options": [],
}
def _get_booklore_cache_key(base_url: str, username: str, password: str) -> str:
return f"{base_url}|{username}|{hash(password)}"
def _get_booklore_select_options(
base_url: str,
username: str,
password: str,
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
# library_id/path_id are not used for login/library listing
booklore_config = BookloreConfig(
base_url=base_url.rstrip("/"),
username=username,
password=password,
library_id=1,
path_id=1,
verify_tls=True,
refresh_after_upload=True,
)
token = booklore_login(booklore_config)
libraries = booklore_list_libraries(booklore_config, token) or []
logger.debug("Booklore libraries response: %s", libraries)
library_options: list[dict[str, Any]] = []
path_options: list[dict[str, Any]] = []
for library in libraries:
if not isinstance(library, dict):
continue
library_id = library.get("id")
if library_id is None:
continue
library_name = str(library.get("name") or f"Library {library_id}")
library_id_str = str(library_id)
library_options.append({"value": library_id_str, "label": library_name})
paths = library.get("paths") or []
if not isinstance(paths, list):
continue
for path in paths:
if not isinstance(path, dict):
continue
path_id = path.get("id")
if path_id is None:
continue
path_label = str(path.get("path") or f"Path {path_id}")
path_options.append(
{
"value": str(path_id),
"label": f"{library_name}: {path_label}",
"childOf": library_id_str,
}
)
logger.debug(
"Booklore options built: libraries=%d paths=%d",
len(library_options),
len(path_options),
)
cache_key = _get_booklore_cache_key(base_url, username, password)
_BOOKLORE_OPTIONS_CACHE.update(
{
"key": cache_key,
"library_options": library_options,
"path_options": path_options,
}
)
return library_options, path_options
def _get_booklore_cached_options(
base_url: str,
username: str,
password: str,
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
cache_key = _get_booklore_cache_key(base_url, username, password)
if _BOOKLORE_OPTIONS_CACHE.get("key") == cache_key:
return (
_BOOKLORE_OPTIONS_CACHE.get("library_options", []),
_BOOKLORE_OPTIONS_CACHE.get("path_options", []),
)
return _get_booklore_select_options(base_url, username, password)
def get_booklore_library_options() -> list[dict[str, Any]]:
"""Build Booklore library options dynamically from config."""
if config.get("BOOKS_OUTPUT_MODE", "folder") != "booklore":
return []
base_url = str(config.get("BOOKLORE_HOST", "") or "").strip().rstrip("/")
username = str(config.get("BOOKLORE_USERNAME", "") or "").strip()
password = str(config.get("BOOKLORE_PASSWORD", "") or "")
if not base_url or not username or not password:
return []
cache_key = _get_booklore_cache_key(base_url, username, password)
try:
library_options, _ = _get_booklore_cached_options(base_url, username, password)
except Exception:
logger.exception("Failed to fetch Booklore libraries")
if _BOOKLORE_OPTIONS_CACHE.get("key") == cache_key:
return _BOOKLORE_OPTIONS_CACHE.get("library_options", [])
return []
else:
return library_options
def get_booklore_path_options() -> list[dict[str, Any]]:
"""Build Booklore path options dynamically from config."""
if config.get("BOOKS_OUTPUT_MODE", "folder") != "booklore":
return []
base_url = str(config.get("BOOKLORE_HOST", "") or "").strip().rstrip("/")
username = str(config.get("BOOKLORE_USERNAME", "") or "").strip()
password = str(config.get("BOOKLORE_PASSWORD", "") or "")
if not base_url or not username or not password:
return []
cache_key = _get_booklore_cache_key(base_url, username, password)
try:
_, path_options = _get_booklore_cached_options(base_url, username, password)
except Exception:
logger.exception("Failed to fetch Booklore paths")
if _BOOKLORE_OPTIONS_CACHE.get("key") == cache_key:
return _BOOKLORE_OPTIONS_CACHE.get("path_options", [])
return []
else:
return path_options
def check_booklore_connection(
current_values: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Test the Booklore connection using current form values."""
current_values = current_values or {}
def _get_value(key: str, default: object = None) -> object:
value = current_values.get(key)
if value not in (None, ""):
return value
if default is None:
return config.get(key)
return config.get(key, default)
base_url = str(_get_value("BOOKLORE_HOST", "") or "").strip().rstrip("/")
username = str(_get_value("BOOKLORE_USERNAME", "") or "").strip()
password = str(_get_value("BOOKLORE_PASSWORD", "") or "")
if not base_url:
return {"success": False, "message": "Grimmory URL is required"}
if not username:
return {"success": False, "message": "Grimmory username is required"}
if not password:
return {"success": False, "message": "Grimmory password is required"}
try:
library_options, _ = _get_booklore_select_options(base_url, username, password)
except BookloreError as exc:
return {"success": False, "message": str(exc)}
else:
message = "Connected to Grimmory"
if library_options:
message = f"Connected to Grimmory ({len(library_options)} libraries)"
return {"success": True, "message": message}
@@ -0,0 +1,110 @@
from __future__ import annotations
import re
from pathlib import Path
from typing import Any
_USER_PLACEHOLDER_PATTERN = re.compile(r"\{user\}", re.IGNORECASE)
def _get_download_setting_value(
current_values: dict[str, Any] | None,
key: str,
*,
default: object = None,
) -> object:
"""Read a downloads setting from unsaved form values first, then persisted config."""
from shelfmark.core.config import config
current_values = current_values or {}
if key in current_values:
return current_values[key]
if default is None:
return config.get(key)
return config.get(key, default)
def _resolve_destination_test_path(
configured_path: str,
) -> tuple[Path, str | None]:
"""Resolve a safe path to validate for destination test actions."""
stripped_path = configured_path.strip()
if not _USER_PLACEHOLDER_PATTERN.search(stripped_path):
return Path(stripped_path), None
base_prefix = _USER_PLACEHOLDER_PATTERN.split(stripped_path, maxsplit=1)[0].rstrip("/")
if not base_prefix and not stripped_path.startswith("/"):
return Path(stripped_path), None
base_path = base_prefix or "/"
return Path(base_path), (
f" (tested base path {base_path} from configured template {stripped_path})"
)
def _test_folder_destination(
*,
current_values: dict[str, Any] | None = None,
is_audiobook: bool,
) -> dict[str, Any]:
"""Validate a folder destination using current form values."""
from shelfmark.download.postprocess.destination import validate_destination
destination_value = _get_download_setting_value(
current_values,
"DESTINATION",
default="/books",
)
destination = str(destination_value or "").strip()
label = "Books destination"
message_suffix = ""
if is_audiobook:
audiobook_value = _get_download_setting_value(
current_values,
"DESTINATION_AUDIOBOOK",
default="",
)
audiobook_destination = str(audiobook_value or "").strip()
if audiobook_destination:
destination = audiobook_destination
label = "Audiobook destination"
else:
label = "Audiobook destination"
message_suffix = " (using the Books destination)"
if not destination:
return {"success": False, "message": f"{label} is required"}
test_path, path_message = _resolve_destination_test_path(destination)
if path_message:
message_suffix += path_message
errors: list[str] = []
def _status_callback(status: str, message: str | None) -> None:
if status == "error" and message:
errors.append(message)
if not validate_destination(test_path, _status_callback):
message = errors[-1] if errors else f"Cannot access destination: {test_path}"
if message_suffix:
message = f"{message}{message_suffix}"
return {"success": False, "message": message}
return {
"success": True,
"message": f"{label} is writable: {test_path}{message_suffix}",
}
def check_books_destination(current_values: dict[str, Any] | None = None) -> dict[str, Any]:
"""Validate the configured books destination."""
return _test_folder_destination(current_values=current_values, is_audiobook=False)
def check_audiobook_destination(current_values: dict[str, Any] | None = None) -> dict[str, Any]:
"""Validate the configured audiobook destination."""
return _test_folder_destination(current_values=current_values, is_audiobook=True)
+50
View File
@@ -0,0 +1,50 @@
"""Helpers for email settings validation and SMTP connection tests."""
from __future__ import annotations
import smtplib
from typing import Any
from shelfmark.core.config import config
from shelfmark.download.outputs.email import (
EmailOutputError,
build_email_smtp_config,
test_smtp_connection,
)
def check_email_connection(
current_values: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Test SMTP connectivity using current form values (including unsaved changes)."""
current_values = current_values or {}
def _get_value(key: str, default: object = None) -> object:
value = current_values.get(key)
if value not in (None, ""):
return value
if default is None:
return config.get(key)
return config.get(key, default)
settings = {
"EMAIL_SMTP_HOST": _get_value("EMAIL_SMTP_HOST", ""),
"EMAIL_SMTP_PORT": _get_value("EMAIL_SMTP_PORT", 587),
"EMAIL_SMTP_SECURITY": _get_value("EMAIL_SMTP_SECURITY", "starttls"),
"EMAIL_SMTP_USERNAME": _get_value("EMAIL_SMTP_USERNAME", ""),
"EMAIL_SMTP_PASSWORD": _get_value("EMAIL_SMTP_PASSWORD", ""),
"EMAIL_FROM": _get_value("EMAIL_FROM", ""),
"EMAIL_SUBJECT_TEMPLATE": _get_value("EMAIL_SUBJECT_TEMPLATE", "{Title}"),
"EMAIL_SMTP_TIMEOUT_SECONDS": _get_value("EMAIL_SMTP_TIMEOUT_SECONDS", 60),
"EMAIL_ALLOW_UNVERIFIED_TLS": _get_value("EMAIL_ALLOW_UNVERIFIED_TLS", default=False),
}
try:
smtp_config = build_email_smtp_config(settings)
test_smtp_connection(smtp_config)
except EmailOutputError as exc:
return {"success": False, "message": str(exc)}
except (OSError, smtplib.SMTPException) as exc:
return {"success": False, "message": f"SMTP test failed: {exc}"}
else:
return {"success": True, "message": "Connected to SMTP server"}
+169
View File
@@ -0,0 +1,169 @@
"""Bootstrap environment variables. No local dependencies - import first."""
import json
import os
import shutil
import tempfile
from pathlib import Path
def string_to_bool(s: str) -> bool:
"""Convert string to boolean."""
return s.lower() in ["true", "yes", "1", "y"]
def _read_debug_from_config() -> bool:
"""Read DEBUG from env var or config file (import-time safe)."""
env_debug = os.environ.get("DEBUG")
if env_debug is not None:
return string_to_bool(env_debug)
# Try to read from config file
config_dir = Path(os.getenv("CONFIG_DIR", "/config"))
config_file = config_dir / "plugins" / "advanced.json"
if config_file.exists():
try:
with config_file.open() as f:
config = json.load(f)
if "DEBUG" in config:
return bool(config["DEBUG"])
except json.JSONDecodeError, OSError:
pass
return False
def _is_sqlite_file(path: Path) -> bool:
"""Check if a file is a valid SQLite database by reading magic bytes."""
try:
with path.open("rb") as f:
header = f.read(16)
return header[:16] == b"SQLite format 3\x00"
except OSError, PermissionError:
return False
def _resolve_cwa_db_path() -> Path | None:
"""Resolve CWA database path from env var or default location."""
env_path = os.getenv("CWA_DB_PATH")
if env_path:
path = Path(env_path)
if path.exists() and path.is_file() and _is_sqlite_file(path):
return path
# Check default mount path
default_path = Path("/auth/app.db")
if default_path.exists() and default_path.is_file() and _is_sqlite_file(default_path):
return default_path
return None
def _is_config_dir_writable() -> bool:
"""Check if the config directory exists and is writable."""
try:
if not CONFIG_DIR.exists() or not CONFIG_DIR.is_dir():
return False
test_file = CONFIG_DIR / ".write_test"
test_file.touch()
test_file.unlink()
except OSError, PermissionError:
return False
else:
return True
def is_covers_cache_enabled() -> bool:
"""Check if cover caching is enabled (requires setting + writable config dir)."""
from shelfmark.core.config import config
setting_enabled = config.get("COVERS_CACHE_ENABLED", True)
if isinstance(setting_enabled, str):
return string_to_bool(setting_enabled) and _is_config_dir_writable()
return bool(setting_enabled) and _is_config_dir_writable()
# =============================================================================
# Bootstrap paths - needed before settings registry is available
# =============================================================================
CONFIG_DIR = Path(os.getenv("CONFIG_DIR", "/config"))
LOG_ROOT = Path(os.getenv("LOG_ROOT", "/var/log/"))
LOG_DIR = LOG_ROOT / "shelfmark"
LOG_FILE = LOG_DIR / "shelfmark.log"
TMP_DIR = Path(os.getenv("TMP_DIR", (Path(tempfile.gettempdir()) / "shelfmark").as_posix()))
INGEST_DIR = Path(os.getenv("INGEST_DIR", "/books"))
# =============================================================================
# Logger configuration - needed before settings registry is available
# =============================================================================
DEBUG = _read_debug_from_config()
LOG_LEVEL = "DEBUG" if DEBUG else "INFO"
ENABLE_LOGGING = string_to_bool(os.getenv("ENABLE_LOGGING", "true"))
# =============================================================================
# Flask configuration - needed before app starts
# =============================================================================
FLASK_HOST = os.getenv("FLASK_HOST", "0.0.0.0")
FLASK_PORT = int(os.getenv("FLASK_PORT", "8084"))
# =============================================================================
# Authentication
# =============================================================================
SESSION_COOKIE_SECURE_ENV = os.getenv("SESSION_COOKIE_SECURE", "false")
SESSION_COOKIE_NAME = "shelfmark_session"
CWA_DB_PATH = _resolve_cwa_db_path()
HIDE_LOCAL_AUTH = string_to_bool(os.getenv("HIDE_LOCAL_AUTH", "false"))
OIDC_AUTO_REDIRECT = string_to_bool(os.getenv("OIDC_AUTO_REDIRECT", "false"))
# =============================================================================
# Version information from Docker build
# =============================================================================
BUILD_VERSION = os.getenv("BUILD_VERSION", "N/A")
RELEASE_VERSION = os.getenv("RELEASE_VERSION", "N/A")
# =============================================================================
# Capability detection - runtime checks, not user-configurable
# =============================================================================
DOCKERMODE = string_to_bool(os.getenv("DOCKERMODE", "false"))
TOR_VARIANT_AVAILABLE = shutil.which("tor") is not None
USING_TOR = string_to_bool(os.getenv("USING_TOR", "false"))
# =============================================================================
# Onboarding
# =============================================================================
# Set to false to skip the onboarding wizard entirely (useful for ephemeral storage)
ONBOARDING = string_to_bool(os.getenv("ONBOARDING", "true"))
# =============================================================================
# Debug/development settings
# =============================================================================
# Debug: skip specific download sources for testing fallback chains
# Comma-separated values: aa-fast, aa-slow-nowait, aa-slow-wait, libgen, zlib, welib
_DEBUG_SKIP_SOURCES_RAW = os.getenv("DEBUG_SKIP_SOURCES", "").strip().lower()
DEBUG_SKIP_SOURCES = {s.strip() for s in _DEBUG_SKIP_SOURCES_RAW.split(",") if s.strip()}
# =============================================================================
# Legacy migration support - will be removed in future version
# =============================================================================
# Legacy welib settings - replaced by SOURCE_PRIORITY OrderableListField
# Kept for migration: if set, used to build initial SOURCE_PRIORITY config
_LEGACY_PRIORITIZE_WELIB = string_to_bool(os.getenv("PRIORITIZE_WELIB", "false"))
_LEGACY_ALLOW_USE_WELIB = string_to_bool(os.getenv("ALLOW_USE_WELIB", "true"))
+149
View File
@@ -0,0 +1,149 @@
"""Configuration migration helpers."""
from __future__ import annotations
import json
from pathlib import Path
from typing import TYPE_CHECKING, Any, Protocol
if TYPE_CHECKING:
from collections.abc import Callable
from os import PathLike
_DEPRECATED_SETTINGS_RESTRICTION_KEYS = (
"PROXY_AUTH_RESTRICT_SETTINGS_TO_ADMIN",
"CWA_RESTRICT_SETTINGS_TO_ADMIN",
"RESTRICT_SETTINGS_TO_ADMIN",
)
class MigrationLogger(Protocol):
"""Logger surface used by config migration helpers."""
def info(self, msg: str, *args: object) -> object: ...
def debug(self, msg: str, *args: object) -> object: ...
def exception(self, msg: str, *args: object) -> object: ...
def _as_bool(value: object) -> bool:
if isinstance(value, bool):
return value
if isinstance(value, str):
return value.strip().lower() in {"1", "true", "yes", "on"}
return bool(value)
def _pick_legacy_settings_restriction(config: dict[str, Any]) -> bool | None:
"""Pick the best legacy admin-restriction value to migrate."""
auth_method = str(config.get("AUTH_METHOD", "")).strip().lower()
if auth_method == "proxy" and "PROXY_AUTH_RESTRICT_SETTINGS_TO_ADMIN" in config:
return _as_bool(config.get("PROXY_AUTH_RESTRICT_SETTINGS_TO_ADMIN"))
if auth_method == "cwa" and "CWA_RESTRICT_SETTINGS_TO_ADMIN" in config:
return _as_bool(config.get("CWA_RESTRICT_SETTINGS_TO_ADMIN"))
if "RESTRICT_SETTINGS_TO_ADMIN" in config:
return _as_bool(config.get("RESTRICT_SETTINGS_TO_ADMIN"))
if "PROXY_AUTH_RESTRICT_SETTINGS_TO_ADMIN" in config:
return _as_bool(config.get("PROXY_AUTH_RESTRICT_SETTINGS_TO_ADMIN"))
if "CWA_RESTRICT_SETTINGS_TO_ADMIN" in config:
return _as_bool(config.get("CWA_RESTRICT_SETTINGS_TO_ADMIN"))
return None
def migrate_security_settings(
*,
load_security_config: Callable[[], dict[str, Any]],
load_users_config: Callable[[], dict[str, Any]],
save_users_config: Callable[[dict[str, Any]], None],
ensure_config_dir: Callable[[], None],
get_config_path: Callable[[], str | PathLike[str]],
sync_builtin_admin_user: Callable[[str, str], None],
logger: MigrationLogger,
) -> None:
"""Migrate legacy security keys and sync builtin admin credentials."""
try:
config = load_security_config()
users_config = load_users_config()
migrated_security = False
migrated_users = False
if "USE_CWA_AUTH" in config:
old_value = config.pop("USE_CWA_AUTH")
if "AUTH_METHOD" not in config:
if old_value:
config["AUTH_METHOD"] = "cwa"
logger.info("Migrated USE_CWA_AUTH=True to AUTH_METHOD='cwa'")
elif config.get("BUILTIN_USERNAME") and config.get("BUILTIN_PASSWORD_HASH"):
config["AUTH_METHOD"] = "builtin"
logger.info("Migrated USE_CWA_AUTH=False to AUTH_METHOD='builtin'")
else:
config["AUTH_METHOD"] = "none"
logger.info("Migrated USE_CWA_AUTH=False to AUTH_METHOD='none'")
migrated_security = True
else:
logger.info("Removed deprecated USE_CWA_AUTH setting (AUTH_METHOD already exists)")
migrated_security = True
# Backfill AUTH_METHOD for configs that have builtin credentials but
# were never migrated from USE_CWA_AUTH (e.g. dev builds that predated
# the AUTH_METHOD field).
if (
"AUTH_METHOD" not in config
and config.get("BUILTIN_USERNAME")
and config.get("BUILTIN_PASSWORD_HASH")
):
config["AUTH_METHOD"] = "builtin"
migrated_security = True
logger.info(
"Backfilled AUTH_METHOD='builtin' from legacy "
"BUILTIN_USERNAME/BUILTIN_PASSWORD_HASH credentials"
)
if "RESTRICT_SETTINGS_TO_ADMIN" not in users_config:
legacy_restrict = _pick_legacy_settings_restriction(config)
if legacy_restrict is not None:
save_users_config({"RESTRICT_SETTINGS_TO_ADMIN": legacy_restrict})
migrated_users = True
logger.info(
"Migrated legacy settings-admin restriction to users.RESTRICT_SETTINGS_TO_ADMIN=%s",
legacy_restrict,
)
for deprecated_key in _DEPRECATED_SETTINGS_RESTRICTION_KEYS:
if deprecated_key in config:
config.pop(deprecated_key, None)
migrated_security = True
logger.info("Removed deprecated security setting: %s", deprecated_key)
try:
sync_builtin_admin_user(
config.get("BUILTIN_USERNAME", ""),
config.get("BUILTIN_PASSWORD_HASH", ""),
)
except Exception:
logger.exception(
"Failed to sync builtin credentials to users database during migration"
)
if migrated_security:
ensure_config_dir()
config_path = Path(get_config_path())
with config_path.open("w") as f:
json.dump(config, f, indent=2)
logger.info("Security settings migration completed successfully")
elif migrated_users:
logger.info("Users settings migration completed successfully")
else:
logger.debug("No security settings migration needed")
except FileNotFoundError:
logger.debug("No existing security config file found - nothing to migrate")
except Exception:
logger.exception("Failed to migrate security settings")
+341
View File
@@ -0,0 +1,341 @@
"""Notifications settings tab registration."""
from __future__ import annotations
import re
from typing import Any
from urllib.parse import urlsplit
from shelfmark.core.config import config as app_config
from shelfmark.core.notifications import NotificationEvent, send_test_notification
from shelfmark.core.settings_registry import (
ActionButton,
HeadingField,
SettingsField,
TableField,
load_config_file,
register_on_save,
register_settings,
)
_URL_SCHEME_RE = re.compile(r"^[a-zA-Z][a-zA-Z0-9+.-]*$")
_ROUTE_EVENT_ALL = "all"
_ADMIN_EVENT_OPTIONS = [
{"value": NotificationEvent.REQUEST_CREATED.value, "label": "New request submitted"},
{"value": NotificationEvent.REQUEST_FULFILLED.value, "label": "Request approved"},
{"value": NotificationEvent.REQUEST_REJECTED.value, "label": "Request rejected"},
{"value": NotificationEvent.DOWNLOAD_COMPLETE.value, "label": "Download complete"},
{"value": NotificationEvent.DOWNLOAD_FAILED.value, "label": "Download failed"},
]
_ROUTE_EVENT_OPTIONS = [
{"value": _ROUTE_EVENT_ALL, "label": "All"},
*_ADMIN_EVENT_OPTIONS,
]
_ROUTE_EVENT_ORDER = [option["value"] for option in _ROUTE_EVENT_OPTIONS]
_ROUTE_EVENT_INDEX = {event: index for index, event in enumerate(_ROUTE_EVENT_ORDER)}
_ALLOWED_ROUTE_EVENTS = set(_ROUTE_EVENT_ORDER)
_DEFAULT_ROUTE_ROWS = [{"event": [_ROUTE_EVENT_ALL], "url": ""}]
def _looks_like_apprise_url(url: str) -> bool:
split = urlsplit(url)
if not split.scheme:
return False
if not _URL_SCHEME_RE.match(split.scheme):
return False
return " " not in url
def _coerce_route_rows(value: Any) -> list[dict[str, Any]]:
if value is None:
return []
if isinstance(value, list):
return [row for row in value if isinstance(row, dict)]
if isinstance(value, dict):
return [value]
return []
def _coerce_route_event_values(value: Any) -> list[Any]:
if isinstance(value, list):
return value
if isinstance(value, (tuple, set)):
return list(value)
return [value]
def _normalize_route_events(value: Any) -> list[str]:
normalized: list[str] = []
seen: set[str] = set()
for raw_event in _coerce_route_event_values(value):
event = str(raw_event or "").strip().lower()
if not event or event not in _ALLOWED_ROUTE_EVENTS:
continue
if event in seen:
continue
seen.add(event)
normalized.append(event)
if _ROUTE_EVENT_ALL in seen:
return [_ROUTE_EVENT_ALL]
return sorted(normalized, key=lambda event: _ROUTE_EVENT_INDEX[event])
def _normalize_routes(value: Any) -> list[dict[str, Any]]:
normalized: list[dict[str, Any]] = []
seen: set[tuple[tuple[str, ...], str]] = set()
for row in _coerce_route_rows(value):
events = _normalize_route_events(row.get("event"))
if not events:
continue
url = str(row.get("url") or "").strip()
key = (tuple(events), url)
if key in seen:
continue
seen.add(key)
normalized.append({"event": events, "url": url})
return normalized
def _count_invalid_route_events(value: Any) -> int:
invalid = 0
for row in _coerce_route_rows(value):
raw_events = _coerce_route_event_values(row.get("event"))
if not raw_events:
invalid += 1
continue
for raw_event in raw_events:
event = str(raw_event or "").strip().lower()
if not event or event not in _ALLOWED_ROUTE_EVENTS:
invalid += 1
return invalid
def _count_invalid_route_urls(routes: list[dict[str, Any]]) -> int:
return sum(1 for row in routes if row["url"] and not _looks_like_apprise_url(row["url"]))
def _ensure_default_route_row(routes: list[dict[str, Any]]) -> list[dict[str, Any]]:
return routes or [dict(row) for row in _DEFAULT_ROUTE_ROWS]
def _extract_unique_route_urls(routes: list[dict[str, Any]]) -> list[str]:
urls: list[str] = []
seen: set[str] = set()
for row in routes:
url = row.get("url", "")
if not url:
continue
if url in seen:
continue
seen.add(url)
urls.append(url)
return urls
def build_notification_test_result(routes_input: Any, *, scope_label: str) -> dict[str, Any]:
"""Validate routes and return a test-notification result payload."""
invalid_event_count = _count_invalid_route_events(routes_input)
if invalid_event_count:
return {
"success": False,
"message": (
f"Found {invalid_event_count} invalid {scope_label} notification route event value(s). "
"Fix route events before running a test."
),
}
normalized_routes = _normalize_routes(routes_input)
invalid_url_count = _count_invalid_route_urls(normalized_routes)
if invalid_url_count:
return {
"success": False,
"message": (
f"Found {invalid_url_count} invalid {scope_label} notification URL(s). "
"Fix route URLs before running a test."
),
}
urls = _extract_unique_route_urls(normalized_routes)
if not urls:
return {
"success": False,
"message": f"Add at least one {scope_label} notification URL route first.",
}
return send_test_notification(urls)
def normalize_notification_routes(value: Any) -> list[dict[str, Any]]:
"""Normalize route table rows for notification preferences."""
return _normalize_routes(value)
def is_valid_notification_url(url: str) -> bool:
"""Shared URL validation for notifications preferences."""
return _looks_like_apprise_url(url)
def _on_save_notifications(values: dict[str, Any]) -> dict[str, Any]:
existing = load_config_file("notifications")
effective: dict[str, Any] = dict(existing)
effective.update(values)
admin_routes_input = effective.get("ADMIN_NOTIFICATION_ROUTES", [])
invalid_admin_event_count = _count_invalid_route_events(admin_routes_input)
if invalid_admin_event_count:
return {
"error": True,
"message": (
f"Found {invalid_admin_event_count} invalid global notification route event value(s)."
),
"values": values,
}
normalized_admin_routes = _normalize_routes(admin_routes_input)
invalid_admin_url_count = _count_invalid_route_urls(normalized_admin_routes)
if invalid_admin_url_count:
return {
"error": True,
"message": (
f"Found {invalid_admin_url_count} invalid global notification URL(s). "
"Use URL values with a valid scheme, e.g. discord://... or ntfys://..."
),
"values": values,
}
user_routes_input = effective.get("USER_NOTIFICATION_ROUTES", [])
invalid_user_event_count = _count_invalid_route_events(user_routes_input)
if invalid_user_event_count:
return {
"error": True,
"message": (
f"Found {invalid_user_event_count} invalid personal notification route event value(s)."
),
"values": values,
}
normalized_user_routes = _normalize_routes(user_routes_input)
invalid_user_url_count = _count_invalid_route_urls(normalized_user_routes)
if invalid_user_url_count:
return {
"error": True,
"message": (
f"Found {invalid_user_url_count} invalid personal notification URL(s). "
"Use URL values with a valid scheme, e.g. discord://... or ntfys://..."
),
"values": values,
}
admin_routes_touched = "ADMIN_NOTIFICATION_ROUTES" in values
if admin_routes_touched:
values["ADMIN_NOTIFICATION_ROUTES"] = _ensure_default_route_row(normalized_admin_routes)
user_routes_touched = "USER_NOTIFICATION_ROUTES" in values
if user_routes_touched:
values["USER_NOTIFICATION_ROUTES"] = _ensure_default_route_row(normalized_user_routes)
return {"error": False, "values": values}
def _test_admin_notification_action(current_values: dict[str, Any]) -> dict[str, Any]:
effective: dict[str, Any] = {
"ADMIN_NOTIFICATION_ROUTES": app_config.get("ADMIN_NOTIFICATION_ROUTES", []),
}
if isinstance(current_values, dict):
effective.update(current_values)
routes_input = effective.get("ADMIN_NOTIFICATION_ROUTES", [])
return build_notification_test_result(routes_input, scope_label="global")
register_on_save("notifications", _on_save_notifications)
@register_settings("notifications", "Notifications", icon="bell", order=7)
def notifications_settings() -> list[SettingsField]:
"""Global notifications settings."""
return [
HeadingField(
key="notifications_heading",
title="Global Notifications",
description=(
"Global notifications send selected events for all users to configured routes. "
"Users can manage personal notifications in User Preferences."
),
),
TableField(
key="ADMIN_NOTIFICATION_ROUTES",
label="",
description=(
"Create one route per URL. Start with All, then add event-specific routes "
"for targeted delivery. Need format examples? "
"[View Apprise URL formats](https://appriseit.com/services/)."
),
columns=[
{
"key": "event",
"label": "Event",
"type": "multiselect",
"options": _ROUTE_EVENT_OPTIONS,
"defaultValue": [_ROUTE_EVENT_ALL],
"placeholder": "Select events...",
},
{
"key": "url",
"label": "Notification URL",
"type": "text",
"placeholder": "e.g. ntfys://ntfy.sh/shelfmark",
},
],
default=[dict(row) for row in _DEFAULT_ROUTE_ROWS],
add_label="Add Route",
empty_message="No routes configured.",
),
ActionButton(
key="test_admin_notification",
label="Test Notification",
description="Send a test notification to all configured global route URLs.",
style="primary",
callback=_test_admin_notification_action,
),
TableField(
key="USER_NOTIFICATION_ROUTES",
label="",
description=(
"Create one route per URL. Start with All, then add event-specific routes "
"for targeted delivery. Need format examples? "
"[View Apprise URL formats](https://appriseit.com/services/)."
),
columns=[
{
"key": "event",
"label": "Event",
"type": "multiselect",
"options": _ROUTE_EVENT_OPTIONS,
"defaultValue": [_ROUTE_EVENT_ALL],
"placeholder": "Select events...",
},
{
"key": "url",
"label": "Notification URL",
"type": "text",
"placeholder": "e.g. ntfys://ntfy.sh/username-topic",
},
],
default=[dict(row) for row in _DEFAULT_ROUTE_ROWS],
add_label="Add Route",
empty_message="No routes configured.",
user_overridable=True,
hidden_in_ui=True,
),
]
+300
View File
@@ -0,0 +1,300 @@
"""Authentication settings registration."""
from typing import TYPE_CHECKING, Any
from shelfmark.config.migrations import migrate_security_settings
from shelfmark.config.security_handlers import (
check_oidc_connection,
on_save_security,
)
from shelfmark.core.config import config as app_config
from shelfmark.core.logger import setup_logger
from shelfmark.core.settings_registry import (
ActionButton,
CheckboxField,
CustomComponentField,
PasswordField,
SelectField,
SettingsField,
TagListField,
TextField,
load_config_file,
register_on_save,
register_settings,
)
from shelfmark.core.user_db import sync_builtin_admin_user
if TYPE_CHECKING:
from collections.abc import Callable
logger = setup_logger(__name__)
def _auth_condition(auth_method: str) -> dict[str, str]:
return {"field": "AUTH_METHOD", "value": auth_method}
def _auth_field(factory: Callable[..., Any], auth_method: str, **kwargs: Any) -> Any:
return factory(show_when=_auth_condition(auth_method), **kwargs)
def _migrate_security_settings() -> None:
from shelfmark.core.settings_registry import (
_ensure_config_dir,
_get_config_file_path,
save_config_file,
)
def _save_users_config(values: dict[str, Any]) -> None:
save_config_file("users", values)
migrate_security_settings(
load_security_config=lambda: load_config_file("security"),
load_users_config=lambda: load_config_file("users"),
save_users_config=_save_users_config,
ensure_config_dir=lambda: _ensure_config_dir("security"),
get_config_path=lambda: _get_config_file_path("security"),
sync_builtin_admin_user=sync_builtin_admin_user,
logger=logger,
)
def _on_save_security(values: dict[str, Any]) -> dict[str, Any]:
return on_save_security(values)
def _test_oidc_connection(current_values: dict[str, Any] | None = None) -> dict[str, Any]:
return check_oidc_connection(
load_security_config=lambda: {
"OIDC_DISCOVERY_URL": app_config.get("OIDC_DISCOVERY_URL", ""),
},
current_values=current_values or {},
logger=logger,
)
@register_settings("security", "Security", icon="shield", order=5)
def security_settings() -> list[SettingsField]:
"""Security and authentication settings."""
from shelfmark.config.env import CWA_DB_PATH
cwa_db_available = CWA_DB_PATH is not None and CWA_DB_PATH.exists()
auth_method_options = [
{"label": "No Authentication", "value": "none"},
{"label": "Local", "value": "builtin"},
{"label": "Proxy Authentication", "value": "proxy"},
{"label": "OIDC (OpenID Connect)", "value": "oidc"},
{"label": "Calibre-Web Database", "value": "cwa"},
]
fields = [
SelectField(
key="AUTH_METHOD",
label="Authentication Method",
description=(
"Select the authentication method for accessing Shelfmark. "
"Restart container after changing Calibre-Web passwords."
),
options=auth_method_options,
default="none",
),
CustomComponentField(
key="builtin_admin_requirement",
component="oidc_admin_hint",
label=(
"Local authentication is inactive until a local admin account with a "
"password is created."
),
show_when=_auth_condition("builtin"),
),
CustomComponentField(
key="oidc_admin_requirement",
component="oidc_admin_hint",
label="A local admin account is required before OIDC can be enabled.",
show_when=_auth_condition("oidc"),
),
*(
[]
if cwa_db_available
else [
CustomComponentField(
key="cwa_db_missing",
component="oidc_admin_hint",
label=(
"Calibre-Web database not detected. Mount your app.db to "
"/auth/app.db to enable this method. Authentication will fall "
"back to none until the database is available."
),
show_when=_auth_condition("cwa"),
),
]
),
ActionButton(
key="open_users_tab",
label="Go to Users",
description="Configure local users and admin access in the Users tab.",
style="primary",
show_when={"field": "AUTH_METHOD", "value": ["builtin", "oidc"]},
),
_auth_field(
TextField,
"proxy",
key="PROXY_AUTH_USER_HEADER",
label="Proxy Auth User Header",
description="The HTTP header your proxy uses to pass the authenticated username.",
placeholder="e.g. X-Auth-User",
default="X-Auth-User",
),
_auth_field(
TextField,
"proxy",
key="PROXY_AUTH_LOGOUT_URL",
label="Proxy Auth Logout URL",
description="The URL to redirect users to for logging out. Leave empty to disable logout functionality.",
placeholder="https://myauth.example.com/logout",
default="",
),
_auth_field(
TextField,
"proxy",
key="PROXY_AUTH_ADMIN_GROUP_HEADER",
label="Proxy Auth Admin Group Header",
description="Optional: header your proxy uses to pass user groups/roles.",
placeholder="e.g. X-Auth-Groups",
default="X-Auth-Groups",
),
_auth_field(
TextField,
"proxy",
key="PROXY_AUTH_ADMIN_GROUP_NAME",
label="Proxy Auth Admin Group",
description="Optional: users in this group are treated as admins. Leave blank to skip group-based admin detection.",
placeholder="e.g. admins",
default="",
),
]
fields.append(
CustomComponentField(
key="oidc_callback_url",
component="settings_label",
label="Callback URL",
description="{origin}/api/auth/oidc/callback",
show_when=_auth_condition("oidc"),
)
)
oidc_specs = [
(
TextField,
{
"key": "OIDC_DISCOVERY_URL",
"label": "Discovery URL",
"description": "OpenID Connect discovery endpoint URL. Usually ends with /.well-known/openid-configuration.",
"placeholder": "https://auth.example.com/.well-known/openid-configuration",
"required": True,
},
),
(
TextField,
{
"key": "OIDC_CLIENT_ID",
"label": "Client ID",
"description": "OAuth2 client ID from your identity provider.",
"placeholder": "shelfmark",
"required": True,
},
),
(
PasswordField,
{
"key": "OIDC_CLIENT_SECRET",
"label": "Client Secret",
"description": "OAuth2 client secret from your identity provider.",
"required": True,
},
),
(
TagListField,
{
"key": "OIDC_SCOPES",
"label": "Scopes",
"description": "OAuth2 scopes to request from the identity provider. Managed automatically: includes essential scopes and the group claim when using admin group authorization.",
"default": ["openid", "email", "profile"],
},
),
(
TextField,
{
"key": "OIDC_GROUP_CLAIM",
"label": "Group Claim Name",
"description": "The name of the claim in the ID token that contains user groups.",
"placeholder": "groups",
"default": "groups",
},
),
(
TextField,
{
"key": "OIDC_ADMIN_GROUP",
"label": "Admin Group Name",
"description": "Users in this group will be given admin access (if enabled below). Leave empty to use database roles only.",
"placeholder": "shelfmark-admins",
"default": "",
},
),
(
CheckboxField,
{
"key": "OIDC_USE_ADMIN_GROUP",
"label": "Use Admin Group for Authorization",
"description": "When enabled, users in the Admin Group are granted admin access. When disabled, admin access is determined solely by database roles.",
"default": True,
},
),
(
CheckboxField,
{
"key": "OIDC_AUTO_PROVISION",
"label": "Auto-Provision Users",
"description": "Automatically create a user account on first OIDC login. When disabled, users must be pre-created by an admin.",
"default": True,
},
),
(
TextField,
{
"key": "OIDC_BUTTON_LABEL",
"label": "Login Button Label",
"description": "Custom label for the OIDC sign-in button on the login page.",
"placeholder": "Sign in with OIDC",
"default": "",
},
),
]
fields.extend(_auth_field(factory, "oidc", **spec) for factory, spec in oidc_specs)
fields.append(
ActionButton(
key="test_oidc",
label="Test Connection",
description="Fetch the OIDC discovery document and validate configuration.",
style="primary",
callback=_test_oidc_connection,
show_when=_auth_condition("oidc"),
)
)
fields.append(
CustomComponentField(
key="oidc_env_info",
component="oidc_env_info",
label="Environment-Only Options",
description="These options can only be set via environment variables because changing them through the UI could lock you out of the application.",
wrap_in_field_wrapper=True,
show_when=_auth_condition("oidc"),
)
)
return fields
register_on_save("security", _on_save_security)
+128
View File
@@ -0,0 +1,128 @@
"""Operational handlers for security settings (save/actions)."""
import os
from pathlib import Path
from typing import TYPE_CHECKING, Any
from shelfmark.core.user_db import UserDB
from shelfmark.core.utils import normalize_http_url
from shelfmark.download.network import get_ssl_verify
if TYPE_CHECKING:
from collections.abc import Callable
_OIDC_LOCKOUT_MESSAGE = "A local admin account with a password is required before enabling OIDC. Use the 'Go to Users' button above to create one. This ensures you can still sign in if your identity provider is unavailable."
_OIDC_REQUIRED_FIELDS = (
("OIDC_DISCOVERY_URL", "Discovery URL"),
("OIDC_CLIENT_ID", "Client ID"),
("OIDC_CLIENT_SECRET", "Client Secret"),
)
def _has_local_password_admin() -> bool:
root = os.environ.get("CONFIG_DIR", "/config")
user_db = UserDB(str(Path(root) / "users.db"))
user_db.initialize()
return any(
user.get("password_hash") and user.get("role") == "admin" for user in user_db.list_users()
)
def _load_effective_security_values(values: dict[str, Any]) -> dict[str, Any]:
"""Merge the current save payload onto the persisted security config."""
from shelfmark.core.settings_registry import load_config_file
effective_values = load_config_file("security")
effective_values.update(values)
return effective_values
def _get_missing_oidc_required_fields(effective_values: dict[str, Any]) -> list[str]:
"""Return missing required OIDC field labels from the effective config."""
missing_fields: list[str] = []
for key, label in _OIDC_REQUIRED_FIELDS:
value = effective_values.get(key)
if value is None:
missing_fields.append(label)
continue
if isinstance(value, str) and not value.strip():
missing_fields.append(label)
return missing_fields
def on_save_security(
values: dict[str, Any],
) -> dict[str, Any]:
"""Validate security values before persistence."""
normalized_values = values.copy()
discovery_url = normalized_values.get("OIDC_DISCOVERY_URL")
if discovery_url is not None:
normalized_values["OIDC_DISCOVERY_URL"] = normalize_http_url(
str(discovery_url),
default_scheme="https",
strip_trailing_slash=False,
)
proxy_logout_url = normalized_values.get("PROXY_AUTH_LOGOUT_URL")
if proxy_logout_url is not None:
normalized_values["PROXY_AUTH_LOGOUT_URL"] = normalize_http_url(
str(proxy_logout_url),
default_scheme="https",
strip_trailing_slash=False,
)
effective_values = _load_effective_security_values(normalized_values)
auth_method = str(effective_values.get("AUTH_METHOD", "") or "").strip().lower()
if auth_method == "oidc":
if not _has_local_password_admin():
return {"error": True, "message": _OIDC_LOCKOUT_MESSAGE, "values": normalized_values}
missing_fields = _get_missing_oidc_required_fields(effective_values)
if missing_fields:
missing_fields_text = ", ".join(missing_fields)
return {
"error": True,
"message": f"OIDC configuration is incomplete: missing {missing_fields_text}.",
"values": normalized_values,
}
return {"error": False, "values": normalized_values}
def check_oidc_connection(
*,
load_security_config: Callable[[], dict[str, Any]],
current_values: dict[str, Any] | None = None,
logger: Any,
) -> dict[str, Any]:
"""Fetch and validate the configured OIDC discovery document."""
import requests
try:
# Prefer the current (unsaved) form value over the saved config
discovery_url = (current_values or {}).get(
"OIDC_DISCOVERY_URL"
) or load_security_config().get("OIDC_DISCOVERY_URL", "")
if not discovery_url:
return {"success": False, "message": "Discovery URL is not configured."}
response = requests.get(discovery_url, timeout=10, verify=get_ssl_verify(discovery_url))
response.raise_for_status()
document = response.json()
required_fields = ["issuer", "authorization_endpoint", "token_endpoint"]
missing_fields = [field for field in required_fields if field not in document]
if missing_fields:
return {
"success": False,
"message": f"Discovery document missing fields: {', '.join(missing_fields)}",
}
return {"success": True, "message": f"Connected to {document['issuer']}"}
except Exception as exc:
logger.exception("OIDC connection test failed")
return {"success": False, "message": f"Connection failed: {exc!s}"}
File diff suppressed because it is too large Load Diff
+411
View File
@@ -0,0 +1,411 @@
"""Users settings tab registration.
This registers a 'users' tab in the settings sidebar.
The actual user management is handled by a custom frontend component
that talks to /api/admin/users endpoints.
"""
from typing import Any
from shelfmark.core.request_policy import (
get_source_content_type_capabilities,
parse_policy_mode,
validate_policy_rules,
)
from shelfmark.core.settings_registry import (
CheckboxField,
CustomComponentField,
HeadingField,
MultiSelectField,
NumberField,
SelectField,
SettingsField,
TableField,
register_on_save,
register_settings,
)
_REQUEST_DEFAULT_MODE_OPTIONS = [
{
"value": "download",
"label": "Download",
"description": "Everything can be downloaded directly.",
},
{
"value": "request_release",
"label": "Request Release",
"description": "Users must request a specific release.",
},
{
"value": "request_book",
"label": "Request Book",
"description": "Users request a book, admin picks the release.",
},
{
"value": "blocked",
"label": "Blocked",
"description": "No downloads or requests allowed.",
},
]
_REQUEST_MATRIX_MODE_OPTIONS = [
option for option in _REQUEST_DEFAULT_MODE_OPTIONS if option["value"] != "request_book"
]
_SELF_SETTINGS_SECTION_OPTIONS = [
{
"value": "delivery",
"label": "Delivery Preferences",
"description": "Show personal delivery output and destination settings.",
},
{
"value": "search",
"label": "Search Preferences",
"description": "Show personal search mode and provider settings.",
},
{
"value": "notifications",
"label": "Notifications",
"description": "Show personal notification route settings.",
},
]
_SELF_SETTINGS_SECTION_VALUES = {option["value"] for option in _SELF_SETTINGS_SECTION_OPTIONS}
_SELF_SETTINGS_SECTION_DEFAULTS = [option["value"] for option in _SELF_SETTINGS_SECTION_OPTIONS]
_SEARCH_MODE_VALUES = {"direct", "universal"}
_SEARCH_PREFERENCE_PROVIDER_KEYS = {
"METADATA_PROVIDER",
"METADATA_PROVIDER_AUDIOBOOK",
"METADATA_PROVIDER_COMBINED",
}
_SEARCH_PREFERENCE_VALIDATABLE_KEYS = {
"SEARCH_MODE",
"DEFAULT_RELEASE_SOURCE",
"DEFAULT_RELEASE_SOURCE_AUDIOBOOK",
"SHOW_COMBINED_SELECTOR",
*_SEARCH_PREFERENCE_PROVIDER_KEYS,
}
_USERS_HEADING_DESCRIPTION_BY_AUTH_MODE = {
"builtin": (
"Create and manage user accounts directly. Passwords are stored locally and users sign in "
"with their username and password."
),
"oidc": (
"Users sign in through your identity provider. New accounts can be created automatically on "
"first login when auto-provisioning is enabled, or you can pre-create users here and they\u2019ll "
"be linked by email on first sign-in."
),
"proxy": (
"Users are authenticated by your reverse proxy. Accounts are automatically created on first "
"sign-in. If a local user with a matching username already exists, it will be linked instead."
),
"cwa": (
"User accounts are synced from your Calibre-Web database. Users are matched by email, and new "
"accounts are created here when new CWA users are found."
),
"none": "Authentication is disabled. Anyone can access Shelfmark without signing in.",
"default": "Authentication is disabled. Anyone can access Shelfmark without signing in.",
}
def _get_request_source_options() -> list[dict[str, str]]:
"""Build request-policy source options from registered release sources."""
from shelfmark.release_sources import list_available_sources
return [
{
"value": source["name"],
"label": source["display_name"],
}
for source in list_available_sources()
]
def _get_valid_release_source_names_for_content_type(content_type: str) -> set[str]:
"""Return registered release source names that support the requested content type."""
from shelfmark.release_sources import list_available_sources
valid_sources: set[str] = set()
for source in list_available_sources():
supported_types = source.get("supported_content_types", ["ebook", "audiobook"])
if content_type in supported_types:
valid_sources.add(source["name"])
return valid_sources
def _get_request_policy_rule_columns() -> list[dict[str, object]]:
source_capabilities = get_source_content_type_capabilities()
content_type_options = []
for source_name, supported_types in source_capabilities.items():
normalized_types = [t for t in ("ebook", "audiobook") if t in supported_types]
content_type_options.extend(
{
"value": content_type,
"label": "Ebook" if content_type == "ebook" else "Audiobook",
"childOf": source_name,
}
for content_type in normalized_types
)
return [
{
"key": "source",
"label": "Source",
"type": "select",
"options": _get_request_source_options(),
"defaultValue": "",
"placeholder": "Select source...",
},
{
"key": "content_type",
"label": "Content Type",
"type": "select",
"options": content_type_options,
"defaultValue": "",
"placeholder": "Select content type...",
"filterByField": "source",
},
{
"key": "mode",
"label": "Mode",
"type": "select",
"options": _REQUEST_MATRIX_MODE_OPTIONS,
"defaultValue": "",
"placeholder": "Select mode...",
},
]
def validate_search_preference_value(key: str, value: Any) -> tuple[Any, str | None]:
"""Validate and normalize a search preference value for user overrides."""
if key not in _SEARCH_PREFERENCE_VALIDATABLE_KEYS:
return value, None
if value is None:
return None, None
normalized_value = str(value).strip()
if key == "SEARCH_MODE":
normalized_mode = normalized_value.lower()
if normalized_mode not in _SEARCH_MODE_VALUES:
return value, "SEARCH_MODE must be 'direct' or 'universal'"
return normalized_mode, None
if key in _SEARCH_PREFERENCE_PROVIDER_KEYS:
if normalized_value == "":
return "", None
from shelfmark.metadata_providers import is_provider_registered
if not is_provider_registered(normalized_value):
return (
value,
f"{key} must be a valid metadata provider name or empty",
)
return normalized_value, None
if key in {"DEFAULT_RELEASE_SOURCE", "DEFAULT_RELEASE_SOURCE_AUDIOBOOK"}:
if normalized_value == "":
return "", None
valid_sources = _get_valid_release_source_names_for_content_type(
"audiobook" if key == "DEFAULT_RELEASE_SOURCE_AUDIOBOOK" else "ebook"
)
if normalized_value not in valid_sources:
return (
value,
f"{key} must be a valid release source name or empty",
)
return normalized_value, None
if key == "SHOW_COMBINED_SELECTOR":
if isinstance(value, bool):
return value, None
return bool(value), None
return value, None
def _on_save_users(values: dict[str, object]) -> dict[str, object]:
"""Validate users/request-policy settings before persistence."""
if "VISIBLE_SELF_SETTINGS_SECTIONS" in values:
raw_sections = values["VISIBLE_SELF_SETTINGS_SECTIONS"]
if raw_sections is None:
candidate_sections: list[str] = []
elif isinstance(raw_sections, str):
candidate_sections = [s.strip() for s in raw_sections.split(",") if s.strip()]
elif isinstance(raw_sections, (list, tuple, set)):
candidate_sections = [
str(section).strip() for section in raw_sections if str(section).strip()
]
else:
return {
"error": True,
"message": "VISIBLE_SELF_SETTINGS_SECTIONS must be a list of section identifiers",
"values": values,
}
normalized_sections: list[str] = []
for section in candidate_sections:
if section not in _SELF_SETTINGS_SECTION_VALUES:
allowed = ", ".join(sorted(_SELF_SETTINGS_SECTION_VALUES))
return {
"error": True,
"message": (
"VISIBLE_SELF_SETTINGS_SECTIONS contains an unsupported section "
f"'{section}'. Supported values: {allowed}"
),
"values": values,
}
if section not in normalized_sections:
normalized_sections.append(section)
values["VISIBLE_SELF_SETTINGS_SECTIONS"] = normalized_sections
if (
"REQUEST_POLICY_DEFAULT_EBOOK" in values
and parse_policy_mode(values["REQUEST_POLICY_DEFAULT_EBOOK"]) is None
):
return {
"error": True,
"message": "REQUEST_POLICY_DEFAULT_EBOOK must be a valid policy mode",
"values": values,
}
if (
"REQUEST_POLICY_DEFAULT_AUDIOBOOK" in values
and parse_policy_mode(values["REQUEST_POLICY_DEFAULT_AUDIOBOOK"]) is None
):
return {
"error": True,
"message": "REQUEST_POLICY_DEFAULT_AUDIOBOOK must be a valid policy mode",
"values": values,
}
if "REQUEST_POLICY_RULES" in values:
normalized_rules, errors = validate_policy_rules(values["REQUEST_POLICY_RULES"])
if errors:
return {
"error": True,
"message": "; ".join(errors),
"values": values,
}
values["REQUEST_POLICY_RULES"] = normalized_rules
for key in _SEARCH_PREFERENCE_VALIDATABLE_KEYS:
if key not in values:
continue
normalized_value, validation_error = validate_search_preference_value(key, values[key])
if validation_error:
return {
"error": True,
"message": validation_error,
"values": values,
}
values[key] = normalized_value
return {"error": False, "values": values}
register_on_save("users", _on_save_users)
@register_settings("users", "Users & Requests", icon="users", order=6)
def users_settings() -> list[SettingsField]:
"""User management tab - rendered as a custom component on the frontend."""
return [
HeadingField(
key="users_heading",
title="Users",
description=_USERS_HEADING_DESCRIPTION_BY_AUTH_MODE["default"],
description_by_auth_mode=_USERS_HEADING_DESCRIPTION_BY_AUTH_MODE,
),
CustomComponentField(
key="users_management",
component="users_management",
),
MultiSelectField(
key="VISIBLE_SELF_SETTINGS_SECTIONS",
label="Visible Self-Settings Sections",
description=(
"Choose which personal settings sections are shown in My Account for non-admin users."
),
options=_SELF_SETTINGS_SECTION_OPTIONS,
default=_SELF_SETTINGS_SECTION_DEFAULTS,
variant="dropdown",
env_supported=False,
),
HeadingField(
key="requests_heading",
title="Requests",
description=("Choose what users can download directly and what needs approval first."),
),
CheckboxField(
key="REQUESTS_ENABLED",
label="Enable Requests",
description=(
"Turn this off to let everyone download directly without needing approval."
),
default=False,
user_overridable=True,
),
CustomComponentField(
key="request_policy_editor",
component="request_policy_grid",
label="Request Rules",
description=(
"Fine-tune access per source. Source rules can only be the same or more restrictive than the default above."
),
show_when={"field": "REQUESTS_ENABLED", "value": True},
wrap_in_field_wrapper=True,
value_fields=[
SelectField(
key="REQUEST_POLICY_DEFAULT_EBOOK",
label="Default Ebook Mode",
description=("Sets the baseline for all ebook sources."),
options=_REQUEST_DEFAULT_MODE_OPTIONS,
default="download",
user_overridable=True,
),
SelectField(
key="REQUEST_POLICY_DEFAULT_AUDIOBOOK",
label="Default Audiobook Mode",
description=("Sets the baseline for all audiobook sources."),
options=_REQUEST_DEFAULT_MODE_OPTIONS,
default="download",
user_overridable=True,
),
TableField(
key="REQUEST_POLICY_RULES",
label="Request Rules",
description=(
"Fine-tune access per source. Source rules can only be the same or more restrictive than the default above."
),
columns=_get_request_policy_rule_columns,
default=[],
add_label="Add Rule",
empty_message="No request policy rules configured.",
env_supported=False,
user_overridable=True,
),
],
),
NumberField(
key="MAX_PENDING_REQUESTS_PER_USER",
label="Max pending requests per user",
description="How many open requests a user can have at a time.",
default=20,
min_value=1,
max_value=1000,
user_overridable=True,
show_when={"field": "REQUESTS_ENABLED", "value": True},
),
CheckboxField(
key="REQUESTS_ALLOW_NOTES",
label="Allow notes on requests",
description="Let users add a note when they submit a request.",
default=True,
user_overridable=True,
show_when={"field": "REQUESTS_ENABLED", "value": True},
),
]
+14
View File
@@ -0,0 +1,14 @@
"""Core module - shared models, queue, and utilities."""
from shelfmark.core.logger import setup_logger
from shelfmark.core.models import QueueItem, QueueStatus, SearchFilters
from shelfmark.core.queue import BookQueue, book_queue
__all__ = [
"BookQueue",
"QueueItem",
"QueueStatus",
"SearchFilters",
"book_queue",
"setup_logger",
]
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,327 @@
"""Persistence helpers for per-viewer activity visibility state."""
from __future__ import annotations
import sqlite3
import threading
from typing import Any
from shelfmark.core.request_helpers import now_utc_iso
VALID_ACTIVITY_ITEM_TYPES = frozenset({"download", "request"})
ADMIN_VIEWER_SCOPE = "admin:shared"
NOAUTH_VIEWER_SCOPE = "noauth:shared"
USER_VIEWER_SCOPE_PREFIX = "user:"
def user_viewer_scope(user_id: int) -> str:
"""Build the persisted viewer scope string for a specific user."""
if not isinstance(user_id, int) or user_id < 1:
msg = "user_id must be a positive integer"
raise ValueError(msg)
return f"{USER_VIEWER_SCOPE_PREFIX}{user_id}"
def normalize_viewer_scope(viewer_scope: object) -> str:
"""Validate and normalize a persisted viewer scope string."""
if not isinstance(viewer_scope, str) or not viewer_scope.strip():
msg = "viewer_scope must be a non-empty string"
raise ValueError(msg)
normalized = viewer_scope.strip()
if normalized in {ADMIN_VIEWER_SCOPE, NOAUTH_VIEWER_SCOPE}:
return normalized
if not normalized.startswith(USER_VIEWER_SCOPE_PREFIX):
msg = "viewer_scope must be one of: admin:shared, noauth:shared, or user:<id>"
raise ValueError(msg)
raw_user_id = normalized[len(USER_VIEWER_SCOPE_PREFIX) :].strip()
try:
parsed_user_id = int(raw_user_id)
except (TypeError, ValueError) as exc:
msg = "viewer_scope user id must be a positive integer"
raise ValueError(msg) from exc
return user_viewer_scope(parsed_user_id)
def _normalize_item_type(item_type: object) -> str:
if not isinstance(item_type, str) or not item_type.strip():
msg = "item_type must be a non-empty string"
raise ValueError(msg)
normalized = item_type.strip().lower()
if normalized not in VALID_ACTIVITY_ITEM_TYPES:
msg = "item_type must be one of: download, request"
raise ValueError(msg)
return normalized
def _normalize_item_key(item_key: object, *, item_type: str) -> str:
if not isinstance(item_key, str) or not item_key.strip():
msg = "item_key must be a non-empty string"
raise ValueError(msg)
normalized = item_key.strip()
expected_prefix = f"{item_type}:"
if not normalized.startswith(expected_prefix):
msg_0 = f"item_key must be in the format {expected_prefix}<id>"
raise ValueError(msg_0)
if not normalized.split(":", 1)[1].strip():
msg_0 = f"item_key must be in the format {expected_prefix}<id>"
raise ValueError(msg_0)
return normalized
class ActivityViewStateService:
"""Service for per-viewer activity dismissal and history visibility."""
def __init__(self, db_path: str) -> None:
"""Initialize the service with the SQLite state database path."""
self._db_path = db_path
self._lock = threading.Lock()
def _connect(self) -> sqlite3.Connection:
conn = sqlite3.connect(self._db_path)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA foreign_keys = ON")
return conn
def list_hidden(
self,
*,
viewer_scope: str,
limit: int | None = None,
) -> list[dict[str, Any]]:
"""Return dismissed rows for a viewer, including cleared history entries."""
normalized_scope = normalize_viewer_scope(viewer_scope)
normalized_limit = None if limit is None else max(1, int(limit))
query = """
SELECT item_type, item_key, dismissed_at, cleared_at
FROM activity_view_state
WHERE viewer_scope = ?
AND dismissed_at IS NOT NULL
ORDER BY COALESCE(cleared_at, dismissed_at) DESC, id DESC
"""
params: list[Any] = [normalized_scope]
if normalized_limit is not None:
query += "\nLIMIT ?"
params.append(normalized_limit)
conn = self._connect()
try:
rows = conn.execute(query, params).fetchall()
return [dict(row) for row in rows]
finally:
conn.close()
def list_history(
self,
*,
viewer_scope: str,
limit: int = 50,
offset: int = 0,
) -> list[dict[str, Any]]:
"""Return active dismissal history rows for a viewer."""
normalized_scope = normalize_viewer_scope(viewer_scope)
normalized_limit = max(1, min(int(limit), 5000))
normalized_offset = max(0, int(offset))
conn = self._connect()
try:
rows = conn.execute(
"""
SELECT item_type, item_key, dismissed_at
FROM activity_view_state
WHERE viewer_scope = ?
AND dismissed_at IS NOT NULL
AND cleared_at IS NULL
ORDER BY dismissed_at DESC, id DESC
LIMIT ? OFFSET ?
""",
(normalized_scope, normalized_limit, normalized_offset),
).fetchall()
return [dict(row) for row in rows]
finally:
conn.close()
def dismiss(
self,
*,
viewer_scope: str,
item_type: str,
item_key: str,
) -> int:
"""Mark a single activity item as dismissed for a viewer."""
normalized_scope = normalize_viewer_scope(viewer_scope)
normalized_type = _normalize_item_type(item_type)
normalized_key = _normalize_item_key(item_key, item_type=normalized_type)
dismissed_at = now_utc_iso()
with self._lock:
conn = self._connect()
try:
cursor = conn.execute(
"""
INSERT INTO activity_view_state (
viewer_scope,
item_type,
item_key,
dismissed_at,
cleared_at
)
VALUES (?, ?, ?, ?, NULL)
ON CONFLICT(viewer_scope, item_type, item_key) DO UPDATE SET
dismissed_at = excluded.dismissed_at,
cleared_at = NULL
""",
(normalized_scope, normalized_type, normalized_key, dismissed_at),
)
conn.commit()
rowcount = int(cursor.rowcount) if cursor.rowcount is not None else 0
return max(rowcount, 0)
finally:
conn.close()
def dismiss_many(
self,
*,
viewer_scope: str,
items: list[dict[str, str]],
) -> int:
"""Mark multiple activity items as dismissed for a viewer."""
normalized_scope = normalize_viewer_scope(viewer_scope)
if not items:
return 0
seen: set[tuple[str, str]] = set()
normalized_items: list[tuple[str, str]] = []
for item in items:
normalized_type = _normalize_item_type(item.get("item_type"))
normalized_key = _normalize_item_key(item.get("item_key"), item_type=normalized_type)
marker = (normalized_type, normalized_key)
if marker in seen:
continue
seen.add(marker)
normalized_items.append(marker)
if not normalized_items:
return 0
dismissed_at = now_utc_iso()
with self._lock:
conn = self._connect()
try:
total = 0
for normalized_type, normalized_key in normalized_items:
cursor = conn.execute(
"""
INSERT INTO activity_view_state (
viewer_scope,
item_type,
item_key,
dismissed_at,
cleared_at
)
VALUES (?, ?, ?, ?, NULL)
ON CONFLICT(viewer_scope, item_type, item_key) DO UPDATE SET
dismissed_at = excluded.dismissed_at,
cleared_at = NULL
""",
(
normalized_scope,
normalized_type,
normalized_key,
dismissed_at,
),
)
rowcount = int(cursor.rowcount) if cursor.rowcount is not None else 0
total += max(rowcount, 0)
conn.commit()
return total
finally:
conn.close()
def clear_history(self, *, viewer_scope: str) -> int:
"""Mark all dismissed items as cleared for a viewer."""
normalized_scope = normalize_viewer_scope(viewer_scope)
cleared_at = now_utc_iso()
with self._lock:
conn = self._connect()
try:
cursor = conn.execute(
"""
UPDATE activity_view_state
SET cleared_at = ?
WHERE viewer_scope = ?
AND dismissed_at IS NOT NULL
AND cleared_at IS NULL
""",
(cleared_at, normalized_scope),
)
conn.commit()
rowcount = int(cursor.rowcount) if cursor.rowcount is not None else 0
return max(rowcount, 0)
finally:
conn.close()
def clear_item_for_all_viewers(self, *, item_type: str, item_key: str) -> int:
"""Delete a dismissed item record for every viewer."""
normalized_type = _normalize_item_type(item_type)
normalized_key = _normalize_item_key(item_key, item_type=normalized_type)
with self._lock:
conn = self._connect()
try:
cursor = conn.execute(
"""
DELETE FROM activity_view_state
WHERE item_type = ? AND item_key = ?
""",
(normalized_type, normalized_key),
)
conn.commit()
rowcount = int(cursor.rowcount) if cursor.rowcount is not None else 0
return max(rowcount, 0)
finally:
conn.close()
def delete_viewer_scope(self, *, viewer_scope: str) -> int:
"""Delete all activity-view state rows for a viewer scope."""
normalized_scope = normalize_viewer_scope(viewer_scope)
with self._lock:
conn = self._connect()
try:
cursor = conn.execute(
"DELETE FROM activity_view_state WHERE viewer_scope = ?",
(normalized_scope,),
)
conn.commit()
rowcount = int(cursor.rowcount) if cursor.rowcount is not None else 0
return max(rowcount, 0)
finally:
conn.close()
def delete_items(self, *, item_type: str, item_keys: list[str]) -> int:
"""Delete multiple dismissed item records for a given item type."""
normalized_type = _normalize_item_type(item_type)
normalized_keys = [
_normalize_item_key(item_key, item_type=normalized_type) for item_key in item_keys
]
if not normalized_keys:
return 0
with self._lock:
conn = self._connect()
try:
cursor = conn.executemany(
"DELETE FROM activity_view_state WHERE item_type = ? AND item_key = ?",
[(normalized_type, normalized_key) for normalized_key in normalized_keys],
)
conn.commit()
rowcount = int(cursor.rowcount) if cursor.rowcount is not None else 0
return max(rowcount, 0)
finally:
conn.close()
+474
View File
@@ -0,0 +1,474 @@
"""Admin user management API routes.
Registers /api/admin/users CRUD endpoints for managing users.
All endpoints require admin session.
"""
from __future__ import annotations
import os
import sqlite3
from functools import wraps
from typing import TYPE_CHECKING, Any, ParamSpec
from flask import Flask, Response, g, jsonify, request, session
from werkzeug.security import generate_password_hash
from shelfmark.config.booklore_settings import (
get_booklore_library_options,
get_booklore_path_options,
)
from shelfmark.config.env import CWA_DB_PATH
from shelfmark.core.admin_settings_routes import (
register_admin_settings_routes,
validate_user_settings,
)
from shelfmark.core.auth_modes import (
AUTH_SOURCE_BUILTIN,
AUTH_SOURCE_CWA,
AUTH_SOURCE_OIDC,
AUTH_SOURCE_PROXY,
is_user_active_for_auth_mode,
load_active_auth_mode,
normalize_auth_source,
)
from shelfmark.core.config import config as app_config
from shelfmark.core.cwa_user_sync import sync_cwa_users_from_rows
from shelfmark.core.logger import setup_logger
if TYPE_CHECKING:
from collections.abc import Callable
from flask.typing import ResponseReturnValue
from shelfmark.core.user_db import UserDB
P = ParamSpec("P")
logger = setup_logger(__name__)
MIN_PASSWORD_LENGTH = 4
_CONFIG_REFRESH_ERRORS = (ImportError, OSError, RuntimeError, TypeError, ValueError)
__all__ = [
"get_booklore_library_options",
"get_booklore_path_options",
"register_admin_routes",
"validate_user_settings",
]
def _get_user_edit_capabilities(
user: dict[str, Any],
security_config: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Return backend-authored capability flags for the user edit form."""
auth_source = normalize_auth_source(
user.get("auth_source"),
user.get("oidc_subject"),
)
oidc_use_admin_group = bool(
(security_config or {}).get(
"OIDC_USE_ADMIN_GROUP",
app_config.get("OIDC_USE_ADMIN_GROUP", True),
)
)
role_managed_by_oidc_group = auth_source == AUTH_SOURCE_OIDC and oidc_use_admin_group
can_edit_role = auth_source == AUTH_SOURCE_BUILTIN or (
auth_source == AUTH_SOURCE_OIDC and not role_managed_by_oidc_group
)
return {
"authSource": auth_source,
"canSetPassword": auth_source == AUTH_SOURCE_BUILTIN,
"canEditRole": can_edit_role,
"canEditEmail": auth_source in {AUTH_SOURCE_BUILTIN, AUTH_SOURCE_PROXY},
"canEditDisplayName": auth_source != AUTH_SOURCE_OIDC,
}
def _sanitize_user(user: dict) -> dict:
"""Remove sensitive fields from user dict before returning to client."""
sanitized = dict(user)
sanitized.pop("password_hash", None)
return sanitized
def _oidc_role_management_message(security_config: dict[str, Any] | None = None) -> str:
admin_group = (security_config or {}).get(
"OIDC_ADMIN_GROUP",
app_config.get("OIDC_ADMIN_GROUP", ""),
)
if admin_group:
return (
"Admin roles for OIDC users are managed by the "
f"'{admin_group}' group in your identity provider"
)
return (
"Disable 'Use Admin Group for Authorization' in security settings to manage roles manually"
)
def _serialize_user(
user: dict[str, Any],
auth_method: str,
security_config: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Sanitize and enrich a user payload for API responses."""
payload = _sanitize_user(user)
payload["auth_source"] = normalize_auth_source(
payload.get("auth_source"),
payload.get("oidc_subject"),
)
payload["is_active"] = is_user_active_for_auth_mode(payload, auth_method)
payload["edit_capabilities"] = _get_user_edit_capabilities(
payload,
security_config=security_config,
)
return payload
def _sync_all_cwa_users(user_db: UserDB) -> dict[str, int]:
"""Sync all users from the Calibre-Web database into users.db."""
if not CWA_DB_PATH or not CWA_DB_PATH.exists():
msg = "Calibre-Web database is not available"
raise FileNotFoundError(msg)
db_path = os.fspath(CWA_DB_PATH)
db_uri = f"file:{db_path}?mode=ro&immutable=1"
conn = sqlite3.connect(db_uri, uri=True)
try:
cur = conn.cursor()
cur.execute("SELECT name, role, email FROM user")
rows = cur.fetchall()
finally:
conn.close()
return sync_cwa_users_from_rows(user_db, rows)
def register_admin_routes(app: Flask, user_db: UserDB) -> None:
"""Register admin user management routes on the Flask app."""
def _require_admin(
f: Callable[P, ResponseReturnValue],
) -> Callable[P, ResponseReturnValue]:
"""Require an admin session for admin routes.
In no-auth mode, everyone has access (is_admin defaults True).
In auth-required modes, requires an authenticated session with admin role.
Caches the resolved auth_mode in ``g.auth_mode`` for the request.
"""
@wraps(f)
def decorated(*args: P.args, **kwargs: P.kwargs) -> ResponseReturnValue:
auth_mode = load_active_auth_mode(CWA_DB_PATH, user_db=user_db)
g.auth_mode = auth_mode
if auth_mode != "none":
if "user_id" not in session:
return jsonify({"error": "Authentication required"}), 401
if not session.get("is_admin", False):
return jsonify({"error": "Admin access required"}), 403
return f(*args, **kwargs)
return decorated
@app.route("/api/admin/users", methods=["GET"])
@_require_admin
def admin_list_users() -> Response | tuple[Response, int]:
"""List all users."""
users = user_db.list_users()
auth_mode = g.auth_mode
return jsonify([_serialize_user(u, auth_mode) for u in users])
@app.route("/api/admin/users", methods=["POST"])
@_require_admin
def admin_create_user() -> Response | tuple[Response, int]:
"""Create a new user with password authentication."""
data = request.get_json() or {}
auth_mode = g.auth_mode
username = (data.get("username") or "").strip()
password = data.get("password", "")
email = (data.get("email") or "").strip() or None
display_name = (data.get("display_name") or "").strip() or None
role = data.get("role", "user")
if auth_mode in {AUTH_SOURCE_PROXY, AUTH_SOURCE_CWA}:
return jsonify(
{
"error": "Local user creation is disabled in this authentication mode",
"message": (
"Users are provisioned by your external authentication source. "
"Switch to builtin or OIDC mode to create local users."
),
}
), 400
if not username:
return jsonify({"error": "Username is required"}), 400
if not password or len(password) < MIN_PASSWORD_LENGTH:
return jsonify(
{"error": f"Password must be at least {MIN_PASSWORD_LENGTH} characters"}
), 400
if role not in ("admin", "user"):
return jsonify({"error": "Role must be 'admin' or 'user'"}), 400
# First user is always admin
existing_users = user_db.list_users()
if not existing_users:
role = "admin"
# Check if username already exists
if user_db.get_user(username=username):
return jsonify({"error": "Username already exists"}), 409
password_hash = generate_password_hash(password)
try:
user = user_db.create_user(
username=username,
password_hash=password_hash,
email=email,
display_name=display_name,
auth_source=AUTH_SOURCE_BUILTIN,
role=role,
)
except ValueError:
return jsonify({"error": "Username already exists"}), 409
logger.info(
"Shelfmark user created (source=manual_admin_create, created_by=%s, username=%s, role=%s, auth_source=%s)",
session.get("user_id", "unknown"),
username,
role,
AUTH_SOURCE_BUILTIN,
)
return jsonify(
_serialize_user(
user,
g.auth_mode,
)
), 201
@app.route("/api/admin/users/<int:user_id>", methods=["GET"])
@_require_admin
def admin_get_user(user_id: int) -> Response | tuple[Response, int]:
"""Get a user by ID with their settings."""
user = user_db.get_user(user_id=user_id)
if not user:
return jsonify({"error": "User not found"}), 404
result = _serialize_user(
user,
g.auth_mode,
)
result["settings"] = user_db.get_user_settings(user_id)
return jsonify(result)
@app.route("/api/admin/users/<int:user_id>", methods=["PUT"])
@_require_admin
def admin_update_user(user_id: int) -> Response | tuple[Response, int]:
"""Update user fields and/or settings."""
user = user_db.get_user(user_id=user_id)
if not user:
return jsonify({"error": "User not found"}), 404
data = request.get_json() or {}
auth_source = normalize_auth_source(
user.get("auth_source"),
user.get("oidc_subject"),
)
capabilities = _get_user_edit_capabilities(user)
# Handle optional password update
password = data.get("password", "")
if password:
if not capabilities["canSetPassword"]:
return jsonify(
{
"error": f"Cannot set password for {auth_source.upper()} users",
"message": "Password authentication is only available for local users.",
}
), 400
if len(password) < MIN_PASSWORD_LENGTH:
return jsonify(
{"error": f"Password must be at least {MIN_PASSWORD_LENGTH} characters"}
), 400
user_db.update_user(user_id, password_hash=generate_password_hash(password))
# Update user fields
user_fields = {}
for field in ("role", "email", "display_name"):
if field in data:
user_fields[field] = data[field]
if "role" in user_fields and user_fields["role"] not in ("admin", "user"):
return jsonify({"error": "Role must be 'admin' or 'user'"}), 400
role_changed = "role" in user_fields and user_fields["role"] != user.get("role")
email_changed = "email" in user_fields and user_fields["email"] != user.get("email")
display_name_changed = "display_name" in user_fields and user_fields[
"display_name"
] != user.get("display_name")
if role_changed and not capabilities["canEditRole"]:
if auth_source == AUTH_SOURCE_OIDC:
return jsonify(
{
"error": "Cannot change role for OIDC user when group-based authorization is enabled",
"message": _oidc_role_management_message(),
}
), 400
return jsonify(
{
"error": f"Cannot change role for {auth_source.upper()} users",
"message": "Role is managed by the external authentication source.",
}
), 400
if email_changed and not capabilities["canEditEmail"]:
if auth_source == AUTH_SOURCE_CWA:
return jsonify(
{
"error": "Cannot change email for CWA users",
"message": "Email is synced from Calibre-Web.",
}
), 400
return jsonify(
{
"error": "Cannot change email for OIDC users",
"message": "Email is managed by your identity provider.",
}
), 400
if display_name_changed and not capabilities["canEditDisplayName"]:
return jsonify(
{
"error": "Cannot change display name for OIDC users",
"message": "Display name is managed by your identity provider.",
}
), 400
# Allow demoting the last admin account.
# Auth mode resolution automatically falls back to "none" when no
# local password admin remains.
# Avoid unnecessary writes for no-op field updates.
for field in ("role", "email", "display_name"):
if field in user_fields and user_fields[field] == user.get(field):
user_fields.pop(field)
if user_fields:
user_db.update_user(user_id, **user_fields)
# Update per-user settings
if "settings" in data:
if not isinstance(data["settings"], dict):
return jsonify({"error": "Settings must be an object"}), 400
validated_settings, validation_errors = validate_user_settings(data["settings"])
if validation_errors:
return jsonify(
{
"error": "Invalid settings payload",
"details": validation_errors,
}
), 400
user_db.set_user_settings(user_id, validated_settings)
# Ensure runtime reads see updated per-user overrides immediately.
try:
app_config.refresh(force=True)
except _CONFIG_REFRESH_ERRORS as exc:
logger.warning(
"Updated settings for user %s but failed to refresh runtime config: %s",
user_id,
exc,
)
updated = user_db.get_user(user_id=user_id)
if not isinstance(updated, dict):
return jsonify({"error": "User not found"}), 404
result = _serialize_user(
updated,
g.auth_mode,
)
result["settings"] = user_db.get_user_settings(user_id)
logger.info("Admin updated user %s", user_id)
return jsonify(result)
@app.route("/api/admin/users/sync-cwa", methods=["POST"])
@_require_admin
def admin_sync_cwa_users() -> Response | tuple[Response, int]:
"""Manually sync users from Calibre-Web into users.db."""
if g.auth_mode != AUTH_SOURCE_CWA:
return jsonify(
{
"error": "CWA sync is only available when CWA authentication is enabled",
}
), 400
try:
summary = _sync_all_cwa_users(user_db)
except FileNotFoundError:
return jsonify(
{
"error": "Calibre-Web database is not available",
"message": "Verify app.db is mounted and readable at /auth/app.db.",
}
), 503
except Exception:
logger.exception("Failed to sync CWA users")
return jsonify(
{
"error": "Failed to sync users from Calibre-Web",
}
), 500
message = (
f"Synced {summary['total']} CWA users "
f"({summary['created']} created, {summary['updated']} updated, "
f"{summary.get('deleted', 0)} deleted)."
)
logger.info(message)
return jsonify(
{
"success": True,
"message": message,
**summary,
}
)
register_admin_settings_routes(app, user_db, _require_admin)
@app.route("/api/admin/users/<int:user_id>", methods=["DELETE"])
@_require_admin
def admin_delete_user(user_id: int) -> Response | tuple[Response, int]:
"""Delete a user."""
# Prevent self-deletion
if session.get("db_user_id") == user_id:
return jsonify({"error": "Cannot delete your own account"}), 400
user = user_db.get_user(user_id=user_id)
if not user:
return jsonify({"error": "User not found"}), 404
auth_source = normalize_auth_source(
user.get("auth_source"),
user.get("oidc_subject"),
)
if auth_source == AUTH_SOURCE_CWA and auth_source == g.auth_mode:
return jsonify(
{
"error": f"Cannot delete active {auth_source.upper()} users",
"message": f"{auth_source.upper()} users are automatically re-provisioned on login.",
}
), 400
# Allow deleting the last local admin account.
# Auth mode resolution automatically falls back to "none" when no
# local password admin remains.
user_db.delete_user(user_id)
logger.info("Admin deleted user %s: %s", user_id, user["username"])
return jsonify({"success": True})
+310
View File
@@ -0,0 +1,310 @@
"""Admin settings-introspection routes and settings validation helpers."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from flask import Flask, jsonify, request
from shelfmark.config.notifications_settings import (
build_notification_test_result,
is_valid_notification_url,
normalize_notification_routes,
)
from shelfmark.config.users_settings import validate_search_preference_value
from shelfmark.core.config import config as app_config
from shelfmark.core.request_policy import parse_policy_mode, validate_policy_rules
from shelfmark.core.settings_registry import load_config_file
from shelfmark.core.user_settings_overrides import (
build_user_preferences_payload as _build_user_preferences_payload,
)
from shelfmark.core.user_settings_overrides import (
get_ordered_user_overridable_fields as _get_ordered_user_overridable_fields,
)
from shelfmark.core.user_settings_overrides import (
get_settings_registry as _get_settings_registry,
)
if TYPE_CHECKING:
from collections.abc import Callable
from flask.typing import ResponseReturnValue
from shelfmark.core.user_db import UserDB
def validate_user_settings(
settings: dict[str, Any],
) -> tuple[dict[str, Any], list[str]]:
"""Validate and normalize per-user settings overrides."""
settings_registry = _get_settings_registry()
field_map = settings_registry.get_settings_field_map()
overridable_map = settings_registry.get_user_overridable_fields()
valid: dict[str, Any] = {}
errors: list[str] = []
for key, value in settings.items():
if key not in field_map:
errors.append(f"Unknown setting: {key}")
elif key not in overridable_map:
errors.append(f"Setting not user-overridable: {key}")
else:
# null means "clear the per-user override; use global default"
if value is None:
valid[key] = None
continue
if (
key in {"REQUEST_POLICY_DEFAULT_EBOOK", "REQUEST_POLICY_DEFAULT_AUDIOBOOK"}
and parse_policy_mode(value) is None
):
errors.append(f"Invalid policy mode for {key}: {value}")
continue
if key == "REQUEST_POLICY_RULES":
normalized_rules, rule_errors = validate_policy_rules(value)
if rule_errors:
errors.extend(rule_errors)
continue
valid[key] = normalized_rules
continue
if key == "USER_NOTIFICATION_ROUTES":
normalized_routes = normalize_notification_routes(value)
invalid_count = sum(
1
for row in normalized_routes
if row.get("url") and not is_valid_notification_url(str(row.get("url")))
)
if invalid_count:
errors.append(
f"Invalid value for {key}: found {invalid_count} invalid URL(s). "
"Use URL values with a valid scheme, e.g. discord://... or ntfys://..."
)
continue
valid[key] = normalized_routes
continue
normalized_search_value, search_validation_error = validate_search_preference_value(
key, value
)
if search_validation_error:
errors.append(search_validation_error)
continue
if key in {
"SEARCH_MODE",
"METADATA_PROVIDER",
"METADATA_PROVIDER_AUDIOBOOK",
"DEFAULT_RELEASE_SOURCE",
"DEFAULT_RELEASE_SOURCE_AUDIOBOOK",
}:
valid[key] = normalized_search_value
continue
if key == "DOWNLOAD_TO_BROWSER_CONTENT_TYPES":
if not isinstance(value, list):
errors.append(f"Invalid value for {key}: must be a list")
continue
candidate_values = [
str(entry).strip().lower() for entry in value if str(entry).strip()
]
normalized_values: list[str] = []
has_invalid_value = False
for entry in candidate_values:
if entry not in {"book", "audiobook"}:
errors.append(
f"Invalid value for {key}: unsupported content type '{entry}'"
)
has_invalid_value = True
continue
if entry not in normalized_values:
normalized_values.append(entry)
if has_invalid_value:
continue
valid[key] = normalized_values
continue
valid[key] = value
return valid, errors
def build_user_notification_test_response(
*,
user_id: int,
payload: object,
) -> tuple[dict[str, Any], int]:
"""Build a notification test response using effective per-user routes."""
from shelfmark.core.config import config as app_config
routes_input = app_config.get("USER_NOTIFICATION_ROUTES", [], user_id=user_id)
if isinstance(payload, dict):
if "USER_NOTIFICATION_ROUTES" in payload:
routes_input = payload.get("USER_NOTIFICATION_ROUTES")
elif "routes" in payload:
routes_input = payload.get("routes")
result = build_notification_test_result(routes_input, scope_label="personal")
status_code = 200 if result.get("success", False) else 400
return result, status_code
def register_admin_settings_routes(
app: Flask,
user_db: UserDB,
require_admin: Callable[
[Callable[..., ResponseReturnValue]], Callable[..., ResponseReturnValue]
],
) -> None:
"""Register admin endpoints for user-specific settings and defaults."""
@app.route("/api/admin/download-defaults", methods=["GET"])
@require_admin
def admin_download_defaults() -> ResponseReturnValue:
defaults = {
key: ("" if (value := app_config.get(key, field.default)) is None else value)
for key, field in _get_ordered_user_overridable_fields("downloads")
}
defaults["OIDC_ADMIN_GROUP"] = app_config.get("OIDC_ADMIN_GROUP", "")
defaults["OIDC_USE_ADMIN_GROUP"] = app_config.get("OIDC_USE_ADMIN_GROUP", True)
defaults["OIDC_AUTO_PROVISION"] = app_config.get("OIDC_AUTO_PROVISION", True)
return jsonify(defaults)
@app.route("/api/admin/booklore-options", methods=["GET"])
@require_admin
def admin_booklore_options() -> ResponseReturnValue:
from shelfmark.core import admin_routes
return jsonify(
{
"libraries": admin_routes.get_booklore_library_options(),
"paths": admin_routes.get_booklore_path_options(),
}
)
@app.route("/api/admin/users/<int:user_id>/delivery-preferences", methods=["GET"])
@require_admin
def admin_get_delivery_preferences(user_id: int) -> ResponseReturnValue:
user = user_db.get_user(user_id=user_id)
if not user:
return jsonify({"error": "User not found"}), 404
try:
payload = _build_user_preferences_payload(user_db, user_id, "downloads")
except ValueError:
return jsonify({"error": "Downloads settings tab not found"}), 500
return jsonify(payload)
@app.route("/api/admin/users/<int:user_id>/search-preferences", methods=["GET"])
@require_admin
def admin_get_search_preferences(user_id: int) -> ResponseReturnValue:
user = user_db.get_user(user_id=user_id)
if not user:
return jsonify({"error": "User not found"}), 404
try:
payload = _build_user_preferences_payload(user_db, user_id, "search_mode")
except ValueError:
return jsonify({"error": "Search mode settings tab not found"}), 500
return jsonify(payload)
@app.route("/api/admin/users/<int:user_id>/notification-preferences", methods=["GET"])
@require_admin
def admin_get_notification_preferences(user_id: int) -> ResponseReturnValue:
user = user_db.get_user(user_id=user_id)
if not user:
return jsonify({"error": "User not found"}), 404
try:
payload = _build_user_preferences_payload(user_db, user_id, "notifications")
except ValueError:
return jsonify({"error": "Notifications settings tab not found"}), 500
return jsonify(payload)
@app.route("/api/admin/users/<int:user_id>/notification-preferences/test", methods=["POST"])
@require_admin
def admin_test_notification_preferences(user_id: int) -> ResponseReturnValue:
user = user_db.get_user(user_id=user_id)
if not user:
return jsonify({"error": "User not found"}), 404
payload = request.get_json(silent=True)
result, status_code = build_user_notification_test_response(
user_id=user_id,
payload=payload,
)
return jsonify(result), status_code
@app.route("/api/admin/settings/overrides-summary", methods=["GET"])
@require_admin
def admin_settings_overrides_summary() -> ResponseReturnValue:
settings_registry = _get_settings_registry()
tab_name = (request.args.get("tab") or "downloads").strip()
if not settings_registry.get_settings_tab(tab_name):
return jsonify({"error": f"Unknown settings tab: {tab_name}"}), 404
overridable_keys = list(settings_registry.get_user_overridable_fields(tab_name=tab_name))
keys_payload: dict[str, dict[str, Any]] = {}
for user_record in user_db.list_users():
user_settings = user_db.get_user_settings(user_record["id"])
if not isinstance(user_settings, dict):
continue
for key in overridable_keys:
if key not in user_settings or user_settings[key] is None:
continue
entry = keys_payload.setdefault(key, {"count": 0, "users": []})
entry["users"].append(
{
"userId": user_record["id"],
"username": user_record["username"],
"value": user_settings[key],
}
)
for summary in keys_payload.values():
summary["count"] = len(summary["users"])
return jsonify({"tab": tab_name, "keys": keys_payload})
@app.route("/api/admin/users/<int:user_id>/effective-settings", methods=["GET"])
@require_admin
def admin_get_effective_settings(user_id: int) -> ResponseReturnValue:
user = user_db.get_user(user_id=user_id)
if not user:
return jsonify({"error": "User not found"}), 404
from shelfmark.core.config import config as app_config
from shelfmark.core.settings_registry import is_value_from_env
field_map = _get_settings_registry().get_user_overridable_fields()
user_settings = user_db.get_user_settings(user_id)
tab_config_cache: dict[str, dict[str, Any]] = {}
effective: dict[str, dict[str, Any]] = {}
for key, (field, tab_name) in sorted(field_map.items()):
value = app_config.get(key, field.default, user_id=user_id)
source = "default"
if field.env_supported and is_value_from_env(field):
source = "env_var"
elif key in user_settings and user_settings[key] is not None:
source = "user_override"
value = user_settings[key]
else:
tab_config = tab_config_cache.setdefault(tab_name, load_config_file(tab_name))
if key in tab_config:
source = "global_config"
effective[key] = {"value": value, "source": source}
return jsonify(effective)
+175
View File
@@ -0,0 +1,175 @@
"""Authentication mode, auth-source normalization, and admin access policy helpers."""
from __future__ import annotations
import os
import sqlite3
from pathlib import Path
from typing import TYPE_CHECKING, Any, Protocol, TypeGuard
if TYPE_CHECKING:
from collections.abc import Mapping
AUTH_SOURCE_BUILTIN = "builtin"
AUTH_SOURCE_OIDC = "oidc"
AUTH_SOURCE_PROXY = "proxy"
AUTH_SOURCE_CWA = "cwa"
AUTH_SOURCES = (
AUTH_SOURCE_BUILTIN,
AUTH_SOURCE_OIDC,
AUTH_SOURCE_PROXY,
AUTH_SOURCE_CWA,
)
AUTH_SOURCE_SET = frozenset(AUTH_SOURCES)
_ALWAYS_ADMIN_SETTINGS_TABS = frozenset({"security", "users"})
class _UserDBWithAdminPassword(Protocol):
"""Minimal user DB surface needed for local-admin checks."""
def has_admin_with_password(self) -> bool: ...
def _has_admin_password_api(candidate: object) -> TypeGuard[_UserDBWithAdminPassword]:
"""Return True when *candidate* exposes the admin-password lookup we need."""
return callable(getattr(candidate, "has_admin_with_password", None))
def has_local_password_admin(user_db: object | None = None) -> bool:
"""Return True when at least one local admin with a password exists."""
try:
db = user_db
if db is None:
from shelfmark.core.user_db import UserDB
config_root = os.environ.get("CONFIG_DIR", "/config")
db = UserDB(str(Path(config_root) / "users.db"))
db.initialize()
if not _has_admin_password_api(db):
return False
return db.has_admin_with_password()
except AttributeError, ImportError, OSError, RuntimeError, TypeError, ValueError, sqlite3.Error:
return False
def normalize_auth_source(
source: object,
oidc_subject: object = None,
) -> str:
"""Resolve a stable auth source value from persisted fields."""
normalized = str(source or "").strip().lower()
if normalized in AUTH_SOURCE_SET:
return normalized
if oidc_subject:
return AUTH_SOURCE_OIDC
return AUTH_SOURCE_BUILTIN
def determine_auth_mode(
security_config: Mapping[str, Any],
cwa_db_path: object | None,
*,
has_local_admin: bool = True,
) -> str:
"""Determine active auth mode from security config and runtime prerequisites."""
auth_mode = security_config.get("AUTH_METHOD", "none")
if auth_mode == AUTH_SOURCE_CWA and cwa_db_path:
return AUTH_SOURCE_CWA
if auth_mode == AUTH_SOURCE_BUILTIN and has_local_admin:
return AUTH_SOURCE_BUILTIN
if auth_mode == AUTH_SOURCE_PROXY and security_config.get("PROXY_AUTH_USER_HEADER"):
return AUTH_SOURCE_PROXY
if (
auth_mode == AUTH_SOURCE_OIDC
and has_local_admin
and security_config.get("OIDC_DISCOVERY_URL")
and security_config.get("OIDC_CLIENT_ID")
):
return AUTH_SOURCE_OIDC
return "none"
def load_active_auth_mode(
cwa_db_path: object | None,
*,
user_db: object | None = None,
) -> str:
"""Resolve active auth mode using current security config and runtime prerequisites."""
try:
from shelfmark.core.config import config as app_config
security_config = {
"AUTH_METHOD": app_config.get("AUTH_METHOD", "none"),
"PROXY_AUTH_USER_HEADER": app_config.get("PROXY_AUTH_USER_HEADER", ""),
"OIDC_DISCOVERY_URL": app_config.get("OIDC_DISCOVERY_URL", ""),
"OIDC_CLIENT_ID": app_config.get("OIDC_CLIENT_ID", ""),
}
return determine_auth_mode(
security_config,
cwa_db_path,
has_local_admin=has_local_password_admin(user_db),
)
except ImportError, OSError, RuntimeError, TypeError, ValueError, sqlite3.Error:
return "none"
def is_user_active_for_auth_mode(user: Mapping[str, Any], auth_mode: str) -> bool:
"""Return whether a user can authenticate under the current auth mode."""
source = normalize_auth_source(user.get("auth_source"), user.get("oidc_subject"))
if source == AUTH_SOURCE_BUILTIN:
return auth_mode in (AUTH_SOURCE_BUILTIN, AUTH_SOURCE_OIDC)
return source == auth_mode
def is_settings_or_onboarding_path(path: str) -> bool:
"""Return True when request path targets protected admin settings routes."""
return path.startswith(("/api/settings", "/api/onboarding"))
def get_settings_tab_from_path(path: str) -> str | None:
"""Extract tab name from /api/settings/<tab>[...] paths."""
if not path.startswith("/api/settings/"):
return None
suffix = path[len("/api/settings/") :]
if not suffix:
return None
return suffix.split("/", 1)[0] or None
def should_restrict_settings_to_admin(
_users_config: Mapping[str, Any],
) -> bool:
"""Settings/onboarding is always admin-only."""
return True
def requires_admin_for_settings_access(
path: str,
users_config: Mapping[str, Any],
) -> bool:
"""Return whether this settings/onboarding request requires admin privileges."""
tab_name = get_settings_tab_from_path(path)
if tab_name in _ALWAYS_ADMIN_SETTINGS_TABS:
return True
return should_restrict_settings_to_admin(users_config)
def get_auth_check_admin_status(
_auth_mode: str,
_users_config: Mapping[str, Any],
session_data: Mapping[str, Any],
) -> bool:
"""Resolve /api/auth/check `is_admin` as the session's real admin role."""
if "user_id" not in session_data:
return False
return bool(session_data.get("is_admin", False))
+188
View File
@@ -0,0 +1,188 @@
"""Thread-safe in-memory cache with TTL support."""
import threading
import time
from dataclasses import dataclass
from functools import wraps
from typing import TYPE_CHECKING, ParamSpec, TypeVar, cast
from shelfmark.core.logger import setup_logger
if TYPE_CHECKING:
from collections.abc import Callable
logger = setup_logger(__name__)
P = ParamSpec("P")
R = TypeVar("R")
@dataclass
class CacheEntry:
"""A cached value with expiration time."""
value: object
expires_at: float
class CacheService:
"""Thread-safe in-memory cache with TTL support."""
def __init__(self, max_size: int = 1000) -> None:
"""Initialize cache with max_size entries before eviction."""
self._cache: dict[str, CacheEntry] = {}
self._lock = threading.Lock()
self._max_size = max_size
def get(self, key: str) -> object | None:
"""Get cached value if not expired."""
with self._lock:
entry = self._cache.get(key)
if entry is None:
return None
if time.time() > entry.expires_at:
del self._cache[key]
return None
return entry.value
def set(self, key: str, value: object, ttl: int) -> None:
"""Cache value with TTL in seconds."""
with self._lock:
# Evict oldest entries if at capacity
if len(self._cache) >= self._max_size:
self._evict_oldest()
self._cache[key] = CacheEntry(value=value, expires_at=time.time() + ttl)
def invalidate(self, key: str) -> bool:
"""Remove specific cache entry. Returns True if found."""
with self._lock:
if key in self._cache:
del self._cache[key]
return True
return False
def invalidate_prefix(self, prefix: str) -> int:
"""Remove all cache entries whose keys start with prefix."""
with self._lock:
matching_keys = [key for key in self._cache if key.startswith(prefix)]
for key in matching_keys:
del self._cache[key]
return len(matching_keys)
def clear(self) -> None:
"""Clear all cache entries."""
with self._lock:
self._cache.clear()
def cleanup_expired(self) -> int:
"""Remove all expired entries. Returns count removed."""
with self._lock:
now = time.time()
expired_keys = [key for key, entry in self._cache.items() if entry.expires_at < now]
for key in expired_keys:
del self._cache[key]
return len(expired_keys)
def _evict_oldest(self) -> None:
"""Evict ~10% of oldest entries. Called with lock held."""
if not self._cache:
return
# Remove ~10% of entries, oldest first
entries_to_remove = max(1, len(self._cache) // 10)
sorted_entries = sorted(self._cache.items(), key=lambda x: x[1].expires_at)
for key, _ in sorted_entries[:entries_to_remove]:
del self._cache[key]
def stats(self) -> dict[str, int]:
"""Get cache statistics (size, max_size)."""
with self._lock:
return {"size": len(self._cache), "max_size": self._max_size}
# Global cache instance for metadata providers
_metadata_cache = CacheService(max_size=1000)
def get_metadata_cache() -> CacheService:
"""Get the global metadata cache instance."""
return _metadata_cache
def cache_key(*args: object, **kwargs: object) -> str:
"""Generate cache key from arguments."""
parts = [str(arg) for arg in args]
parts.extend(f"{k}={v}" for k, v in sorted(kwargs.items()))
return ":".join(parts)
def _coerce_ttl_seconds(value: object, *, default: int) -> int:
"""Normalize cache TTL values read from config or decorator arguments."""
if isinstance(value, bool):
return default
if isinstance(value, int):
return value if value > 0 else default
if isinstance(value, str):
stripped = value.strip()
if stripped.isdigit():
parsed = int(stripped)
return parsed if parsed > 0 else default
return default
def cacheable(
ttl: int | None = None,
ttl_key: str | None = None,
ttl_default: int = 300,
key_prefix: str = "",
) -> Callable[[Callable[P, R]], Callable[P, R]]:
"""Cache function results with a static or config-backed TTL."""
def decorator(func: Callable[P, R]) -> Callable[P, R]:
@wraps(func)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
# Check if metadata caching is enabled
from shelfmark.core.config import config
if not config.get("METADATA_CACHE_ENABLED", True):
# Caching disabled, execute function directly
return func(*args, **kwargs)
# Determine TTL: static or from config
if ttl is not None:
effective_ttl = ttl
elif ttl_key:
effective_ttl = _coerce_ttl_seconds(
config.get(ttl_key, ttl_default),
default=ttl_default,
)
else:
effective_ttl = ttl_default
# Generate cache key from function name and arguments
# Skip 'self' argument if present (first arg of method)
cache_args = args[1:] if args and hasattr(args[0], func.__name__) else args
key = cache_key(key_prefix or func.__name__, *cache_args, **kwargs)
# Check cache
cached = _metadata_cache.get(key)
if cached is not None:
return cast("R", cached)
# Execute function and cache result
result = func(*args, **kwargs)
# Only cache non-None results
if result is not None:
_metadata_cache.set(key, result, effective_ttl)
return result
return wrapper
return decorator
+289
View File
@@ -0,0 +1,289 @@
"""Configuration singleton with ENV > config file > default resolution."""
import os
import sqlite3
import time
from importlib import import_module
from pathlib import Path
from threading import Lock
from typing import TYPE_CHECKING, Any, Self
if TYPE_CHECKING:
from types import ModuleType
from shelfmark.core.user_db import UserDB
# Import lazily to avoid circular imports
_registry_module = None
_env_module = None
_user_db_module = None
_SETTINGS_REFRESH_COOLDOWN_SECONDS = 0.05
def _get_registry() -> ModuleType:
"""Lazy import of settings registry to avoid circular imports."""
global _registry_module
if _registry_module is None:
from shelfmark.core import settings_registry
_registry_module = settings_registry
return _registry_module
def _get_env() -> ModuleType:
"""Lazy import of env module for fallback values."""
global _env_module
if _env_module is None:
from shelfmark.config import env
_env_module = env
return _env_module
def _get_user_db_module() -> type[UserDB]:
"""Lazy import of user DB module to avoid optional dependency loops."""
global _user_db_module
if _user_db_module is None:
from shelfmark.core.user_db import UserDB
_user_db_module = UserDB
return _user_db_module
class Config:
"""Dynamic configuration singleton that provides live settings access.
Settings are resolved with priority: ENV var > config file > default.
Values are cached for performance and can be refreshed when settings change.
"""
_instance: Self | None = None
_lock = Lock()
def __new__(cls) -> Self:
"""Return the shared configuration singleton instance."""
if cls._instance is None:
with cls._lock:
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._instance._initialized = False
instance = cls._instance
if instance is None:
msg = "Config singleton failed to initialize"
raise RuntimeError(msg)
return instance
def __init__(self) -> None:
"""Initialize caches and backing stores for the singleton."""
if self._initialized:
return
self._cache: dict[str, Any] = {}
self._field_map: dict[str, tuple] = {} # key -> (field, tab_name)
self._cache_lock = Lock()
self._user_settings_cache: dict[int, dict[str, Any]] = {}
self._user_settings_cache_lock = Lock()
self._user_db = None
self._user_db_load_attempted = False
self._initialized = True
self._loaded = False
self._last_refresh_time: float = 0.0
def _ensure_loaded(self) -> None:
"""Ensure settings are loaded from the registry."""
if self._loaded:
return
with self._cache_lock:
if self._loaded:
return
self._load_settings()
def _load_settings(self) -> None:
"""Load all settings from the registry."""
# Ensure all settings modules are imported before loading
# This handles cases where config is accessed before settings are registered
try:
import_module("shelfmark.config.notifications_settings")
import_module("shelfmark.config.security")
import_module("shelfmark.config.settings")
import_module("shelfmark.config.users_settings")
import_module("shelfmark.metadata_providers")
import_module("shelfmark.release_sources")
except ImportError:
pass
registry = _get_registry()
# On first load, sync ENV values to config files
# This ensures ENV values persist even if ENV vars are later removed
if not hasattr(self, "_env_synced"):
registry.sync_env_to_config()
self._env_synced = True
# Build field map from all registered tabs
self._field_map.clear()
self._cache.clear()
for key, (field, tab_name) in registry.get_settings_field_map().items():
self._field_map[key] = (field, tab_name)
self._cache[key] = registry.get_setting_value(field, tab_name)
self._loaded = True
def refresh(self, *, force: bool = False) -> None:
"""Refresh all cached settings from config files.
Call this after settings are updated via the UI to ensure
the config singleton reflects the new values.
Multiple calls within a short window (50 ms) are coalesced to
avoid redundant disk I/O when several helpers each call refresh()
during the same request. Pass ``force=True`` to bypass the guard
(e.g. after a settings write).
"""
now = time.monotonic()
if not force and (now - self._last_refresh_time) < _SETTINGS_REFRESH_COOLDOWN_SECONDS:
return
with self._cache_lock:
self._loaded = False
self._load_settings()
with self._user_settings_cache_lock:
self._user_settings_cache.clear()
self._user_db = None
self._user_db_load_attempted = False
self._last_refresh_time = time.monotonic()
def _get_user_db(self) -> UserDB | None:
"""Get or initialize a UserDB handle if available."""
if self._user_db is not None:
return self._user_db
if self._user_db_load_attempted:
return None
self._user_db_load_attempted = True
try:
user_db_cls = _get_user_db_module()
db_path = str(Path(os.environ.get("CONFIG_DIR", "/config")) / "users.db")
user_db = user_db_cls(db_path)
user_db.initialize()
except ImportError, OSError, sqlite3.Error:
# Multi-user support is optional; fall back to global config when unavailable.
return None
else:
self._user_db = user_db
return self._user_db
def _get_user_settings(self, user_id: int) -> dict[str, Any]:
"""Get cached per-user settings from user DB."""
with self._user_settings_cache_lock:
if user_id in self._user_settings_cache:
return self._user_settings_cache[user_id]
user_db = self._get_user_db()
if user_db is None:
return {}
try:
settings = user_db.get_user_settings(user_id)
except sqlite3.OperationalError, OSError, ValueError, TypeError:
return {}
if not isinstance(settings, dict):
settings = {}
with self._user_settings_cache_lock:
self._user_settings_cache[user_id] = settings
return settings
def _get_user_override(self, user_id: int, key: str) -> object:
"""Get a user override for a specific key."""
user_settings = self._get_user_settings(user_id)
return user_settings.get(key)
def get(self, key: str, default: object = None, user_id: int | None = None) -> object:
"""Get a setting value by key.
Args:
key: The setting key (e.g., 'MAX_RETRY')
default: Default value if setting not found
user_id: Optional DB user ID for per-user setting overrides
Returns:
The setting value, or default if not found
"""
self._ensure_loaded()
if key in self._field_map:
field, _ = self._field_map[key]
registry = _get_registry()
# Deployment-level ENV values always win.
if field.env_supported and registry.is_value_from_env(field):
return self._cache.get(key, default)
# User overrides are only available for explicitly overridable fields.
if user_id is not None and getattr(field, "user_overridable", False):
user_value = self._get_user_override(user_id, key)
if user_value is not None:
return user_value
return self._cache.get(key, default)
def __getattr__(self, name: str) -> object:
"""Allow attribute-style access to settings.
Example: config.MAX_RETRY instead of config.get('MAX_RETRY')
"""
# Avoid recursion for internal attributes
if name.startswith("_"):
msg = f"'{type(self).__name__}' object has no attribute '{name}'"
raise AttributeError(msg)
self._ensure_loaded()
if name in self._cache:
return self._cache[name]
# Fallback to env module for settings not in registry
# This ensures backward compatibility during migration
env = _get_env()
if hasattr(env, name):
return getattr(env, name)
msg = f"Setting '{name}' not found in config or env"
raise AttributeError(msg)
def is_from_env(self, key: str) -> bool:
"""Check if a setting's value comes from an environment variable.
Args:
key: The setting key
Returns:
True if the value is set via ENV var, False otherwise
"""
self._ensure_loaded()
if key not in self._field_map:
return False
field, _ = self._field_map[key]
registry = _get_registry()
return registry.is_value_from_env(field)
def get_all(self) -> dict[str, Any]:
"""Get all cached settings as a dictionary.
Returns:
Dict of all setting keys to their current values
"""
self._ensure_loaded()
return dict(self._cache)
# Global singleton instance
config = Config()
+102
View File
@@ -0,0 +1,102 @@
"""Helpers for provisioning and syncing Calibre-Web users into users.db."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from shelfmark.core.auth_modes import AUTH_SOURCE_CWA, normalize_auth_source
from shelfmark.core.external_user_linking import upsert_external_user
if TYPE_CHECKING:
from collections.abc import Iterable
from shelfmark.core.user_db import UserDB
_CWA_ALIAS_SUFFIX = "__cwa"
def _normalize_email(value: object) -> str | None:
if value is None:
return None
email = str(value).strip()
return email or None
def upsert_cwa_user(
user_db: UserDB,
cwa_username: str,
cwa_email: str | None,
role: str,
context: str | None = None,
) -> tuple[dict[str, Any], str]:
"""Create/update a CWA-backed user with collision-safe matching."""
normalized_email = _normalize_email(cwa_email)
collision_strategy = "alias" if normalized_email else "takeover"
user, action = upsert_external_user(
user_db,
auth_source="cwa",
username=cwa_username,
email=normalized_email,
role=role,
allow_email_link=True,
collision_strategy=collision_strategy,
alias_suffix=_CWA_ALIAS_SUFFIX,
context=context,
)
if user is None:
msg = "Unexpected CWA user sync result: no user returned"
raise RuntimeError(msg)
return user, action
def sync_cwa_users_from_rows(
user_db: UserDB,
rows: Iterable[tuple[Any, Any, Any]],
) -> dict[str, int]:
"""Sync CWA users from raw `(name, role_flags, email)` rows."""
created = 0
updated = 0
active_cwa_user_ids: set[int] = set()
for username, role_flags, email in rows:
normalized_username = str(username or "").strip()
if not normalized_username:
continue
role = "admin" if (int(role_flags or 0) & 1) == 1 else "user"
user, action = upsert_cwa_user(
user_db,
cwa_username=normalized_username,
cwa_email=_normalize_email(email),
role=role,
context="cwa_manual_sync",
)
active_cwa_user_ids.add(int(user["id"]))
if action == "created":
created += 1
else:
updated += 1
deleted = 0
for existing_user in user_db.list_users():
if (
normalize_auth_source(
existing_user.get("auth_source"),
existing_user.get("oidc_subject"),
)
!= AUTH_SOURCE_CWA
):
continue
existing_id = int(existing_user.get("id") or 0)
if existing_id in active_cwa_user_ids:
continue
user_db.delete_user(existing_id)
deleted += 1
return {
"created": created,
"updated": updated,
"deleted": deleted,
"total": created + updated,
}
+433
View File
@@ -0,0 +1,433 @@
"""Persistence helpers for canonical download activity rows."""
from __future__ import annotations
import json
import sqlite3
import threading
from datetime import UTC, datetime
from pathlib import Path
from typing import Any, SupportsIndex, SupportsInt, TypeGuard
from shelfmark.core.logger import setup_logger
from shelfmark.core.models import TERMINAL_QUEUE_STATUSES
from shelfmark.core.request_helpers import (
normalize_optional_positive_int,
normalize_optional_text,
now_utc_iso,
)
logger = setup_logger(__name__)
VALID_TERMINAL_STATUSES = frozenset(s.value for s in TERMINAL_QUEUE_STATUSES)
ACTIVE_DOWNLOAD_STATUS = "active"
VALID_ORIGINS = frozenset({"direct", "requested"})
def _is_convertible_to_int(
value: object,
) -> TypeGuard[str | bytes | bytearray | SupportsInt | SupportsIndex]:
"""Return True when *value* can be safely passed to ``int``."""
return (
isinstance(value, (str, bytes, bytearray))
or hasattr(value, "__int__")
or hasattr(value, "__index__")
)
def _coerce_int_value(value: object) -> int:
"""Normalize int-like values and raise TypeError for unsupported inputs."""
if isinstance(value, bool) or not _is_convertible_to_int(value):
msg = "limit must be an integer"
raise TypeError(msg)
return int(value)
def _normalize_task_id(task_id: object) -> str:
normalized = normalize_optional_text(task_id)
if normalized is None:
msg = "task_id must be a non-empty string"
raise ValueError(msg)
return normalized
def _normalize_origin(origin: object) -> str:
normalized = normalize_optional_text(origin)
if normalized is None:
return "direct"
lowered = normalized.lower()
if lowered not in VALID_ORIGINS:
msg = "origin must be one of: direct, requested"
raise ValueError(msg)
return lowered
def _normalize_final_status(final_status: object) -> str:
normalized = normalize_optional_text(final_status)
if normalized is None:
msg = "final_status must be a non-empty string"
raise ValueError(msg)
lowered = normalized.lower()
if lowered not in VALID_TERMINAL_STATUSES:
msg = "final_status must be one of: complete, error, cancelled"
raise ValueError(msg)
return lowered
def _normalize_limit(value: object, *, default: int, minimum: int, maximum: int) -> int:
if value is None:
return default
try:
parsed = _coerce_int_value(value)
except (TypeError, ValueError) as exc:
msg = "limit must be an integer"
raise ValueError(msg) from exc
if parsed < minimum:
return minimum
if parsed > maximum:
return maximum
return parsed
class DownloadHistoryService:
"""Service for persisted canonical download activity rows."""
def __init__(self, db_path: str) -> None:
"""Initialize the service with the SQLite history database path."""
self._db_path = db_path
self._lock = threading.Lock()
def _connect(self) -> sqlite3.Connection:
conn = sqlite3.connect(self._db_path)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA foreign_keys = ON")
return conn
@classmethod
def _normalize_row_dict(cls, row: dict[str, Any] | None) -> dict[str, Any] | None:
if row is None:
return None
normalized = dict(row)
normalized["retry_payload"] = cls._deserialize_retry_payload(
normalized.get("retry_payload")
)
return normalized
@classmethod
def _row_to_dict(cls, row: sqlite3.Row | None) -> dict[str, Any] | None:
return cls._normalize_row_dict(dict(row) if row is not None else None)
@staticmethod
def _to_item_key(task_id: str) -> str:
return f"download:{task_id}"
@staticmethod
def _resolve_existing_download_path(value: object) -> str | None:
normalized = normalize_optional_text(value)
if normalized is None:
return None
return normalized if Path(normalized).exists() else None
@staticmethod
def _serialize_retry_payload(payload: object) -> str | None:
if payload is None:
return None
try:
return json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
except (TypeError, ValueError) as exc:
msg = "retry_payload must be JSON-serializable"
raise ValueError(msg) from exc
@staticmethod
def _deserialize_retry_payload(value: object) -> dict[str, Any] | None:
if isinstance(value, dict):
return dict(value)
normalized = normalize_optional_text(value)
if normalized is None:
return None
try:
parsed = json.loads(normalized)
except json.JSONDecodeError:
return None
return parsed if isinstance(parsed, dict) else None
@staticmethod
def _has_staged_retry_source(retry_payload: dict[str, Any]) -> bool:
staged_path = retry_payload.get("staged_path")
normalized_staged_path = normalize_optional_text(staged_path)
if normalized_staged_path is None:
return False
return Path(normalized_staged_path).exists()
@staticmethod
def _can_retry_without_staged_source(retry_payload: dict[str, Any]) -> bool:
return bool(retry_payload.get("can_retry_without_staged_source", True))
@staticmethod
def is_retry_available(row: dict[str, Any]) -> bool:
"""Return whether a persisted download row can be retried."""
final_status = (
str(row.get("retry_final_status") or row.get("final_status") or "").strip().lower()
)
retry_payload = DownloadHistoryService._deserialize_retry_payload(row.get("retry_payload"))
if retry_payload is None:
return False
has_staged_retry_source = DownloadHistoryService._has_staged_retry_source(retry_payload)
can_retry_without_staged_source = DownloadHistoryService._can_retry_without_staged_source(
retry_payload
)
request_id = normalize_optional_positive_int(row.get("request_id"), "request_id")
if request_id is None:
if final_status in {ACTIVE_DOWNLOAD_STATUS, "cancelled"}:
return can_retry_without_staged_source
if final_status == "error":
return has_staged_retry_source or can_retry_without_staged_source
return False
if final_status in {ACTIVE_DOWNLOAD_STATUS, "cancelled"}:
return can_retry_without_staged_source
if final_status != "error":
return False
return has_staged_retry_source
@staticmethod
def to_download_payload(row: dict[str, Any]) -> dict[str, Any]:
"""Build the sidebar/history download payload for a persisted row."""
return {
"id": row.get("task_id"),
"title": row.get("title"),
"author": row.get("author"),
"format": row.get("format"),
"size": row.get("size"),
"preview": row.get("preview"),
"content_type": row.get("content_type"),
"source": row.get("source"),
"source_display_name": row.get("source_display_name"),
"status_message": row.get("status_message"),
"download_path": DownloadHistoryService._resolve_existing_download_path(
row.get("download_path")
),
"added_time": DownloadHistoryService._iso_to_epoch(row.get("queued_at")),
"user_id": row.get("user_id"),
"username": row.get("username"),
"request_id": row.get("request_id"),
"retry_available": DownloadHistoryService.is_retry_available(row),
}
@staticmethod
def _iso_to_epoch(value: object) -> float | None:
if not isinstance(value, str) or not value.strip():
return None
normalized = value.strip().replace("Z", "+00:00")
try:
parsed = datetime.fromisoformat(normalized)
except ValueError:
return None
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=UTC)
return parsed.timestamp()
@classmethod
def to_history_row(cls, row: dict[str, Any], *, dismissed_at: str) -> dict[str, Any]:
"""Build the activity-history payload for a persisted download row."""
task_id = str(row.get("task_id") or "").strip()
item_key = cls._to_item_key(task_id)
download_payload = cls.to_download_payload(row)
# Clear stale progress messages for non-error terminal states.
if row.get("final_status") in ("complete", "cancelled"):
download_payload["status_message"] = None
return {
"id": item_key,
"user_id": row.get("user_id"),
"item_type": "download",
"item_key": item_key,
"dismissed_at": dismissed_at,
"snapshot": {
"kind": "download",
"download": download_payload,
},
"origin": row.get("origin"),
"final_status": row.get("final_status"),
"terminal_at": row.get("terminal_at"),
"request_id": row.get("request_id"),
"source_id": task_id or None,
}
def record_download(
self,
*,
task_id: str,
user_id: int | None,
username: str | None,
request_id: int | None,
source: str,
source_display_name: str | None,
title: str,
author: str | None,
file_format: str | None,
size: str | None,
preview: str | None,
content_type: str | None,
origin: str,
retry_payload: dict[str, Any] | None = None,
) -> None:
"""Record a download at queue time with final_status='active'.
On first queue: inserts a new row.
On retry (row already exists): resets the row back to 'active'
so the normal finalize path works when the retry completes.
"""
normalized_task_id = _normalize_task_id(task_id)
normalized_user_id = normalize_optional_positive_int(user_id, "user_id")
normalized_request_id = normalize_optional_positive_int(request_id, "request_id")
normalized_source = normalize_optional_text(source)
if normalized_source is None:
msg = "source must be a non-empty string"
raise ValueError(msg)
normalized_title = normalize_optional_text(title)
if normalized_title is None:
msg = "title must be a non-empty string"
raise ValueError(msg)
normalized_origin = _normalize_origin(origin)
normalized_retry_payload = self._serialize_retry_payload(retry_payload)
recorded_at = now_utc_iso()
with self._lock:
conn = self._connect()
try:
conn.execute(
"""
INSERT INTO download_history (
task_id, user_id, username, request_id,
source, source_display_name,
title, author, format, size, preview, content_type,
origin, final_status,
status_message, download_path, retry_payload,
queued_at, terminal_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', NULL, NULL, ?, ?, ?)
ON CONFLICT(task_id) DO UPDATE SET
final_status = 'active',
status_message = NULL,
download_path = NULL,
retry_payload = excluded.retry_payload,
terminal_at = ?
""",
(
normalized_task_id,
normalized_user_id,
normalize_optional_text(username),
normalized_request_id,
normalized_source,
normalize_optional_text(source_display_name),
normalized_title,
normalize_optional_text(author),
normalize_optional_text(file_format),
normalize_optional_text(size),
normalize_optional_text(preview),
normalize_optional_text(content_type),
normalized_origin,
normalized_retry_payload,
recorded_at,
recorded_at,
recorded_at,
),
)
conn.commit()
finally:
conn.close()
def finalize_download(
self,
*,
task_id: str,
final_status: str,
status_message: str | None = None,
download_path: str | None = None,
retry_payload: dict[str, Any] | None = None,
) -> None:
"""Update an existing download row to its terminal state."""
normalized_task_id = _normalize_task_id(task_id)
normalized_final_status = _normalize_final_status(final_status)
normalized_status_message = normalize_optional_text(status_message)
normalized_download_path = normalize_optional_text(download_path)
normalized_retry_payload = self._serialize_retry_payload(retry_payload)
effective_terminal_at = now_utc_iso()
with self._lock:
conn = self._connect()
try:
cursor = conn.execute(
"""
UPDATE download_history
SET final_status = ?,
status_message = ?,
download_path = ?,
retry_payload = COALESCE(?, retry_payload),
terminal_at = ?
WHERE task_id = ? AND final_status = 'active'
""",
(
normalized_final_status,
normalized_status_message,
normalized_download_path,
normalized_retry_payload,
effective_terminal_at,
normalized_task_id,
),
)
rowcount = int(cursor.rowcount) if cursor.rowcount is not None else 0
if rowcount < 1:
logger.warning(
"finalize_download: no active row found for task_id=%s (may have been missed at queue time)",
normalized_task_id,
)
conn.commit()
finally:
conn.close()
def get_by_task_id(self, task_id: str) -> dict[str, Any] | None:
"""Return a persisted download row for the given task id."""
normalized_task_id = _normalize_task_id(task_id)
conn = self._connect()
try:
row = conn.execute(
"SELECT * FROM download_history WHERE task_id = ?",
(normalized_task_id,),
).fetchone()
return self._row_to_dict(row)
finally:
conn.close()
def list_recent(
self,
*,
user_id: int | None,
limit: int = 200,
) -> list[dict[str, Any]]:
"""Return recent persisted download rows, optionally scoped to one user."""
normalized_user_id = normalize_optional_positive_int(user_id, "user_id")
normalized_limit = _normalize_limit(limit, default=200, minimum=1, maximum=1000)
query = "SELECT * FROM download_history"
params: list[Any] = []
if normalized_user_id is not None:
query += " WHERE user_id = ?"
params.append(normalized_user_id)
query += " ORDER BY terminal_at DESC, id DESC LIMIT ?"
params.append(normalized_limit)
conn = self._connect()
try:
rows = conn.execute(query, params).fetchall()
result: list[dict[str, Any]] = []
for row in rows:
normalized = self._normalize_row_dict(dict(row))
if normalized is not None:
result.append(normalized)
return result
finally:
conn.close()
+308
View File
@@ -0,0 +1,308 @@
"""Shared external identity matching and provisioning helpers."""
from __future__ import annotations
import re
from typing import TYPE_CHECKING, Any, Literal
from shelfmark.core.auth_modes import normalize_auth_source
from shelfmark.core.logger import setup_logger
if TYPE_CHECKING:
from shelfmark.core.user_db import UserDB
UNSET = object()
CollisionStrategy = Literal["takeover", "suffix", "alias"]
MatchReason = Literal[
"subject_match",
"existing_source_username_match",
"unique_email_match",
]
logger = setup_logger(__name__)
def _normalize_username(value: object) -> str:
return str(value or "").strip()
def _normalize_email(value: object) -> str | None:
if value is None:
return None
email = str(value).strip()
return email or None
def _normalize_display_name(value: object) -> str | None:
if value is None:
return None
name = str(value).strip()
return name or None
def _email_key(value: str | None) -> str:
return (value or "").strip().lower()
def _normalize_role(value: object) -> str:
return "admin" if str(value or "").strip().lower() == "admin" else "user"
def _get_by_subject(
user_db: UserDB, subject_field: str | None, subject: str | None
) -> dict[str, Any] | None:
if not subject_field or not subject:
return None
if subject_field == "oidc_subject":
return user_db.get_user(oidc_subject=subject)
return None
def find_unique_user_by_email(user_db: UserDB, email: str | None) -> dict[str, Any] | None:
"""Return the unique local user matching an email address, if any."""
key = _email_key(_normalize_email(email))
if not key:
return None
matches = [u for u in user_db.list_users() if _email_key(u.get("email")) == key]
return matches[0] if len(matches) == 1 else None
def find_external_user_match(
user_db: UserDB,
*,
auth_source: str,
username: str,
email: str | None,
subject_field: str | None = None,
subject: str | None = None,
allow_email_link: bool = False,
) -> tuple[dict[str, Any] | None, MatchReason | None]:
"""Find an existing local user that should be linked to an external identity."""
normalized_username = _normalize_username(username)
normalized_email = _normalize_email(email)
by_subject = _get_by_subject(user_db, subject_field, subject)
if by_subject is not None:
return by_subject, "subject_match"
by_username = user_db.get_user(username=normalized_username)
if (
by_username
and normalize_auth_source(
by_username.get("auth_source"),
by_username.get("oidc_subject"),
)
== auth_source
):
return by_username, "existing_source_username_match"
if allow_email_link:
return find_unique_user_by_email(user_db, normalized_email), "unique_email_match"
return None, None
def _build_updates(
*,
auth_source: str,
role: str,
sync_role: bool,
email: str | None | object,
display_name: str | None | object,
subject_field: str | None,
subject: str | None,
) -> dict[str, Any]:
updates: dict[str, Any] = {"auth_source": auth_source}
if sync_role:
updates["role"] = _normalize_role(role)
if email is not UNSET:
updates["email"] = _normalize_email(email)
if display_name is not UNSET:
updates["display_name"] = _normalize_display_name(display_name)
if subject_field == "oidc_subject" and subject:
updates["oidc_subject"] = subject
return updates
def _next_suffix_username(user_db: UserDB, base_username: str) -> str:
candidate = base_username
suffix = 1
while user_db.get_user(username=candidate):
candidate = f"{base_username}_{suffix}"
suffix += 1
return candidate
def _find_existing_alias_user(
user_db: UserDB,
*,
auth_source: str,
alias_base: str,
) -> dict[str, Any] | None:
pattern = re.compile(rf"^{re.escape(alias_base)}(?:_\d+)?$")
candidates = [
user
for user in user_db.list_users()
if pattern.match(str(user.get("username") or ""))
and normalize_auth_source(user.get("auth_source"), user.get("oidc_subject")) == auth_source
]
if not candidates:
return None
return sorted(candidates, key=lambda user: int(user.get("id") or 0))[0]
def _resolve_create_username(
user_db: UserDB,
*,
auth_source: str,
requested_username: str,
strategy: CollisionStrategy,
alias_suffix: str,
) -> tuple[str | None, dict[str, Any] | None, str]:
existing = user_db.get_user(username=requested_username)
if not existing:
return requested_username, None, "new_username_available"
if strategy == "takeover":
return None, existing, "username_collision_takeover"
if strategy == "suffix":
return (
_next_suffix_username(user_db, requested_username),
None,
"username_collision_suffix",
)
alias_base = f"{requested_username}{alias_suffix}"
alias_existing = _find_existing_alias_user(
user_db,
auth_source=auth_source,
alias_base=alias_base,
)
if alias_existing is not None:
return None, alias_existing, "reuse_existing_alias"
return _next_suffix_username(user_db, alias_base), None, "username_collision_alias"
def upsert_external_user(
user_db: UserDB,
*,
auth_source: str,
username: str,
role: str,
email: str | None | object = UNSET,
display_name: str | None | object = UNSET,
subject_field: str | None = None,
subject: str | None = None,
allow_email_link: bool = False,
sync_role: bool = True,
allow_create: bool = True,
collision_strategy: CollisionStrategy = "takeover",
alias_suffix: str | None = None,
context: str | None = None,
) -> tuple[dict[str, Any] | None, str]:
"""Create/update a user from an external auth identity.
Returns `(user, action)` where action is one of:
- `"updated"`
- `"created"`
- `"not_found"` (when `allow_create=False` and no link target exists)
"""
normalized_username = _normalize_username(username)
if not normalized_username:
msg = "External username is required"
raise ValueError(msg)
normalized_email = _normalize_email(email) if email is not UNSET else None
normalized_display_name = (
_normalize_display_name(display_name) if display_name is not UNSET else None
)
normalized_role = _normalize_role(role)
matched, match_reason = find_external_user_match(
user_db,
auth_source=auth_source,
username=normalized_username,
email=normalized_email,
subject_field=subject_field,
subject=subject,
allow_email_link=allow_email_link,
)
updates = _build_updates(
auth_source=auth_source,
role=normalized_role,
sync_role=sync_role,
email=normalized_email if email is not UNSET else UNSET,
display_name=normalized_display_name if display_name is not UNSET else UNSET,
subject_field=subject_field,
subject=subject,
)
if matched is not None:
user_db.update_user(matched["id"], **updates)
mapped = user_db.get_user(user_id=matched["id"]) or matched
logger.info(
"External user mapped to existing Shelfmark user (source=%s, context=%s, reason=%s, external_username=%s, shelfmark_user_id=%s, shelfmark_username=%s)",
auth_source,
context or "unspecified",
match_reason,
normalized_username,
mapped["id"],
mapped["username"],
)
return mapped, "updated"
if not allow_create:
logger.info(
"External user could not be mapped and creation is disabled (source=%s, context=%s, external_username=%s)",
auth_source,
context or "unspecified",
normalized_username,
)
return None, "not_found"
resolved_alias_suffix = alias_suffix or f"__{auth_source}"
create_username, takeover_target, create_reason = _resolve_create_username(
user_db,
auth_source=auth_source,
requested_username=normalized_username,
strategy=collision_strategy,
alias_suffix=resolved_alias_suffix,
)
if takeover_target is not None:
user_db.update_user(takeover_target["id"], **updates)
mapped = user_db.get_user(user_id=takeover_target["id"]) or takeover_target
logger.info(
"External user mapped to existing Shelfmark user (source=%s, context=%s, reason=%s, external_username=%s, shelfmark_user_id=%s, shelfmark_username=%s)",
auth_source,
context or "unspecified",
create_reason,
normalized_username,
mapped["id"],
mapped["username"],
)
return mapped, "updated"
create_kwargs: dict[str, Any] = {
"username": create_username,
"auth_source": auth_source,
"role": normalized_role,
}
if email is not UNSET:
create_kwargs["email"] = normalized_email
if display_name is not UNSET:
create_kwargs["display_name"] = normalized_display_name
if subject_field == "oidc_subject" and subject:
create_kwargs["oidc_subject"] = subject
created = user_db.create_user(**create_kwargs)
logger.info(
"External user created Shelfmark user (source=%s, context=%s, reason=%s, external_username=%s, shelfmark_user_id=%s, shelfmark_username=%s)",
auth_source,
context or "unspecified",
create_reason,
normalized_username,
created["id"],
created["username"],
)
return created, "created"
+621
View File
@@ -0,0 +1,621 @@
"""Disk-based image cache with LRU eviction."""
import ipaddress
import json
import socket
import threading
import time
from http import HTTPStatus
from io import BytesIO
from typing import TYPE_CHECKING, Any
from urllib.parse import urlparse
import requests
from shelfmark.core.logger import setup_logger
from shelfmark.core.request_helpers import coerce_int
from shelfmark.download.network import get_ssl_verify
if TYPE_CHECKING:
from pathlib import Path
logger = setup_logger(__name__)
# Image type detection via magic bytes
IMAGE_SIGNATURES = {
b"\xff\xd8\xff": ("image/jpeg", "jpg"),
b"\x89PNG\r\n\x1a\n": ("image/png", "png"),
b"GIF87a": ("image/gif", "gif"),
b"GIF89a": ("image/gif", "gif"),
b"RIFF": ("image/webp", "webp"), # WebP starts with RIFF
}
# HTTP headers for image fetching
FETCH_HEADERS = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/129.0.0.0 Safari/537.36",
"Accept": "image/webp,image/apng,image/*,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.5",
}
# Maximum image size to fetch (5 MB)
MAX_IMAGE_SIZE = 5 * 1024 * 1024
# Negative cache TTL (for failed fetches) - 1 hour
NEGATIVE_CACHE_TTL = 3600
# Transient failure cache TTL (for timeouts/connection errors) - 60 seconds
# Short enough to retry soon, long enough to prevent spam during one page view
TRANSIENT_CACHE_TTL = 60
_MIN_WEBP_HEADER_LENGTH = 12
HTTP_NOT_FOUND = HTTPStatus.NOT_FOUND
def _detect_image_type(data: bytes) -> tuple[str, str] | None:
"""Detect image type from magic bytes.
Args:
data: Image data bytes
Returns:
Tuple of (content_type, extension) or None if not recognized
"""
for signature, (content_type, ext) in IMAGE_SIGNATURES.items():
if data.startswith(signature):
return content_type, ext
# Special case for WebP - check for WEBP after RIFF
if data.startswith(b"RIFF") and len(data) > _MIN_WEBP_HEADER_LENGTH and data[8:12] == b"WEBP":
return "image/webp", "webp"
return None
class ImageCacheService:
"""Persistent image cache with LRU eviction and TTL support."""
def __init__(self, cache_dir: Path, max_size_mb: int = 500, ttl_seconds: int = 0) -> None:
"""Initialize the image cache.
Args:
cache_dir: Directory to store cached images
max_size_mb: Maximum cache size in megabytes
ttl_seconds: Time-to-live in seconds (0 = forever)
"""
self.cache_dir = cache_dir
self.max_size_bytes = max_size_mb * 1024 * 1024
self.ttl_seconds = ttl_seconds
self.index_path = cache_dir / "cache_index.json"
self._lock = threading.RLock()
self._index: dict[str, dict[str, Any]] = {}
# Stats tracking
self._hits = 0
self._misses = 0
# Ensure cache directory exists
self.cache_dir.mkdir(parents=True, exist_ok=True)
# Load existing index and sync with files on disk (once at startup)
self._load_index()
self._sync_index_with_files()
def _load_index(self) -> None:
"""Load cache index from disk."""
if not self.index_path.exists():
self._index = {}
return
try:
with self.index_path.open() as f:
self._index = json.load(f)
except OSError, json.JSONDecodeError:
self._index = {}
def _sync_index_with_files(self) -> None:
"""Sync cache index with actual files on disk.
- Adds entries for files that exist but aren't in index
- Removes entries for files that no longer exist (non-negative only)
- Preserves negative cache entries (they have no files)
"""
image_extensions = {".jpg", ".jpeg", ".png", ".gif", ".webp"}
added_count = 0
removed_count = 0
# Build set of files that exist on disk
existing_files: dict[str, Path] = {}
for file_path in self.cache_dir.iterdir():
if not file_path.is_file():
continue
if file_path.suffix.lower() not in image_extensions:
continue
existing_files[file_path.stem] = file_path
# Add files that aren't in the index
for cache_id, file_path in existing_files.items():
if cache_id in self._index:
continue
ext = file_path.suffix.lstrip(".")
stat = file_path.stat()
# Detect content type
try:
with file_path.open("rb") as f:
header = f.read(16)
detected = _detect_image_type(header)
content_type = detected[0] if detected else f"image/{ext}"
except OSError:
content_type = f"image/{ext}"
self._index[cache_id] = {
"ext": ext,
"content_type": content_type,
"size": stat.st_size,
"cached_at": stat.st_mtime,
"accessed_at": stat.st_mtime,
}
added_count += 1
# Remove index entries for missing files (skip negative cache entries)
stale_entries = []
for cache_id, entry in self._index.items():
if entry.get("negative", False):
continue # Negative entries don't have files
if cache_id not in existing_files:
stale_entries.append(cache_id)
for cache_id in stale_entries:
del self._index[cache_id]
removed_count += 1
if added_count > 0 or removed_count > 0:
self._save_index()
def _save_index(self) -> None:
"""Save cache index to disk."""
try:
# Write to temp file first, then rename for atomicity
temp_path = self.index_path.with_suffix(".tmp")
with temp_path.open("w") as f:
json.dump(self._index, f)
temp_path.rename(self.index_path)
except OSError:
pass
def _get_image_path(self, cache_id: str, ext: str) -> Path:
"""Get the file path for a cached image."""
return self.cache_dir / f"{cache_id}.{ext}"
def _is_expired(self, entry: dict[str, Any]) -> bool:
"""Check if a cache entry is expired."""
if self.ttl_seconds == 0:
return False
return (time.time() - entry.get("cached_at", 0)) > self.ttl_seconds
def _is_negative_expired(self, entry: dict[str, Any]) -> bool:
"""Check if a negative cache entry is expired.
Transient failures (timeouts) expire after TRANSIENT_CACHE_TTL (60s).
Permanent failures (404s) expire after NEGATIVE_CACHE_TTL (1 hour).
"""
if not entry.get("negative", False):
return False
cached_at = entry.get("cached_at", 0)
ttl = TRANSIENT_CACHE_TTL if entry.get("transient", False) else NEGATIVE_CACHE_TTL
return (time.time() - cached_at) > ttl
def _calculate_total_size(self) -> int:
"""Calculate total size of cached images."""
return sum(entry.get("size", 0) for entry in self._index.values())
def _evict_if_needed(self, required_space: int = 0) -> None:
"""Evict old entries if cache is over size limit.
Uses LRU eviction based on accessed_at timestamp.
"""
current_size = self._calculate_total_size()
target_size = self.max_size_bytes - required_space
if current_size <= target_size:
return
# Sort entries by accessed_at (oldest first)
sorted_entries = sorted(self._index.items(), key=lambda x: x[1].get("accessed_at", 0))
evicted_count = 0
for cache_id, entry in sorted_entries:
if current_size <= target_size:
break
# Delete the image file
ext = entry.get("ext", "jpg")
image_path = self._get_image_path(cache_id, ext)
try:
if image_path.exists():
image_path.unlink()
except OSError:
pass
# Update tracking
current_size -= entry.get("size", 0)
del self._index[cache_id]
evicted_count += 1
if evicted_count > 0:
self._save_index()
def get(self, cache_id: str) -> tuple[bytes, str] | None:
"""Get a cached image.
Args:
cache_id: Cache key (book ID or composite key)
Returns:
Tuple of (image_data, content_type) or None if not cached/expired
"""
with self._lock:
entry = self._index.get(cache_id)
# Try reloading from disk if not found (handles multiprocess case)
if not entry:
self._load_index()
entry = self._index.get(cache_id)
if not entry:
self._misses += 1
return None
# Check for negative cache (failed fetch)
if entry.get("negative", False):
if self._is_negative_expired(entry):
# Negative cache expired, allow retry
del self._index[cache_id]
self._save_index()
self._misses += 1
return None
# Still in negative cache, return None (don't retry)
return None
# Check for expired entry
if self._is_expired(entry):
# Remove expired entry
ext = entry.get("ext", "jpg")
image_path = self._get_image_path(cache_id, ext)
try:
if image_path.exists():
image_path.unlink()
except OSError:
pass
del self._index[cache_id]
self._save_index()
self._misses += 1
return None
# Try to read the cached image
ext = entry.get("ext", "jpg")
content_type = entry.get("content_type", "image/jpeg")
image_path = self._get_image_path(cache_id, ext)
result: tuple[bytes, str] | None = None
try:
if not image_path.exists():
# File missing, remove from index
del self._index[cache_id]
self._save_index()
self._misses += 1
return None
with image_path.open("rb") as f:
data = f.read()
# Update accessed time
entry["accessed_at"] = time.time()
self._save_index()
result = data, content_type
except OSError:
self._misses += 1
return None
else:
self._hits += 1
return result
def put(self, cache_id: str, data: bytes, content_type: str) -> bool:
"""Store an image in the cache.
Args:
cache_id: Cache key
data: Image data bytes
content_type: MIME type of the image
Returns:
True if stored successfully
"""
with self._lock:
# Detect image type for extension
detected = _detect_image_type(data)
if detected:
content_type, ext = detected
# Fall back to content-type header
elif "jpeg" in content_type or "jpg" in content_type:
ext = "jpg"
elif "png" in content_type:
ext = "png"
elif "gif" in content_type:
ext = "gif"
elif "webp" in content_type:
ext = "webp"
else:
ext = "jpg" # Default
image_size = len(data)
# Evict if needed to make room
self._evict_if_needed(image_size)
# Write image to disk
image_path = self._get_image_path(cache_id, ext)
try:
with image_path.open("wb") as f:
f.write(data)
except OSError:
return False
# Update index
now = time.time()
self._index[cache_id] = {
"ext": ext,
"content_type": content_type,
"size": image_size,
"cached_at": now,
"accessed_at": now,
"negative": False,
}
self._save_index()
return True
def put_negative(self, cache_id: str, *, transient: bool = False) -> None:
"""Store a negative cache entry (failed fetch).
Args:
cache_id: Cache key
transient: If True, uses shorter TTL (for timeouts/connection errors)
"""
with self._lock:
self._index[cache_id] = {
"negative": True,
"transient": transient,
"cached_at": time.time(),
}
self._save_index()
def delete(self, cache_id: str) -> bool:
"""Delete a single cache entry.
Args:
cache_id: Cache key
Returns:
True if entry existed and was deleted
"""
with self._lock:
entry = self._index.get(cache_id)
if not entry:
return False
# Delete file if it exists
if not entry.get("negative", False):
ext = entry.get("ext", "jpg")
image_path = self._get_image_path(cache_id, ext)
try:
if image_path.exists():
image_path.unlink()
except OSError:
pass
del self._index[cache_id]
self._save_index()
return True
def clear(self) -> int:
"""Clear all cached images.
Returns:
Number of entries cleared
"""
with self._lock:
count = len(self._index)
# Delete all image files
for cache_id, entry in self._index.items():
if not entry.get("negative", False):
ext = entry.get("ext", "jpg")
image_path = self._get_image_path(cache_id, ext)
try:
if image_path.exists():
image_path.unlink()
except OSError:
pass
# Clear index
self._index = {}
self._save_index()
# Reset stats
self._hits = 0
self._misses = 0
return count
def stats(self) -> dict[str, Any]:
"""Get cache statistics.
Returns:
Dict with size, count, hit rate, etc.
"""
with self._lock:
total_size = self._calculate_total_size()
entry_count = len(self._index)
negative_count = sum(1 for e in self._index.values() if e.get("negative", False))
total_requests = self._hits + self._misses
hit_rate = (self._hits / total_requests * 100) if total_requests > 0 else 0
return {
"entry_count": entry_count,
"negative_count": negative_count,
"total_size_bytes": total_size,
"total_size_mb": round(total_size / (1024 * 1024), 2),
"max_size_mb": self.max_size_bytes / (1024 * 1024),
"hits": self._hits,
"misses": self._misses,
"hit_rate": round(hit_rate, 1),
}
@staticmethod
def _is_safe_url(url: str) -> bool:
"""Check that a URL is safe to fetch (no SSRF to internal resources)."""
try:
parsed = urlparse(url)
hostname = parsed.hostname
except ValueError:
return False
if parsed.scheme not in ("http", "https"):
return False
if not hostname:
return False
try:
resolved = socket.getaddrinfo(hostname, None)
for _, _, _, _, sockaddr in resolved:
ip = ipaddress.ip_address(sockaddr[0])
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved:
return False
except socket.gaierror, ValueError:
return False
return True
def fetch_and_cache(self, cache_id: str, url: str) -> tuple[bytes, str] | None:
"""Fetch an image from URL and cache it.
Args:
cache_id: Cache key
url: URL to fetch from
Returns:
Tuple of (image_data, content_type) or None on failure
"""
cached_data: tuple[bytes, str] | None = None
try:
if not self._is_safe_url(url):
logger.warning("Blocked request to disallowed URL: %s", url)
return None
response = requests.get(
url,
timeout=(5, 10),
headers=FETCH_HEADERS,
stream=True,
verify=get_ssl_verify(url),
)
response.raise_for_status()
# Validate content type
content_type = response.headers.get("content-type", "")
if not content_type.startswith("image/"):
self.put_negative(cache_id)
return None
# Read with size limit
data = BytesIO()
for chunk in response.iter_content(chunk_size=8192):
data.write(chunk)
if data.tell() > MAX_IMAGE_SIZE:
self.put_negative(cache_id)
return None
image_data = data.getvalue()
if not image_data:
self.put_negative(cache_id)
return None
# Store in cache
if self.put(cache_id, image_data, content_type):
# Get the actual content type from detection
detected = _detect_image_type(image_data)
if detected:
content_type = detected[0]
cached_data = image_data, content_type
except requests.exceptions.Timeout:
self.put_negative(cache_id, transient=True)
return None
except requests.exceptions.ConnectionError:
self.put_negative(cache_id, transient=True)
return None
except requests.exceptions.HTTPError as e:
is_404 = e.response is not None and e.response.status_code == HTTP_NOT_FOUND
self.put_negative(cache_id, transient=not is_404)
return None
except requests.exceptions.RequestException:
return None
else:
return cached_data
# Singleton instance (initialized lazily when config is available)
_instance: ImageCacheService | None = None
_instance_lock = threading.Lock()
def get_image_cache() -> ImageCacheService:
"""Get the singleton image cache instance.
Lazily initializes using config values.
"""
global _instance
if _instance is None:
with _instance_lock:
if _instance is None:
from shelfmark.config.env import CONFIG_DIR
from shelfmark.core.config import config
cache_dir = CONFIG_DIR / "covers"
max_size_mb = coerce_int(config.get("COVERS_CACHE_MAX_SIZE_MB", 500), 500)
ttl_days = coerce_int(config.get("COVERS_CACHE_TTL", 0), 0)
ttl_seconds = ttl_days * 86400 if ttl_days > 0 else 0
_instance = ImageCacheService(
cache_dir=cache_dir,
max_size_mb=max_size_mb,
ttl_seconds=ttl_seconds,
)
logger.debug(
"Initialized image cache: %s (max %sMB, TTL %s days)",
cache_dir,
max_size_mb,
ttl_days,
)
return _instance
def reset_image_cache() -> None:
"""Reset the singleton instance (for testing or config changes)."""
global _instance
with _instance_lock:
_instance = None
+179
View File
@@ -0,0 +1,179 @@
"""Logging configuration and custom logger with error tracing."""
import logging
import sys
from collections.abc import Mapping
from logging.handlers import RotatingFileHandler
from typing import TYPE_CHECKING
from shelfmark.config.env import ENABLE_LOGGING, LOG_FILE, LOG_LEVEL
if TYPE_CHECKING:
from pathlib import Path
class CustomLogger(logging.Logger):
"""Custom logger class with additional error_trace method."""
def error_trace(self, msg: object, *args: object, **kwargs: object) -> None:
"""Log an error message with full stack trace."""
self.log_resource_usage()
stack_info, stacklevel, extra = _extract_log_kwargs(kwargs)
self.error(
msg,
*args,
exc_info=True,
stack_info=stack_info,
stacklevel=stacklevel,
extra=extra,
)
def debug_trace(self, msg: object, *args: object, **kwargs: object) -> None:
"""Log a debug message (stack trace only if exception active)."""
stack_info, stacklevel, extra = _extract_log_kwargs(kwargs)
# Only include exc_info if there's actually an exception
has_exception = sys.exc_info()[0] is not None
self.debug(
msg,
*args,
exc_info=has_exception,
stack_info=stack_info,
stacklevel=stacklevel,
extra=extra,
)
def log_resource_usage(self) -> None:
"""Log best-effort CPU and memory usage for the current container."""
try:
import psutil
except ImportError:
return
# Best-effort only; this should never raise during exception logging.
try:
def _get_process_rss_mb(proc: object) -> float | None:
try:
proc_info = getattr(proc, "info", None)
if not isinstance(proc_info, Mapping):
return None
mem = proc_info.get("memory_info")
rss = getattr(mem, "rss", None)
if isinstance(rss, int | float):
return rss / (1024 * 1024)
except (
psutil.NoSuchProcess,
psutil.AccessDenied,
KeyError,
AttributeError,
):
return None
return None
# Sum RSS of all processes for actual app memory (container-friendly),
# but fall back gracefully on platforms that restrict process enumeration.
app_memory_mb = 0.0
try:
for proc in psutil.process_iter(["memory_info"]):
proc_rss_mb = _get_process_rss_mb(proc)
if proc_rss_mb is not None:
app_memory_mb += proc_rss_mb
except PermissionError, psutil.AccessDenied, OSError:
try:
app_memory_mb = psutil.Process().memory_info().rss / (1024 * 1024)
except AttributeError, OSError, psutil.Error:
app_memory_mb = 0.0
memory = psutil.virtual_memory()
system_used_mb = memory.used / (1024 * 1024)
available_mb = memory.available / (1024 * 1024)
cpu_percent = psutil.cpu_percent()
self.debug(
f"Container Memory: App={app_memory_mb:.2f} MB, System={system_used_mb:.2f} MB, "
f"Available={available_mb:.2f} MB, CPU: {cpu_percent:.2f}%"
)
except AttributeError, OSError, psutil.Error:
# Avoid breaking the original log call if psutil is missing or restricted.
return
def _extract_log_kwargs(
kwargs: Mapping[str, object],
) -> tuple[bool, int, Mapping[str, object] | None]:
stack_info = kwargs.get("stack_info")
normalized_stack_info = stack_info if isinstance(stack_info, bool) else False
stacklevel = kwargs.get("stacklevel")
normalized_stacklevel = stacklevel if isinstance(stacklevel, int) else 1
extra = kwargs.get("extra")
normalized_extra = _normalize_log_extra(extra)
return normalized_stack_info, normalized_stacklevel, normalized_extra
def _normalize_log_extra(value: object) -> Mapping[str, object] | None:
if not isinstance(value, Mapping):
return None
if all(isinstance(key, str) for key in value):
return value
return None
def setup_logger(name: str, log_file: Path = LOG_FILE) -> CustomLogger:
"""Set up and configure a logger instance.
Args:
name: The name of the logger instance
log_file: Optional path to log file. If None, logs only to stdout/stderr
Returns:
CustomLogger: Configured logger instance with error_trace method
"""
# Register our custom logger class
logging.setLoggerClass(CustomLogger)
# Create logger as CustomLogger instance
logger = CustomLogger(name)
log_level = getattr(logging, LOG_LEVEL, logging.INFO)
logger.setLevel(log_level)
formatter = logging.Formatter(
"%(asctime)s - %(name)s - %(levelname)s - %(filename)s:%(lineno)d - %(message)s"
)
# Console handler for Docker output
console_handler = logging.StreamHandler(sys.stdout)
console_handler.setFormatter(formatter)
console_handler.setLevel(log_level)
console_handler.addFilter(
lambda record: record.levelno < logging.ERROR
) # Only allow logs below ERROR to stdout
logger.addHandler(console_handler)
# Error handler for stderr
error_handler = logging.StreamHandler(sys.stderr)
error_handler.setLevel(logging.ERROR) # Error and above go to stderr
error_handler.setFormatter(formatter)
logger.addHandler(error_handler)
# File handler if log file is specified
try:
if ENABLE_LOGGING:
# Create log directory if it doesn't exist
log_dir = log_file.parent
log_dir.mkdir(parents=True, exist_ok=True)
file_handler = RotatingFileHandler(
log_file,
maxBytes=10485760, # 10MB
backupCount=5,
)
file_handler.setFormatter(formatter)
logger.addHandler(file_handler)
except (OSError, TypeError, ValueError) as e:
logger.error_trace(f"Failed to create log file: {e}", exc_info=True)
return logger

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