100 Commits
Author SHA1 Message Date
CaliBrain be803ed5ca Adding Coderabbit as a PR sponsor (#1682)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
<!-- coderabbit-ai-generated:v1 -->

## Summary by CodeRabbit

* **Documentation**
* Added a Sponsors section to the README recognizing CodeRabbit and
linking to its logo.
* **Chores**
* Removed the automated pull-request review workflows for repository and
fork pull requests. These workflows no longer trigger review runs for
the pull-request events they previously handled.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-11-09 12:58:40 -05:00
CaliBrain 2cb1fb4ac6 fix(tooltip): ignore sub-pixel overflow in the viewport clamp (#1651)
Closes #1646. In Firefox, hovering the Stop download button on an
in-progress download blanked the page with React error #185.

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

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


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

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

## Summary by CodeRabbit

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

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-11-03 00:00:21 -05:00
CaliBrain 947ce7bfe5 test(e2e): serve the mock Prowlarr .torrent from Prowlarr's own origin (#1602)
The nightly client-deluge profile has failed since 61c7ad3 with "Failed
to
add to deluge: Failed to fetch torrent file: torrent fetch refused:
address
is not public". Shelfmark now fetches a .torrent only from the
configured
Prowlarr/Newznab/Jackett origin or from a public address, and the mock
Prowlarr's release linked straight to http://mock-aa/payload.torrent, a
private address on another origin. qBittorrent and Transmission hid this
by
handing the URL to the client; Deluge needs the file and failed.

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

tests/download/test_prowlarr_mock_torrent_fetch.py runs the real
extract_torrent_info against the mock in normal CI, so the link cannot
drift
off the Prowlarr origin again unnoticed; it fails on the old mock with
the
same error.
2026-10-27 11:25:49 -04:00
CaliBrain 16e20679b4 fix(docker): bump Chromium pin to 156.0.8078.47 (#1601) 2026-10-27 10:50:22 -04:00
CaliBrain c5af6b9ae6 ci(jules): review the whole PR diff, not just the latest push (#1529)
jules-pr-reviewer v0.0.2 reviewed only before...head on synchronize.
After
"Update branch" merged main into fork PRs, that range was main's
commits, so
of their own changes. v0.0.3 of the calibrain fork always reviews the
PR's
full diff. Also correct the fork workflow comment: jules-forks has no
required reviewers by design, so runs don't wait for approval.
2026-10-22 03:20:45 -04:00
CaliBrain 9d5bcf0c58 ci(jules): review fork PRs through a gated pull_request_target workflow (#1528)
Fork PRs get no secrets under pull_request and Dependabot runs get only
Dependabot secrets, so JULES_API_KEY was empty and every such run failed
at
config. The same-repo workflow now skips them, and a new
pull_request_target
workflow reviews fork PRs behind maintainer approval on the jules-forks
environment, without checking out PR code. Both use calibrain's fork of
jules-pr-reviewer, since upstream refuses pull_request_target.
2026-10-22 02:40:50 -04:00
CaliBrain d887942630 fix(search): name the proxy when a gateway cuts a search off (#1522)
A reverse proxy whose read timeout is shorter than the release search
budget
(nginx defaults to 60s) answers a slow search with its own 504 page.
describeSearchFailure found no server message there and fell back to
"Unable to reach download source. Network may be restricted or mirrors
blocked.", which sent users off changing mirrors (discussion #1520).

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

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

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
Co-authored-by: calibrain <191882101+calibrain@users.noreply.github.com>
2026-10-21 14:25:27 -04:00
CaliBrain 1aa9846f22 fix(download): trust a pinned AA mirror and repair the full e2e profile (#1518)
The destination guard from the oceanofpdf/SSRF fix (f837506) trusts only
hosts in
the mirror lists and refuses any other host that resolves to a private
address.

The full e2e profile has failed on every PR since then: its AA mirror
was mock-aa,
but the slow-download links pointed at cf.mock.test (172.30.0.11). The
guard refused
the slow-download page before the internal Chrome bypasser ever started,
so the
download ended with "All download sources failed". Adding cf.mock.test
as a second
AA mirror would not help: AAMirrorSelector.rewrite would then move the
gated link
onto mock-aa, and the test would pass without Chrome solving anything.
The profile
now uses the gate as its only AA mirror. The cloudflare mock takes
CF_GATED_PATHS,
and the full profile challenges only /slow_download/, so search, detail
and the
final file pass through while the download still needs Chrome. The
unused
SLOW_DOWNLOAD_BASE indirection is gone.

The same allowlist missed a supported setup: Primary Mirror
(AA_BASE_URL) may pin a
URL that is not in the mirror list, and network uses it as the active AA
base. With
it on a LAN address, every AA request was refused. The guard now trusts
the pinned
URL too (mirrors.get_pinned_aa_base_url), and
has_aa_mirror_configuration reuses
the same helper.

download_url also ran the guard a second time outside its try block,
which repeated
the DNS lookup and could raise instead of returning None. The duplicate
call is gone.
2026-10-21 11:19:48 -04:00
CaliBrain d42494c696 fix(tor): survive container restarts and run Tor as debian-tor (#1517)
With USING_TOR=true, any restart that reuses the container (docker
restart, a Docker daemon restart, a host reboot) put it in a restart
loop. Only recreating the container ended it.

Tor appends to /var/log/tor/notices.log, and the file survives the
restart. tor.sh waited for Tor by grepping that log for "Bootstrapped
100%", so the previous boot's line passed at once, before Tor had
connected to anything. The NAT rules then went live, Tor could not
finish bootstrapping behind them, the connectivity check could not
resolve check.torproject.org, and the next boot found the same stale
line. tor.sh now moves the previous log to notices.log.prev before it
starts Tor, so the wait and the watchdog only see this boot's output.

Tor could not finish behind the NAT rules because it ran as root. The
rules exempt only debian-tor's traffic from the transparent redirect, so
every relay connection Tor opened after they went live was redirected
into its own TransPort: the log shows dozens of TLS handshakes dying.
This also meant a Tor that lost its guard could never reconnect, on any
kernel. torrc now has `User debian-tor` and `DataDirectory
/var/lib/tor`. Tor still binds DNSPort 53 as root, then drops. It opens
its log after dropping and refuses to start if it cannot, so tor.sh
hands the fresh log to debian-tor first.

The check also failed without a word. `RESULT=$(curl -s ...)` under
`set -e` ended tor.sh with curl's exit status, so the only trace was
ExitCode=6 (could not resolve host) in `docker inspect`. A reply that
was not the expected JSON did the same through grep. The check now:
- retries twice, 10s apart, since a fresh circuit can fail its first
  lookup;
- logs each failure with curl's exit status and error;
- says when it gives up and prints the last Tor log lines;
- still stops the container when traffic does not go through Tor.

- Reproduced on the v1.4.0 image (tor.sh is unchanged since then): after
  one `docker restart` the container restarted 9 times in 150s, each
  boot ending at "Verifying Tor connectivity...".
- With the new tor.sh mounted into the same image, the container went
  through 2 restarts in a row. Each boot verified Tor and reached
  gunicorn within 15-21s, and the restart policy never had to step in.
  Tor runs as debian-tor and its logs belong to it.
- Restarting Tor (`supervisorctl restart tor`) once the NAT rules were
  live, so it had to open new relay connections: as debian-tor it
  bootstrapped again in 4s and traffic still went through Tor. As root
  it had not bootstrapped after 120s, 1775 TLS handshakes had died, and
  curl could not resolve check.torproject.org (exit 6).
- A SIGHUP, which the rotation monitor sends every 5 minutes, keeps
  DNSPort 53 working after the drop to debian-tor.
- 9 new tests in tests/config/test_tor_script_rules.py. 8 of them fail
  on the old tor.sh; the 9th checks that traffic outside Tor still stops
  the container. Three need `grep -P` and skip on macOS, so I also ran
  the file in the Debian image: 23 passed.
- The `make python-checks` tools pass (ruff check and format,
  basedpyright on the app and tests, vulture). The non-e2e suite
  passes: 4366 tests on 86943f5.

Fixes #1502
2026-10-21 10:24:51 -04:00
CaliBrain c269230ef3 fix: review regressions in metadata, notifications, activity redaction, search options and genDebug (#1510)
- **Open Library (#1451): keep the work unless the search matched a
translation.** Open Library shows an English edition whenever one
matches the query. So "Mistborn" returned the 2023 "Mistborn" reissue
(2023) instead of "The Final Empire" (2001), and "Children of Dune"
returned "Children of Dune (Dune Chronicles, Book 3)" (1987). The
matched edition's title, ISBNs, cover, language and year now win only
when the edition is in another language and the query doesn't name the
work's own title. Checked against live Open Library: "Children of Dune"
and "Mistborn" give the v1.4.0 results again, and "Der Weg der Könige"
and "Kinder des Nebels" still return the German editions.
- **Google Books (#1434): the ISBN cache decorator is back on
`search_by_isbn`.** It had moved to the new `search_paginated`. Failed
searches were cached as empty pages, and ISBN lookups lost their cache.
- **Notifications: Test notification shows the plugin's failure reason
again.** Apprise 2 delivers on its own worker threads, even for a single
service when that service has a deadline. `_AppriseLogCapture` filtered
records by thread id and dropped all of them, so the UI only said "json:
delivery failed". Apprise runs those workers in a copy of the caller's
context, so the capture now filters on a context variable. That still
keeps concurrent dispatches apart. `AppriseAsset(async_mode=False)`
would not fix this, because the sequential path also uses the worker
pool when a service has a deadline.
- **Activity: `activity_update` no longer sends the raw task id to
non-admins.** The owner's `user_<id>` room got the raw task id. For a
request-linked download that id can hold a private tracker's passkey. It
now goes through `viewer_download_id`, the same mapping `/api/status`
and `download_progress` use. The admins room still gets the raw id.
- **SearchBar (#1476): Escape and an outside click close the homepage's
inline options panel again,** as they did in v1.4.0.
- **genDebug.sh: packages are listed through `importlib.metadata`,**
since #1404 removed pip from the image. The script also now redacts:
  - `PROWLARR_MAM_ID`
- `user:pass@` credentials in URLs (proxies), in both the environment
and the config files
- notification route URLs, which carry their service's token (Telegram,
Discord and others)

- `make python-checks`, `make python-test`, `make frontend-checks` and
`make frontend-test` pass on top of current `main`.
- New tests cover:
  - Open Library edition selection
  - Google Books caching
- log capture against a real Apprise 2 delivery to a local server that
answers 500, plus a concurrent dispatch on another thread
  - the `activity_update` payload sent to each room
  - genDebug redaction and the package listing

  Each one fails on the old code.
- I checked the SearchBar fix in the running app, because the frontend
has no DOM test setup: Escape closes the panel, an outside click closes
it, and a click inside keeps it open.
- I ran the genDebug redaction under GNU sed (Debian image) and BSD sed.
The package listing ran in the pip-less image.
2026-10-21 01:58:41 -04:00
CaliBrain a00f3d28bf fix(slskd): refuse unsafe peer paths, keep queued downloads alive, report search outages (#1512)
Fixes four defects in the Soulseek (slskd)

A peer names its own files, and Shelfmark works out where slskd stored
one from the last folder and the file name. `remote_directory_name`
returned the whole directory when its last segment was empty. So
`../../../etc//passwd.epub` or `/abs/path//book.epub` resolved outside
the downloads folder, and Shelfmark imported whatever was there. For a
multi-file release it moved those files into staging (`STAGE_MOVE`).

- New `safe_remote_segments` refuses a name with an empty, `.` or `..`
segment (which covers absolute and UNC paths), a NUL, or a drive
anywhere but a Windows share root (`C:\Books\…`, which never reaches a
local path).
- Search (`iter_peer_files`, so `build_releases`) drops such files.
- `download` refuses them before anything is queued, in both modes. The
spec arrives with the queued release payload, so search filtering alone
is not enough.
- `local_path_for` also refuses a path that resolves outside the root
(`resolve()` + `is_relative_to`), for example through a symlinked
folder.

`_poll`, used when isolation is off and as the fallback for slskd <
0.26, never asked for an activity grace. A file "Queued, Remotely" sent
the same status and progress on every poll, so the orchestrator
cancelled it after 300s and `SLSKD_QUEUE_TIMEOUT_MINUTES` never applied.
It now requests, renews and releases the grace the same way isolated
mode does.

`search` caught every exception per query, so slskd being down or
rejecting the API key showed as "no releases". When no query gets an
answer it now raises `SourceUnavailableError`, as Prowlarr does, and the
error reaches the user. One failed query among answered ones is still
not an outage.

A retry reuses the task's folder, and slskd renames a new file when one
is already there. Files from a failed attempt therefore meant
`_folder_complete`'s exact count could never match again. Now:
- a failed or cancelled attempt removes its folder;
- a new attempt removes any finished files an earlier one left (for
example after a restart) before it queues the batch.

`tests/slskd/conftest.py` stubbed `flask_socketio` whenever it had not
been imported yet, even though it is installed. Running `pytest
tests/slskd tests/` therefore broke the Flask app tests in the same
worker (285 errors, also on 10710ab). It now stubs only when the module
is missing.

- 25 new regression tests. All of them fail on 10710ab.
- `make python-checks` passes.
- `uv run pytest tests/slskd tests/ -m "not integration and not e2e"`
passes: 4047 tests on e83bab9.
2026-10-21 01:58:13 -04:00
CaliBrain 49aeec97cd fix(download): stop deleting user data and losing completed downloads (#1511)
Fixes six defects. Each one breaks downloads or deletes user data, and
each has a regression test that fails on 10710ab and on 77b80eb (current
main).

- **Completed-path wait (#1464)**: `_wait_for_completed_path` only
returned a directory after two identical signatures of supported
formats.
- A directory holding only archives (a scene `book.rar` + `.nfo` +
`.sfv`) never got a signature and failed with "Completed download has no
stable supported files yet". Archives (`.zip`/`.rar`, via `is_archive`)
now count toward the signature, so post-processing extracts them again,
as in v1.4.0.
- With `DOWNLOAD_CLIENT_COMPLETED_PATH_TIMEOUT=0`, `max_attempts` is 1,
so every directory failed. One sample with book files or archives is now
accepted when no retry is possible.
- **Remove & Delete Files (#1401)**: `post_process_cleanup` called
`remove(id, delete_files=True)` on torrents matched by `find_existing`
that Shelfmark never added, deleting the user's seeding data.
`_cleanup_refs` now records `added_by_shelfmark`. A pre-existing torrent
(complete, or in progress and joined) is removed but keeps its files.
- **Cancelling an unstarted torrent (#1421)**:
`_remove_unstarted_torrent` deleted with files whenever `progress == 0`.
That included `DownloadStatus.error()`, which always reports 0, and
checking states that are re-verifying data already on disk. The torrent
must now be QUEUED, PAUSED or DOWNLOADING. rTorrent's
`remove(delete_files=True)` always returns False, so a new
`DownloadClient.can_delete_files` flag (False on rTorrent) makes it
remove without files instead.
- **Queue grace overwritten**: when a QUEUED torrent's progress rose on
the same poll, the 900 s queue grace was immediately replaced by a
shorter stall window, because the orchestrator keeps one deadline. The
stall window is now requested only when it is longer than what is left
of the queue grace.
- **`drop_ignored_files` (#1480)**: the suffix rule `w.endswith("/" +
rel)` dropped a kept root `Book.epub` when `Extras/Book.epub` was
skipped. Each skipped entry now drops at most one file: the exact path,
else the file sharing the most trailing components, else a unique
basename, else an AudiobookBay glued `"<folder> <file>"` name (the
fallback `match_plan_to_files` uses, matched on folder plus file so
chaptered `Book 2 01.mp3` picks the right folder). Ties drop nothing.
- **Email and BookLore outputs** ignored `task.ignored_files` and sent
every prepared file. Both now apply `drop_ignored_files` once after
`prepare_output_files`, and report "No book files found" if the user
skipped everything. It is applied in the outputs rather than in
`prepare` because the folder output already filters in
`transfer_book_files`, and filtering twice could match a kept file on
the second pass.

- `make python-checks`: ruff, ruff format, basedpyright (backend +
tests), vulture all pass.
- `uv run pytest tests/ -m "not integration and not e2e"`: 4054 passed
on the rebased branch.
- New tests run against unfixed 77b80eb and 10710ab: all 18 new
regression cases fail, and the 11 existing post-process-cleanup tests
also fail there because they now build the 4-tuple cleanup ref.
2026-10-21 01:56:19 -04:00
CaliBrain 10710abd4e fix(download): send every file of a multi-file download to the browser (#1489)
Download to Browser only ever sent the first file of a multi-file
release
(audiobook chapters, packs): the folder output returned final_paths[0]
and
/api/localdownload served that one path.

The folder output now keeps every final path on the in-memory task
(download_paths), and /api/localdownload streams them as one
uncompressed
ZIP named after the book when there is more than one. The archive is
never
staged on disk or held in memory, and ZIP64 covers large audiobooks.
History still stores only the first path, so a re-download from history
after a dismiss or restart serves a single file, as before.

Also brings every linter back to green on all files:
- pre-commit: bump the ruff hook to v0.17.1 to match uv.lock;
ruff-format
  the Python examples in docs/dev and skills
- ruff: fix the remaining issues in skills/shelfmark/download_books.py;
  format base_handler.py
- basedpyright: Optional and attrs errors in databazeknih.py
- knip: drop 31 unused exports, reuse ColumnColorHint in colorMaps
- oxfmt 0.72: styles.css
- direct download: fix the "Welib, mirrors" typo and the test that still
  expected the pre-OceanofPDF message

Fixes #1467
2026-10-20 02:33:53 -04:00
CaliBrain 3c5cfcb77d fix/redact log secrets (#1488)
- fix(logging): mask tracker API keys in log output (#1486)
- fix(welib): follow slow_download links through the countdown (#1447)
2026-10-20 01:55:49 -04:00
CaliBrainandClaude Opus 5.5 9321096032 fix(clients): correct Debrid-Link file listing and rate-limit handling (#1425)
Follow-up to #1380.

- Fetch the file list with ?ids=, the documented parameter. There is no
  ?id=, so a many-file torrent was not expanded and the call could fail
  with badArguments at the last step of every download.
- Back off status checks for 60s after floodDetected, doubling to a 240s
cap, instead of an hour. The orchestrator cancels a download after five
minutes without a change, so the hour-long pause cancelled every active
  download. Only a flood on the status check starts it, and a successful
  check resets it.
- Drop the client-side one-hour stall timer. The orchestrator stall
timer
  always fires first.
- Keep progress at 50% when file retrieval starts instead of dropping to
  0% until the first file arrives.
- Report queued, paused and verifying torrents as QUEUED, PAUSED and
  CHECKING, so a queued torrent gets the handler queue grace.

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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 08:08:32 -04:00
CaliBrain f773ac6029 fix(sabnzbd): strip Prowlarr API key on cross-host NZB redirects (#1424)
_fetch_nzb_content sent X-Api-Key
then followed redirects with the header still attached, leaking the
Prowlarr key when Prowlarr 302s to the indexer download link. Follow
redirects manually with allow_redirects=False and re-evaluate headers
 per hop, mirroring the torrent path.
2026-10-02 22:46:16 -04:00
CaliBrain 119d3374e3 fix(downloads): reconcile downloads a restart interrupted (#1423) 2026-10-02 22:35:09 -04:00
CaliBrain bac0b93566 fix: keep https:// for qBittorrent and hide release sources from non-admins (#1422)
qBittorrent over HTTPS (#1417)

qbittorrent-api ignores the scheme in `host` and probes HTTP and HTTPS
itself. When the HTTPS probe fails, for instance on a certificate
Shelfmark doesn't trust, it falls back to plain HTTP against the TLS
port, and a reverse proxy answers "400 The plain HTTP request was sent
to HTTPS port". The download client and the Test Connection button now
pass FORCE_SCHEME_FROM_HOST for https:// URLs, so the configured scheme
is used as-is and a certificate problem is reported as one. http:// and
bare host:port URLs keep the probe: normalization adds http:// to a bare
host, and the probe follows a proxy's redirect to HTTPS.

Release sources leaked to non-admins (#1418)

A download queued from an approved request carries the release an admin
picked, and the requester could read it from their own activity feed:
the request's release_data (source_id, indexer, info URL, torrent
attributes), the download's full server path, and the download id
itself, which for Prowlarr is "<indexer id>:<guid>" and for a private
tracker is a URL into it. That id keys every status payload, the
/api/localdownload query and the cover proxy URL, so hiding
release_data alone would not have closed the leak.

For non-admin viewers:
- request rows keep only the release fields the activity cards display
- download_path is reduced to the file name, which the browser download
  reveals anyway
- request-linked downloads are addressed by an opaque id (a keyed HMAC
  of the task id) in the snapshot, history, /api/status, queue order,
  active downloads, dismissed keys, cover URLs and the websocket status
  and progress events. Routes that take a download id translate it
  back, searching only the caller's own downloads.

Downloads a user queued directly keep their real id: the user picked
that release from search results that already showed it, and the
release list matches its buttons to the queue by that id. Admin views
are unchanged. The activity sidebar now links a fulfilled request to
its download by request_id instead of release_data.source_id.

Closes #1417
Closes #1418
2026-10-02 20:38:58 -04:00
CaliBrain d2b4a0290e fix(ui): contain the result count added in #1362 (#1363)
Two regressions from the Anna's Archive search stats feature.

The release modal's count block declared flex-1, so it competed with the
tab strip's own flex-1 and took half the row - including on tabs whose
source reports no total, where it renders nothing at all (only
direct_download exposes total_results). Measured on a 1024px modal with
six sources, the scrollable tab strip fell from 846px to 423px against
601px of tabs; at 375px with two sources it fell from 197px to 44px. It
is now flex-none, and hidden below sm: the label alone is ~150px, which
the full-screen mobile modal cannot spare. Remeasured at 693px on
desktop and back to the pre-feature 197px on mobile.

directTotalResults is only ever written on the direct-mode branch, and
neither the universal branch nor App's resetSearchResultsState cleared
it, so a count from an earlier direct search survived a mode switch and
rendered over unrelated universal results. Both universal-branch reset
blocks now clear it.
2026-09-25 21:22:42 -04:00
CaliBrain 893f7bdb92 fix(mam): keep the session ID on MAM and rerun Prowlarr's exact search (#1399)
Follow-up to #1390.

- The origin came from each result's infoUrl, matched by a regex that
did
  not check the host, and the mam_id cookie had no domain. Any Prowlarr
  indexer returning a URL like https://evil.example?myanonamouse.net/t/1
  sent the session ID to evil.example. Requests now always go to
https://www.myanonamouse.net (Prowlarr's only MAM URL), with the cookie
  as a header and redirects off. Only results from Prowlarr's
  MyAnonamouse indexer are looked up, and their URLs must be on
  myanonamouse.net.
- The lookup searched every category and read one page, so for common
  titles most of Prowlarr's results were missed (a "Dune" audiobook
  search: 56 audiobooks on the first 100 of 328 matches). It now reruns
  Prowlarr's exact search: the same query clean-up, the MAM main
  categories behind the Torznab categories searched (13/15/16 for
  audiobooks, 14 for e-books, all once expanded), and the MAM indexer's
  own search type, search-in options and languages. Further pages are
  read while IDs are missing, page 1 of every title first, at most 4
  requests per search.
- Failed requests back off for 1, 2, 4 ... up to 30 minutes. The 10th
  consecutive failure stops enrichment until Test MAM Session passes,
  the session ID changes, or Shelfmark restarts.
- The detail cache prunes expired entries instead of growing for as long
  as Shelfmark runs.
2026-09-25 21:21:54 -04:00
CaliBrain dce91e6972 fix(audiobookbay): reuse the resolved magnet when retrying (#1388) (#1398)
Retrying an AudiobookBay download re-scraped the detail page for the
magnet link before checking the torrent client, so a torrent that had
already finished in the client could not be imported while AudiobookBay
was down.

The handler now keeps the magnet it resolved in the task's retry
context, which is persisted with the download history, and a retry hands
that magnet straight to the client's existing-download check. The
context is handler-owned, so a client-supplied download URL cannot seed
it.

Fixes #1388
2026-09-25 19:11:56 -04:00
CaliBrain ece6b8f341 fix(auth): fail closed when auth prerequisites are missing (#1387) (#1397)
Auth mode resolution fell back to "none" (anonymous full admin) whenever
the configured method's prerequisites were missing: no local password
admin for builtin/OIDC, no Calibre-Web database, a blank proxy header,
an
unrecognized AUTH_METHOD (including "OIDC" in uppercase), or any error
while reading the config. Deleting or demoting the last local admin was
allowed on purpose because of that fallback, which exposed OIDC
instances publicly.

- Only an explicit AUTH_METHOD=none disables authentication. A
configured
  method stays active when its prerequisites are missing, so sign-in
  fails instead of opening up.
- An unrecognized or unreadable AUTH_METHOD resolves to "unavailable",
  which still requires a session and accepts no login. Values are
  normalized, so AUTH_METHOD=OIDC works.
- Restore the guard against deleting or demoting the last local password
  admin while builtin/OIDC is active (unless DISABLE_LOCAL_AUTH is set).
- Require a local admin before enabling Local auth, as OIDC already did.
- Log a recovery hint at startup when builtin/OIDC runs without a local
  admin, and document recovery via AUTH_METHOD=none.
- Drop the "will fall back to No Authentication" UI toasts and hints.



Fixes https://github.com/calibrain/shelfmark/issues/1387
2026-09-25 18:56:16 -04:00
CaliBrain c7bfb20448 Default to english when no lang is slected (#1396)
Fixes https://github.com/calibrain/shelfmark/issues/1384
2026-09-25 18:38:25 -04:00
CaliBrain 2c6d6a02cd ci: debounce dev image builds instead of building nightly (#1376)
Replace the nightly cron and its check-changes job with a debounce.
Every push to main starts dev-image-debounce.yml, which waits out the
60-minute wait timer on the dev-image-debounce environment, then
dispatches the Docker workflow only if main still points at its commit.
A burst of merges now publishes one dev image, an hour after the last
merge.

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

Requires the dev-image-debounce environment with a 60-minute wait
timer (Settings → Environments).
2026-09-21 02:38:06 -04:00
CaliBrain e1c3f057ab fix: bypass recordings, welib wrong-md5 links, footer build sha (#1364) (#1373)
Debug screen recordings never started. Every bypass logged "Capturearea
1540x1050 at position 0.0 outside the screen size 1440x1880".We ask
ffmpeg for the fingerprint screen size plus margin, the size wealso pass
SeleniumBase as xvfb_metrics. SeleniumBase builds thatdisplay with
use_xauth=True, the image ships no xauth binary, so itfalls back to a
fixed 1440x1880 Xvfb and the requested size neverexists. Drop
-video_size so x11grab records the whole screen, whateversize it turned
out to be.

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

The footer showed the build date instead of the commit. CI
stampsBUILD_VERSION as <yyyy-mm-dd>-<sha> (pr-<sha> for PR images) and
thefooter kept its first seven characters, so dev images read
"Shelfmarkmain (2026-09)". Take the trailing commit sha instead: "main
(1a5b37d)".The full BUILD_VERSION stays in the hover title
2026-09-21 00:15:41 -04:00
CaliBrain d978896142 fix(auth): rename the API_KEY env var to SHELFMARK_API_KEY (#1374) 2026-09-21 00:10:10 -04:00
CaliBrain e5dd34ae0e fix: unbreak main and follow up on the Blackhole handoff review (#1346)
DownloadHistoryService.record_download and updated the single production
caller, but not the eleven in the test suite, leaving main red with 32
failures. Pass None, which is what the pre-#1336 behaviour recorded.

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

Finally, make tests/direct_download a package: test_libgen_extract.py
imports tests.libgen.sample_html across test directories, so without an
__init__.py pytest named its modules by bare basename and a same-named
module elsewhere would collide.
2026-09-17 16:27:53 -04:00
CaliBrain 35b89b0d78 fix(sources): restore Direct Download search errors and language matches (#1339)
Fixes two regressions from the provider-driven refactor (#1337). First,
the composite search caught RuntimeError, TypeError, ValueError and
request errors from each provider and returned an empty list, so a
failed search looked like one with no hits. It now raises the first
provider failure when no provider returned releases. Second, the shared
parser re-matched every row's language locally, dropping rows Anna's
Archive had already matched with &lang= (free-text cells like 'English,
French' or 'unknown'). parse_search_items gains a filter_languages
option, which AA turns off, so AA's own language-from-path filter is
again the only local one.
2026-09-14 01:30:05 -04:00
CaliBrain 1b17fe179a fix(irc): rank a surname-only result as partial, not wrong (#1332) (#1334)
"David Petrie" as "D. Petrie", then ranked the answer by the full name
to
recover the precision the surname gave up. The two halves disagreed.

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

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

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

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

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

Tested with make python-lint, python-format, python-dead-code,
python-typecheck
and python-test.
2026-09-11 22:26:28 -04:00
CaliBrain 9452ebc70d fix(bypass): stop handing solvers DDoS-Guard's ?check=1 probe URL (#1300)
html_get_page follows Anna's Archive redirects by hand, and DDoS-Guard's
gate
answers /search with a 302 to the same path plus `check=1`. The follower
walks
that handshake by reassigning `current_url`, so every downstream handoff
- the
403 branch, the 503-challenge branch, both redirect-loop rescues -
passed the
*probe* URL to the bypasser rather than the page we actually wanted.

A solver opens that in a fresh browser holding none of the cookies the
probe
exists to collect, so DDoS-Guard cannot verify it automatically and
serves the
manual CAPTCHA page that nothing can solve. The #1292 log is exactly
that: a 403
handed off on `&check=1`, FlareSolverr answering "Challenge solved!",
and a
4721-byte DDOS-GUARD captcha page coming back.

- `_solvable_url()` strips the probe parameter, applied at the single
choke point
in `_run_bypasser` so all four handoffs are covered. Scoped to the hosts
whose
redirects we follow manually; a URL without the parameter is returned by
  identity, so nothing else is re-encoded.

The same reports showed three further defects, all of which stand
whatever the
host was reacting to:

- The external bypasser logged that the solve had not cleared the
protection and
then returned the challenge page as a success. That skipped the one
recovery
left - get_bypassed_page's retry-and-rotate loop, where the next mirror
is a
different DDoS-Guard host - and filed the captcha page's own __ddg
cookies as
that host's clearance, to be replayed on every later request. It now
raises
  ChallengeNotSolvedError before storing anything.

- "Check that the bypasser is reachable and working" was the one piece
of advice
guaranteed to waste the reporter's time: it was reachable, it ran a full
solve,
and it returned a captcha. ChallengeNotSolvedError carries the marker so
the
  search layer can name the host as the cause instead of the bypasser.

- The untabled-page fingerprint logged `attempt_url`, which
html_get_page has
since rotated past. The #1298 bundle reported the page against
annas-archive.gl
when the body had come from .pk - the triage cost #1289 added the line
to
remove. The search now asks for the response URL and logs that. Its
give-up
shape is the tuple ("", url), which is truthy, so the exhaustion check
reads
  the body rather than the response.

Regression fixtures are built from the pages in the reports. The two
behavioural
handoff tests were checked against the unfixed code: both fail there,
reproducing
the reporter's log line verbatim.

Refs #1292
Refs #1298
2026-09-02 16:12:52 -04:00
CaliBrain d3f4ccd79a seleniumbase==4.53.5 (#1299)
replaces #1296
2026-09-02 15:48:48 -04:00
CaliBrain cb690b45b8 fix(prowlarr): rank releases by author instead of querying for it (#1293) (#1295)
MyAnonamouse is the only indexer Shelfmark treats as enriched, and it
alone was sent {title} {author} while every other indexer got the title
on its own. MAM matches all search terms conjunctively, so whenever the
metadata provider spelled the author differently to the tracker -
Hardcover says Timothy Ferriss, MAM lists Tim Ferriss - the search came
back empty and the UI reported No releases found for this book, with the
release sitting on the tracker the whole time.

The enriched flag is a statement about responses: MAM returns clean
author and bookTitle attributes, which is why it earns format detection
and preferential ordering. Using that same flag to shape the request is
the actual defect, and it is why turning the flag off recovers the
search but takes format detection down with it.

So the query is title-only for every indexer now, and the author orders
the results rather than narrowing them. MAM already hands us its author
field, so agreement is judged on data we hold instead of by an AND we
cannot control. The ranking is three-way on purpose - agrees, no
metadata, disagrees - so an indexer reporting no author does not sort
below one reporting the wrong author.

A wrong verdict costs a release its position, never its visibility: a
transliteration such as Dostoevsky against Dostoyevsky sorts last
instead of vanishing. That is what makes the loose token comparison safe
to ship without a tuning knob.

Falling back to a title-only query on zero results was the alternative.
It only rescues total failure - if two of six editions happen to use the
provider's spelling, the search returns those two, no fallback fires,
and the user quietly gets a truncated list. It also spends a round trip
inside the search deadline and stacks a retry on an indexer that may
still be solving a challenge (#1249).

Manual queries skip author ranking: they are the user's own words and
should not be reordered against the metadata they were typed to
override.
2026-09-01 12:59:33 -04:00
CaliBrain 3d7ea40088 fix(search): reach the server's deadline, query one author (#1285, #1252) (#1287)
Two independent reasons a working search reported failure to the user.

1. The client gave up before the server did (#1285)

`/api/releases` bounds one release search with RELEASE_SEARCH_TIMEOUT
(default
300s) and answers a spent budget with a sentence naming the real cause -
the
machinery added for #1276. The frontend then aborted the direct_download
search
at a hard-coded 180s, so it always won the race: the user saw "Request
timed
out. Check your network connection or proxy configuration." instead, and
raising RELEASE_SEARCH_TIMEOUT changed nothing they could observe, the
180s
being baked into the hashed bundle inside the image.

- /api/config reports the effective (clamped) budget, and the client
derives its
  abort from it plus a margin, so the server always answers first.
- Direct-mode search shows what the server actually said. Every non-auth
failure
was relabelled "Unable to reach download source. Network may be
restricted or
mirrors blocked.", which discarded the explanation and blamed the user's
network. ApiResponseError now carries `serverMessage`, set only when the
server
  explained itself, so the status-line placeholder still falls back.

Two latency fixes for the cost that made the timeout reachable at all:

- Fetch each distinct AA search URL once per search. The language-filter
retry
re-runs every title variant, and with DIRECT_DOWNLOAD_LANGUAGE_FROM_PATH
on
both passes build a byte-identical URL - behind DDoS-Guard each repeat
is a
  fresh browser solve.
- Drop the solve-only bypass method. `_bypass_method_cdp_gui_click`
opens with
exactly that call and returns the moment it works, so the entry ahead of
it
could only repeat the half that had already failed, plus the backoff
before
the method that does work started. Reported at 0/19 successes and ~5.5s
of
  each ~26s solve against DDoS-Guard.

2. The query carried every contributor, not one author (#1252)

`_pick_search_author` returned `book.search_author` verbatim while the
authors[]
fallback beside it deliberately narrowed to the first name before a
comma. Both
fields routinely arrive holding every contributor joined with ", ": the
frontend
builds `book.author` as `authors.join(', ')` for display
(bookTransformers.ts)
and the release modal sends that display string straight back as the
`author`
parameter, and `browse_record_to_book_metadata` and the manual-search
branch
both split the joined text into `authors` while still passing the
unsplit string
as `search_author`, so the split was never used.

A book whose metadata lists translators was therefore searched for as

    Blindness Jose Saramago, Giovanni Pontiero, <persian translator>

which matches nothing on Anna's Archive. The bypass succeeds, the search
comes
back empty, and the user is told the book has no releases.

Narrowed in one place, `search_plan.first_author`, so the two branches
cannot
drift apart again, and applied to the IRC source, which built its query
with the
same verbatim preference. Hardcover is unaffected: it already sets
`search_author` from `_simplify_author_for_search(authors[0])`, which
resolves
"Last, First" itself and never yields a multi-author string.
2026-09-01 12:38:57 -04:00
CaliBrain 633004ecf0 fix(search): stop reading real Anna's Archive pages as unsolved challenges (#1294)
`_looks_like_challenge_page` substring-matched "ddos-guard"/"cloudflare"
over
the whole document. DDoS-Guard-fronted sites carry those strings on
their own
pages - Anna's Archive ships a `DDOS-GUARD` comment in the inline JS it
serves
on every page - so every real AA response that was not a results table
was
reported as an unsolved protection challenge, sending users off to fix a
bypasser that had just succeeded.

Measured against live pages: a served AA page (HTTP 200) is 182,685
bytes and
matched the old detector; the real interstitial is 902 bytes.

- `_looks_like_challenge_page` now delegates to the shared
`challenge_marker()`,
whose 64 KB cap is what separates a few-KB interstitial from the page
behind
it. `download/http.py` already used it; this module carried an unguarded
  private copy.
- `_looks_like_aa_page` is checked ahead of the challenge branch. A
genuine
interstitial carries no AA markers, so nothing actually blocked leaks
through.

Also adds the diagnostics whose absence made #1289 guesswork: the debug
bundle
carries no response bodies, so "unsolved protection challenge" and
FlareSolverr's
"Challenge solved!" were indistinguishable after the fact.

- `_log_untabled_search_page()` fingerprints the one ambiguous shape at
INFO -
size, size-cap verdict, AA markers, challenge marker - with a bounded
700-char
  head at DEBUG. Best-effort: it swallows its own errors.
- The external bypasser records what it actually returned, and warns
when it
  reports success while handing back a challenge page.

Regression tests use fixtures built from the live pages rather than
invented
ones; the previous fixtures were two-line synthetic pages with no
"ddos-guard"
substring, which is why nothing caught this.

Closes #1289
Closes #1292
2026-09-01 11:19:01 -04:00
CaliBrain 97e289ae13 fix: search, Prowlarr and qBittorrent follow-ups (#1276, #1283) (#1284) 2026-08-30 03:09:13 -04:00
CaliBrain b25acdb2ad fix(packs): don't disrupt normal downloads when inspecting for packs (#1274)
Follow-ups to the multi-book pack feature (#1270), which inspects every
release before download. Two behaviours leaked into the ordinary
single-book
flow and are corrected here:

- A flat folder of chaptered audio (`01 - Chapter.mp3`, `02 - ...`) was
detected as a pack, because each track name parses to a series position,
so
clicking download popped the review panel for one normal audiobook. Flat
folders are now split one-book-per-file only with real evidence of
distinct
books: two or more series positions, more than one title, and no
chaptered
audio (only the single-file m4b/m4a containers and ebook formats
qualify).
  Subfolder packs and flat m4b/m4a packs are unchanged.

- Every release that couldn't be inspected (usenet, magnet-only, sources
  without a list_files hook, ABB single-file) showed an info toast on
download. That is now a console.warn, so a normal download is silent
again.

Adds regression tests for the chaptered-mp3 cases.
2026-08-27 01:00:54 -04:00
CaliBrain ff06a1a581 fix(search): follow-ups to per-user book languages (#1267)
Review follow-ups to #1255, all in the code that PR touched.

Drop the dead user_id from the Prowlarr retry path.
ProwlarrSource.search
never reads plan.languages, and _refresh_release builds a synthetic book
with no titles_by_language, so the title variants came out identical
with
and without it. It also should not language-filter: it re-finds one
exact
release by its guid.

Pin the tab move in tests. BOOK_LANGUAGE moved from the General tab to
Search Mode with no migration, which only works because both tabs
persist
into the same settings.json. Nothing asserted that, so splitting the
files
later would silently reset every install to ["en"]. Covers the stored
value, a fresh install, and ENV precedence.

Stop the UI inventing a default language. An empty BOOK_LANGUAGE is a
deliberate "no default filter" that the backend preserves, but the two
frontend call sites replaced it with the first supported language, so
the
filter said English where the server filtered nothing.
resolveDefaultLanguageCodes
now falls back only when the value is absent.

Keep the normalized value for every validated search key.
validate_user_settings
gated the write-back on a hand-maintained subset of the keys the search
validator recognises, so METADATA_PROVIDER_COMBINED,
SHOW_COMBINED_SELECTOR
and FORCE_COMBINED_SEARCH were validated and then stored raw -- a padded
provider name was accepted and persisted with its padding. Reuse the
validator's own key set instead.

Skip blank language entries rather than rejecting them, so "" and "en,"
mean the same as [] and ["en"] instead of erroring on an unnamed
language.

Extract resolveListOverride for the list-override detection that was
copy-pasted between the two user-settings sections, and mention
languages
in the Search Preferences section description.
2026-08-24 18:11:29 -04:00
CaliBrain a5595cf9f1 Change test for fake extension that wont work (#1266) 2026-08-24 17:54:10 -04:00
CaliBrain ddc26f01b6 fix(download): escalating per-host cooldown on HTTP 429 (#1263)
Anna's Archive 429-throttles the source IP after repeated automated
requests.
The bypasser could clear the DDoS-Guard challenge but not the 429, so
each retry
re-solved, re-spawned Chrome, and rotated mirrors that share the same IP
- a
costly loop that never converged.

Add a process-global, per-host cooldown that escalates 2 -> 5 -> 10 ->
15 -> 30
minutes each time a host 429s again after its window elapsed, resetting
after a
long clear gap. Mirror selection skips cooling hosts and the bypasser
refuses to
solve one, so a throttled host fails fast instead of storming the
solver.
2026-08-24 13:08:07 -04:00
CaliBrain 89104ae80f fix(search): let manual search switch media type under forced combined search (#1262)
Manual search browses release sources directly, one media type at a
time,
so the combined (both) flow never applied to it — yet
FORCE_COMBINED_SEARCH
locked the content-type selector onto both, pinning manual search to
ebook
sources with no way to reach audiobook sources (no Audiobay tab).

Treat a manual search target as combined-exempt in the search bar:
present a
plain, switchable Books/Audiobooks selector (unlocked, no combined
toggle),
even when combined search is forced on for metadata targets. Metadata
search
behavior is unchanged.

Fixes #1256
2026-08-24 01:57:42 -04:00
CaliBrain 95e34670f7 fix/group archive extracted audiobooks (#1261)
- fix: group multi-file audiobooks that arrive as an archive
- Surface the concrete reason when a direct-download fetch fails
2026-08-24 01:25:00 -04:00
CaliBrain 7d56624ab6 fix: group multi-file audiobooks that arrive as an archive (#1254)
Follow-up to #1237. \`rename_and_group\` only grouped when the source
root was a directory, so a multi-file audiobook delivered as a single
archive fell through to the flat path: a \`Book.zip\` of twelve chapters
landed loose in the destination root with its original chapter names —
the layout #1181 is about.

The \`is_dir()\` guard was there to keep \`Book.zip/\` from becoming the
folder name, but skipping the file case gives up the grouping instead of
naming it. A non-directory source can only produce several book files by
having been extracted (\`collect_staged_files\` returns a single-element
list for every other file shape), so the archive stem is the release
name and the suffix is packaging: group under \`Book/\`.

Also regenerates the env docs for the new option and gives it the same
\"do not use with ingest folders\" caveat Rename and Organize carries,
since both now create directories in the destination.

Tested: reverting only the source fix makes both new tests fail and the
\`rename\` control case pass, so grouping stays opt-in. Full non-e2e
suite green (2653 passed).
2026-08-21 10:43:06 -04:00
CaliBrain e7007865a4 fix(prowlarr): stop turning indexer failures into empty results and 404s (#1251)
Two independent bugs, both from an indexer that Prowlarr proxies rather
than answers for itself: the search never reported that it had failed,
and the grab never resolved what it was handed.

Search. A Torznab search is Prowlarr proxying a live request out to the
tracker, so for a Cloudflare-fronted indexer it waits on FlareSolverr.
The client gave it the 30s budget sized for Prowlarr's own JSON
endpoints, then swallowed every failure -- the timeout, the 429 Prowlarr
returns once it has disabled an indexer, a parse error -- into the same
empty list that means "this indexer has nothing". A cold challenge
routinely runs past a minute, so the UI said "No releases found for this
book" while FlareSolverr was still solving. That empty list also drove
the auto-expand retry, which fires on "no results with the category
filter". A timeout satisfies it, so Shelfmark sent a second search to an
indexer still busy with the first -- two Chromes at once, enough to take
FlareSolverr's down on a small host.

torznab_search now raises ProwlarrSearchError, and an empty list
strictly
means the indexer answered with no matches. The source records which
indexer searches failed: one dead indexer no longer sinks the others,
auto-expand runs only when every indexer genuinely answered, and zero
results with at least one failure raises SourceUnavailableError, which
the releases endpoint already turns into a 503 carrying a real message.
Prowlarr being unreachable was the same lie by another route -- the
indexer list came back empty, leaving nothing to query -- and now says
so.

Indexer searches also get their own timeout, PROWLARR_INDEXER_TIMEOUT,
defaulting to 90s and clamped to 5-300. Prowlarr's status and indexer
list keep 30s so Test Connection stays responsive, and the connect
timeout is split out at 10s so an unreachable Prowlarr fails fast rather
than hanging for the whole read budget. The overall per-request search
budget now scales to twice the indexer timeout, capped at 240s, so
raising the setting is not undone by the cap one level up while staying
under the 300s gunicorn worker timeout.

Grab. Prowlarr hands out a proxy download URL, with no magnetUrl and no
infoHash, for any indexer that only publishes torrent files. The native
Real-Debrid client built its magnet as "if not
url.startswith('magnet:') and expected_hash", so with no hash to work
from it left the URL alone and POSTed it to /torrents/addMagnet as the
magnet field. Real-Debrid answered 404 and the grab died on a raw HTTP
error. AllDebrid carried the same line and the same bug.

Both now resolve the URL first, through the extract_torrent_info path
the
torrent clients have used since #1108: pass a magnet through untouched,
follow a redirect or a response body that turns out to be a magnet,
otherwise upload the fetched .torrent, and fall back to a magnet built
from the infoHash only when the fetch failed. The file is preferred over
a synthesized urn:btih: magnet because it carries the tracker list; a
bare hash leaves the service to find the swarm on DHT alone. Fetches are
shared with the rest of the add path through the torrent fetch cache, so
resolving costs at most one request. Real-Debrid takes the file on PUT
/torrents/addTorrent with the raw bytes as the request body, AllDebrid
on
POST /magnet/upload/file as multipart files[]. A URL that resolves to
neither form now raises before any request reaches the service, so the
user reads why instead of a 404. Neither debrid client had any test
coverage; both have some now.

Fixes #1249
Fixes #1250
2026-08-21 09:07:03 -04:00
CaliBrain 5b3df2a463 docs(hardcover): list the API key scopes Shelfmark needs (#1243)
Hardcover's August 2026 token system replaced blanket access with
per-token scopes, and nothing in the docs said which ones Shelfmark
actually uses. A key missing write:library or write:lists still passes
Test Connection -- the reading-status and auto-remove-on-download calls
just fail silently afterwards.

Verified against a live hc_pat_ key: every scope in the table backs a
query or mutation the provider really issues, and the omitted ones
(journal, goals, reviews, prompts, notifications, account) are absent
from the provider entirely.

Refs #1240
2026-08-20 19:29:55 -04:00
CaliBrain 5247ec6124 fix(bypass): close the gaps a helper that outlives its request opened (#1244)
assumptions the code around it still made were written for a helper that
was killed after every request.

A bypass that hits the child's deadline is cancelled from the calling
thread, which returns the moment the cancellation is scheduled - so the
helper went on to serve the next request while the abandoned one was
still closing its browser, on the same loop, sharing the DISPLAY globals
and one process group. The deadline now lives inside the loop, where
asyncio.wait_for() waits for the unwind before it raises, with the
calling thread keeping a bounded backstop in case the cleanup wedges
too. Both budgets are set so the child still answers before the parent
gives up on it.

The helper's cookie store survived the request as well, and the whole of
it is exported back to the parent on every answer - so clearance the
parent had purged for one host came back the next time some other host
was solved, the dead-cookie resurrection _redirect_loop_handoff purges
to avoid. The child starts each request from an empty store again; the
parent already runs the cached-cookie check against a superset of it.

DNS config is compared against what the helper is actually resolving
through rather than skipped whenever the parent reports "auto", so a
user flipping CUSTOM_DNS back to auto - which applies live - reaches a
warm helper instead of leaving it on an abandoned DoH resolver.

The 15s exit grace is now asked only of a helper that can still read its
stdin. One dropped mid-bypass never returns to that read, so the grace
could only end in the kill - while a user cancelling a download, and
every bypass queued behind them on LOCKED, waited it out.

Result files are cleaned on the timeout and cancellation paths too,
staging file included, rather than only when the answer was read.
2026-08-20 19:29:33 -04:00
CaliBrain bd21ec1257 fix(audiobookbay): search the ASCII punctuation ABB actually stores (#1242)
WordPress texturizes punctuation on output only, so a post stored as
"The
Stranger's Wife" renders as "The Stranger’s Wife". ABB's search matches
the
stored value and ANDs its terms, so one typographic character in the
query
empties the entire result set rather than merely ranking worse. Book
metadata
and mobile keyboards both hand us those characters.

Map curly quotes, dashes and ellipses to ASCII before a query goes out,
and on
both sides of the relevance comparison, since scraped titles carry the
rendered
forms. Release titles are still stored and displayed exactly as ABB
renders
them; only matching normalizes.

Also percent-encode the search query properly. The hand-rolled encoder
only
escaped double quotes and spaces, so a bare "&" started a new query
parameter
and silently truncated the search: "detective dan riley books 1 & 2
weatherley"
reached ABB as "detective dan riley books 1" and returned six
confident-looking
results without the requested book among them. "%" and "+" were mangled
too.
2026-08-20 19:00:27 -04:00
CaliBrain 646b531669 fix(hardcover): accept the short hc_pat_ keys Hardcover issues now (#1241)
Hardcover replaced its ~500 char JWTs with short opaque personal access
tokens ("hc_pat_..."), and the connection test rejected anything under
100 chars before a request ever left Shelfmark, so every newly created
key failed with "API key seems too short".

The length floor now applies only to keys without the hc_pat_ prefix; a
prefixed key goes straight to Hardcover, which is the authority on
whether it is valid. Also strip a pasted "bearer " prefix regardless of
casing -- Hardcover's docs tell users to paste the token into an
"authorization" header, so the prefix rides along on the copy, and the
old case-sensitive removeprefix() sent it through as part of the token.
The API key field now names the expected shape.

Note that Hardcover's PAT path currently answers every hc_pat_ token
with a 500, a fabricated one included, while non-PAT tokens still get a
clean 401. So a new key cannot connect yet regardless of this change --
that failure is server-side and not something this code can reach.

Refs #1240
2026-08-20 14:42:45 -04:00
CaliBrain 7193036626 fix(rtorrent): apply the audiobook label to audiobook downloads (#1239)
add_download() picks self._audiobook_label from a content_type kwarg,
but the only call site never passed one, so is_audiobook was always
False and every download got RTORRENT_LABEL. category does not fill
the gap: _get_category_for_task() returns None for rTorrent, which has
no category concept, leaving content_type as its only audiobook signal.

Pass task.content_type through from base_handler, and match it with the
shared is_audiobook() helper instead of == "audiobook".
normalize_content_type()
treats "book (audiobook)" as an audiobook, so the exact-string check
would have mislabeled that value even once it arrived.

The existing rTorrent tests passed content_type straight to the client,
which is why nothing caught the missing wiring; the new handler test
covers the call site itself.

Post-processing was never affected: destination.py reads
task.content_type directly, so files already landed in
DESTINATION_AUDIOBOOK correctly.

Fixes #1235
2026-08-20 10:39:50 -04:00
CaliBrain 12d554a92f fix(download): hand a 503 carrying a challenge to the bypasser (#1238)
503 is in RETRYABLE_CODES, and the bypasser is only ever reached from
the 403
branch and the AA redirect-loop rescues. Once Z-Library re-serves its
DDoS-Guard
interstitial with the same cookie the #1188 handshake already echoed
back, the
request has nothing left to try and spends every attempt on the same
wall.

Gate the handoff on the response body rather than the status, so a
genuine
overloaded-origin 503 keeps its retry path, and on
allow_bypasser_fallback, so
best-effort fetches still fail fast. The challenge indicators move out
of
internal_bypasser into shelfmark/bypass/challenge.py so http.py can use
them
without importing SeleniumBase, which is lazily imported precisely
because it
is optional.

Refs #1233
2026-08-20 10:29:52 -04:00
CaliBrain fae6140c6a fix(bypass): scope browser cleanup to the calling session (#1232)
The orphan sweep ran a container-wide 'pkill -9 -f
chrome|chromium|Xvfb|ffmpeg', so it also matched browsers another bypass
was still driving. Scope it by process group: kill only our own group
and groups whose leader has died. Spawn the helper with
start_new_session so its browser tree is identifiable, tear the whole
group down after every run (a timed-out helper used to leak its Chrome
and Xvfb), and have an orphaned helper take its browser down with it.
Fixes #1231.
2026-08-18 23:24:43 -04:00
CaliBrain 4cd1091d16 fix(hardcover): send the field count Hardcover's Book search requires (#1224)
Advanced title search, advanced title+author search, and the title
typeahead returned zero results every time, and the sort fallback added
in #1183 blamed the sort value for it.

Hardcover turns the `fields` search parameter into Typesense's
`query_by`
but keeps `num_typos` and `query_by_weights` as fixed-length presets per
query_type. For query_type=Book the preset expects exactly five fields,
so a shorter list is not searched loosely - the whole search is rejected
with a null results body. Confirmed against the live API: 1, 2, 3, 4 and
6 fields are all rejected, only 5 works, and weights must match
one-for-one when sent. Every Book-type list we sent was the wrong length
- the title typeahead and advanced title search sent 2, title+author
sent 3.

- Send BOOK_SEARCH_FIELDS (the full five) for every narrowed Book search
  and express the intent through weights instead. Weights only bias
  ranking - a field weighted 0 still matches - so a title search now
  ranks titles first rather than restricting to them. That is the
  closest behaviour Hardcover still allows, and there is no client-side
  filter to restore the old precision.
- Pin the field and weight counts in tests, since the failure mode is a
  silent zero results rather than an error.

The sort fallback from #1183 also misread these rejections:

- Select the `error` field on every search and log Hardcover's own
  explanation. The reason is only ever in that sibling field, so a
  rejection surfaced as "returned no result body" with nothing to act
  on. Reading it is what made the field-count rule findable.
- Drop `sort` entirely on the retry instead of sending an empty string.
  An empty sort is a value like any other and can be rejected too.
- Arm the 900s sticky window only after the sortless retry succeeds. It
  was armed before the retry and never rolled back, so one rejected
  typeahead disabled sorting process-wide for 15 minutes whatever the
  actual cause.

Verified against the live Hardcover API: advanced title search 0 -> 84
results, title+author 0 -> 139, title typeahead 0 -> 84 with the exact
title top. 2566 unit tests pass; ruff, basedpyright and vulture clean.

Refs #1183. The sort_by regression #1183 was written for is gone from
Hardcover's side - every sort value it rejected, including the one in
the report, is accepted again today. Two plain-search rejections in that
report (fields=None) remain unexplained: they could not be reproduced
under any per_page, page depth, sort value or query shape, and are most
likely transient upstream. They now self-report the reason if they
recur.
2026-08-16 20:55:08 -04:00
CaliBrain 651096ed7b fix(bypass): reuse external bypasser clearance instead of re-solving (#1223)
Direct download was unusable behind an external bypasser (FlareSolverr /
Byparr): every request paid a 403 plus a full solve, and a search that
never ran was reported to the user as "No books found".

Clearance was discarded on the external path. get_cf_cookies_for_domain
and get_cf_user_agent_for_domain returned {} / None whenever
USING_EXTERNAL_BYPASSER was set, and _fetch_via_bypasser read only
solution.response - dropping solution.cookies and solution.userAgent,
which FlareSolverr-compatible services do return. A solve therefore
cleared the one request that paid for it and nothing else, and a file
download - which the solver cannot proxy, being binary - presented no
clearance at all. Diagnosed from a v1.3.9 debug bundle: ~35s in the
bypasser per search, on every search.

- Move the cookie jar out of internal_bypasser into bypass/cookie_store.
  internal_bypasser imports seleniumbase at module scope, which is the
  dependency an external-bypasser deployment is entitled not to have, so
  it cannot host a store the external path depends on.
- Harvest solution.cookies and solution.userAgent after a successful
  solve. The existing filtering applies unchanged, so the per-check
  __ddg8_/__ddg9_/__ddg10_ trio is still dropped and the external path
  cannot reintroduce the ?check=1 loop fixed in ebb833a. The UA matters
  as much as the cookies: Cloudflare ties cf_clearance to the UA that
  solved the challenge.
- Read cookie fields from either shape - CDP objects or JSON mappings.
  Both use the same field names, expires included.
- Point http.py's getters and _purge_clearance at the shared store, so
  either bypasser fills and drains the same jar.
- Give the Docker helper-subprocess handoff explicit export_store /
  import_store rather than reaching into module globals.

An unsolved challenge was also indistinguishable from an empty result.
_looks_like_aa_page() counted the challenge markers as "recognisably
AA", so _fetch_search_table handed a DDoS-Guard interstitial back as a
legitimate no-table response and the user was told their query found
nothing when the search never ran. Split challenge detection out and
raise SearchUnavailableError with the reason instead. The mirror is
still not quarantined - every mirror shares the same protection, so it
is not the mirror's fault.

Verified: 2531 unit tests pass; ruff, basedpyright and vulture clean;
e2e bypasser-external profile passes (5). Its mock FlareSolverr already
returned cookies and userAgent from /v1 - the contract was there,
shelfmark was not reading it.

Refs #1220. Deliberately not "Fixes": this removes the re-solve and
makes a failed solve legible, but if Byparr genuinely cannot clear AA's
current DDoS-Guard, the reporter now gets that as an error rather than a
silent "no books found". The download path may swallow interstitials the
same way; not audited here.
2026-08-16 12:08:45 -04:00
CaliBrain ebb833a82c fix(bypass): discard rejected DDoS-Guard cookies instead of replaying them (#1221)
A cookie that has been rejected was kept and presented again on every
later
request, so a single bad clearance could re-arm the challenge
indefinitely.

Cookie storage:
- Enforce expiry for every stored cookie, not just cf_clearance.
DDoS-Guard
domains have no cf_clearance, so the existing check never fired for them
and
  expired cookies were replayed forever.
- Stop storing the per-check cookies __ddg8_/__ddg9_/__ddg10_ and
ddg_last_challenge. Captured live from Anna's Archive, these carry the
client
IP and the timestamp the check was issued (~40 min), versus ~1 year for
the
  __ddg1_/__ddg2_/__ddgid_ clearance. Replaying an IP-bound token stops
describing the caller as soon as the egress IP changes, which is routine
  behind a VPN.

Failure handling — every path that is rejected while carrying cookies
now
purges them, not just the redirect loop:
- 403 returned while presenting cookies.
- Cached-cookie attempt rejected, whether by status or by redirect loop.
- Factored the purge into _purge_clearance, guarded on a non-empty
hostname
since clear_cf_cookies("") means "every host" and would wipe clearance
for
  sites that are working fine.

Also fix the search warm-up switches shipped inert in v1.3.8:
SEARCH_WARMUP_ENABLED and SEARCH_WARMUP_QUERY are not in the settings
registry, and config.get only consults the environment for keys it
knows, so
both always returned their defaults — the warm-up could not be turned
off or
retargeted. Read os.environ first.

Refs #1220. Deliberately not "Fixes": the reported failure could not be
reproduced on v1.3.8 from a stable IP (the reporter's own queries all
returned
200 on both the pre- and post-change builds), and the new purge paths
did not
fire in live testing because the failures arrive as redirect loops,
which were
already purged. These are correctness fixes with no measured effect on
that
issue. The underlying problem remains that Chrome-obtained cookies never
satisfy DDoS-Guard when replayed by requests, so every search still
re-solves.

Verified: 2542 unit tests pass; ruff, basedpyright and vulture clean;
e2e
platform baseline (10), full (6) and bypasser-external (5) all pass;
five
sequential live searches against Anna's Archive all returned 200 with
zero
"Exceeded 30 redirects".
2026-08-15 17:08:11 -04:00
CaliBrain b7093f4594 Fix log to debug DNS (#1219) 2026-08-15 15:30:29 -04:00
CaliBrain b656f019be feat(download): add DoH wireformat support, mirror quarantine, and search warmup (#1218)
- Add RFC 8484 DNS wireformat codec and HTTP/2 support (httpx) for
Quad9/OpenDNS DoH providers.
- Quarantine dead, parked, or seized mirrors for the session on hard
failure (DNS errors, connection refused, 410/451, parked pages) while
preserving bypass clearance on live mirrors.
- Add background startup search warmup to prime DNS, elect mirrors, and
pre-solve protection challenges to eliminate cold-start search latency.
- Add comprehensive test suites for DoH wireformat, mirror quarantine,
parked domain detection, and search warmup.
2026-08-15 14:17:44 -04:00
CaliBrain 6e96ead519 Fix frontend timeout search (#1217) 2026-08-15 13:44:09 -04:00
CaliBrain 7345f6be1a Fix README and hints for audiobooks (#1215) 2026-08-15 12:19:58 -04:00
CaliBrain 2b8b35bb52 fix(newznab): make indexer book categories configurable (#1214)
Newznab searches hardcoded category 7000 for ebooks and 3030 for
audiobooks,
so indexers using custom IDs returned no results or the wrong ones. Add
NEWZNAB_EBOOK_CATEGORIES and NEWZNAB_AUDIOBOOK_CATEGORIES (tag lists,
defaulting to 7000 and 3030) and resolve the search categories from
config.

Values are parsed leniently — list or comma/whitespace separated,
non-numeric
entries skipped, duplicates dropped — and fall back to the standard IDs
when
empty, so a cleared field can't silently widen the search to every
category.
NEWZNAB_AUTO_EXPAND remains the way to do that on purpose.

Results carrying a custom ID outside the standard 7000-7999 / 3030
ranges were
typed as "other", which routed custom-category audiobooks as ebooks.
Trust the
searched content type when a result carries a category we explicitly
asked for.

Also drop the unused NEWZNAB_BOOKS / NEWZNAB_AUDIOBOOKS constants from
api.py —
a third copy of the same hardcoding.

Closes #1208
2026-08-15 11:48:10 -04:00
CaliBrain 0a5256ecbb fix(download): reconcile the two AA redirect-loop rescues (#1213)
#1210 and #1212 both added a DDoS-Guard `?check=1` rescue, and #1212 was
branched before #1210 landed, so the merged result had two of them with
identical guards. #1212's inline handoff returns before the raise that
#1210's exception handler keys on, so the handler was shadowed and its
stale-cookie purge — the substance of #1210 — never ran. Its regression
test has been failing on main since the merge.

Fold both into one path:

- `_redirect_loop_handoff()` purges the host's stale clearance cookies,
  then bypasses, so the inline AA handoff and the exception handler
  cannot drift apart again.
- The exception handler keeps its own reason to exist: non-AA hosts run
with allow_redirects=True, so `requests` raises the loop itself and the
  manual AA follower never sees it. It now invokes the bypasser directly
  rather than setting a flag and continuing, which was a no-op at
  MAX_RETRY=1 for the same reason the 403 handoff was.
- An unrescuable loop returns empty instead of raising TooManyRedirects
into the retry path. That error is not retryable and carries no status,
  so `/dyn/md5/summary` (allow_bypasser_fallback=False) re-ran the full
  6-redirect loop on all 10 attempts: 60 requests to AA and ~30s of
  backoff, measured. Every AA mirror shares the challenge, so there is
  nothing to rotate to.
- `allow_bypasser_fallback` docs now describe what the flag actually
  gates; the old text predated #1198 and named the wrong callers.
2026-08-15 11:14:13 -04:00
CaliBrain d0e008adde Stop dropping audiobook releases that are not m4b or mp3 (#1199)
An IRC audiobook search returned nothing while OpenBooks, reading the
same @search answer from the same channel, listed results. Three
separate defects were discarding them.

The audiobook format list was maintained by hand in four places and had
drifted. The settings UI offered only m4b/mp3/m4a/zip/rar, and that list
is the only one a user's config can be built from, so flac, opus, ogg,
aac, wav and wma were unreachable everywhere — even though the IRC
parser recognized them, the IRC sorter ranked them (dead code that could
never fire), archive extraction knew them and Prowlarr searched for
them. A FLAC audiobook was invisible in search and, if it arrived
anyway, rejected after download as "format not supported".
AUDIOBOOK_FORMATS and ARCHIVE_FORMATS now live once in core.utils and
every layer derives from them, which also restored the missing .opus in
the post-download scan's trackable extensions.

Widening the default alone would not have reached anyone already
affected: initialize_default_configs() writes field defaults only when a
tab has no config file yet, so an existing install keeps its persisted
m4b/mp3 list forever. migrate_audiobook_formats rewrites a list that
still matches the old default exactly and leaves every other value
alone — re-enabling formats someone had deliberately turned off would be
worse than leaving them narrow.

The IRC parser filtered by file extension alone. Multi-file audiobooks
ship as a .rar or .zip of MP3s, which matched neither SUPPORTED_FORMATS
nor SUPPORTED_AUDIOBOOK_FORMATS, so they fell out of the ebook bucket
and the audiobook bucket both. Results are now classified before the
format filter is applied: an audio extension means audiobook, an ebook
extension means ebook, and for a container — where the extension says
nothing about the contents — the release name decides. An ebook archive
stays out of audiobook results.

RESULT_LINE_REGEX matched \w+ after any dot, so a line carrying no file
extension parsed as format "5mb" out of "::INFO:: 620.5MB", taking the
title and the size down with it and guaranteeing every downstream filter
dropped it. Any decimal size did this. The extension is now matched
against the known formats, so such a line falls through to the simple
pattern and comes back as "unknown", which the rest of the parser
already handles. ALL_RECOGNIZED_FORMATS became an ordered tuple in the
process: it was a set, so which extension won for a line naming two of
them depended on set iteration order and could vary between restarts.

Refs #1129
2026-08-13 13:51:40 -04:00
CaliBrain 03e219eb43 Let the bypasser solve bot challenges on Anna's Archive search (#1198)
Anna's Archive put a DDoS-Guard JS challenge in front of /search: the
homepage still returns 200, but /search and /md5/<id> answer 403 on
every mirror (.gl, .pk, .gd all confirmed). Search fetched both with
allow_bypasser_fallback=False, which rotates mirrors on a 403 instead of
invoking the bypasser, so it walked the whole mirror list, exhausted it,
and surfaced "Unable to reach download source. Network restricted or
mirrors are blocked." as a 503 on every query. Adding mirrors could not
help — they sit behind the same gate — and neither could USE_CF_BYPASS,
since search never reached that branch.
Fetch search and the detail page with allow_bypasser_fallback=True so a
403 hands over to the bypasser, which already detects this challenge
(DDOS_GUARD_INDICATORS matches the live page). Echoing the __ddg cookies
back does not clear it; it needs real JS execution. The download-count
fetch keeps allow_bypasser_fallback=False: it is decoration on the
details modal and not worth holding the modal open for a browser solve.
Fixes #1196
2026-08-13 13:06:11 -04:00
CaliBrain 29ce83e274 Stop dependabot proposing Python pre-releases, bound the e2e health wait (#1189)
PR #1169 (python:3.14.6-slim -> python:3.15.0b3-slim) ran for 6h before
GitHub's max job limit killed it, then did it again on re-run. Two
independent defects.

Dependabot proposed a beta at all: the config already excluded python
from the docker digest group for dependabot-core#9496, but the comment
claimed ungrouped python updates get their pre-release filtered. They
don't. dependabot-core#13815 rewrote the Docker pre-release heuristic to
catch PEP 440 tags (its tests cover 3.15.0a2 and 3.5.0b3), yet the
suffixed real tag still got through seven months later. CPython spells
pre-releases without a separator, so 3.15.0b3 parses as an ordinary
version sorting above 3.14.6. Ignore python semver-minor/major instead
of trusting the heuristic; patch and digest updates still flow.

The run took hours rather than failing: the health wait looked bounded
at 60 iterations x 2s, but bare `curl` has no timeout. The 3.15 image
booted a container that bound 8084 without ever serving (greenlet has no
3.15 wheel, so the gevent gunicorn worker was wedged), so curl blocked
on
read forever and the loop never reached iteration 2. Every job's orphan
process at cancellation was that curl. Bound each probe and switch to a
wall-clock deadline, and add timeout-minutes so a hang can never reach
6h
again.

Verified against a socket that accepts and never responds: the old loop
was still hung at 30s, the new one exits at 120s with HEALTHY=0 into the
existing log-dump path, and a responsive endpoint is still detected
immediately.
2026-08-11 23:58:37 -04:00
CaliBrain e320b7623d Fix LOG_LEVEL being ignored and Z-Library 503 cookie gate (#1188)
LOG_LEVEL never reached the app logger: env.py hardcoded the level to
DEBUG or INFO, so INFO lines kept appearing under LOG_LEVEL=error. Read
it from the env var and advanced settings, normalize unknown values to
INFO, and expose it as a setting. entrypoint.sh now normalizes
gunicorn's level too, so a typo falls back to info instead of stopping
the container from booting.

Z-Library gates the first hit on /md5/<hash> with a 503 whose only
payload is a Set-Cookie; echoing that cookie back returns the 302 to the
real page. html_get_page dropped it and re-ran the same rejected request
on every retry, ending in "No download URL resolved". Retry once with
the
cookies the 503 issued.

Fixes #1185
Fixes #1187
2026-08-11 23:27:44 -04:00
CaliBrain bb848f05bc fix/bypass stall watchdog (#1186)
- Fix protection bypass cancelled by stall detection at exactly 300s
- make fixes
- Try to fix Synology DELETE issues
2026-08-11 12:23:34 -04:00
CaliBrain cc1a95f965 Fix protection bypass cancelled by stall detection at exactly 300s (#1184)
A download that hits Cloudflare hung on "Bypassing protection..." for
five
minutes and then died, regardless of which bypasser was configured.

html_get_page() started a BypassHeartbeat thread to keep the download
marked
alive during a bypass, but the thread had no loop: it fired one status
event
and returned. Even with the loop restored it could not have worked,
because
update_download_status() dedupes identical (status, message) tuples and
returns before refreshing _last_activity, and the heartbeat re-sent the
byte-identical payload already emitted just above it.

So _last_activity was frozen for the whole bypass, while both bypassers
are
allowed to run longer than STALL_TIMEOUT (external FlareSolverr ~394s at
default settings, internal 420s per get() call). The watchdog always
won.
From a reporter's log: 403 at 07:04:33.390, cancelled at 07:09:33.987 -
exactly 300.000s, and 41s before the bypasser would have finished and
reported the real error, an HTTP 500 from FlareSolverr the user never
saw.

The regression is not one commit. 1f093de (#536) added the heartbeat and
the
dedup together and refreshed activity before the dedup return, so it
worked.
ff094be (#832) moved the refresh below that return while tightening
stall
detection for #823. 3a3a3ce (#845) then deleted the heartbeat's while
loop
to silence a B023 lint, removing the last evidence of intent.

The dedup itself is correct and stays: a keep-alive that ticks on a
timer
proves nothing about whether an operation is progressing, so letting it
refresh the stall clock would make a wedged download immortal. Split the
two
concerns instead.

Add shelfmark/download/activity.py. A long single-shot operation
declares its
own upper bound once, over a sentinel status carried on the existing
status_callback channel - so no new parameter has to be threaded through
every
handler, post-processor and output module. The orchestrator intercepts
the
sentinel in its per-task closure and records an absolute deadline in
_activity_grace, which stall detection honours alongside STALL_TIMEOUT.
The
grace never extends itself and is clamped to
_MAX_ACTIVITY_GRACE_SECONDS, so
an operation that overruns its own declared budget is still cancelled.

Each bypasser now reports max_duration_seconds() derived from its own
retry
and timeout settings, and http.py asks whichever is active, plus 30s of
slack
so the bypasser's own deadline expires first and the user sees its real
failure. On that path html_get_page() also emits
status_callback("error", ...)
rather than silently returning an empty page.

Three further fixes on the same code path:

- Extract the watchdog into _find_stalled_tasks() and
_cancel_stalled_task().
It was the only place holding _progress_lock across a call into
book_queue,
whose terminal-status hooks reach a sqlite write that gevent does not
patch,
blocking the hub and every download worker. It now holds the lock for
dict
  reads only.
- Bound _CDP_WORKER.run(), which waited with timeout=None while holding
the
module-wide LOCKED, so a single wedged in-process CDP session blocked
every
  subsequent bypass forever on non-Docker installs.
- Broaden the coordinator loop's except clause back to Exception, with
  escalating backoff. 8d98e12 (#868) narrowed it to a six-type tuple to
silence BLE001, which let gevent's LoopExit and similar kill the only
thread
driving the download queue - undoing #832's fix for #823 and resurfacing
it
  as #1166. GreenletExit and gevent.Timeout still propagate.

Fixes #1001
Refs #1166, #823
2026-08-11 02:25:28 -04:00
CaliBrain dfcd7c9b00 Fix silent Hardcover search failures on rejected sort values (#1183)
Hardcover forwards the `sort` argument to Typesense's `sort_by` and
rejects
the entire search if it dislikes the value -- an unknown field, a bare
field
name with no direction, or more than three sort keys. A rejected search
is
not a GraphQL error: it comes back as HTTP 200, no `errors` key, and a
null
`results` body.

_extract_typesense_hits() reads that null as `hits=[], found=0`, so a
failed
search was indistinguishable from one that matched nothing. Users saw
zero
results with a healthy container and no log line explaining why.

Add _execute_search_query(), used by the three sort-bearing call sites
(book
search, field typeahead, series resolution):

- Detect the rejection via the null `results` body. A search that
genuinely
matched nothing still returns a results object with `found: 0`, so empty
    result sets are not mistaken for failures.
- Retry once with an empty sort, which Hardcover always accepts, so
searches
    return results instead of nothing.
- Keep that fallback sticky for 15 minutes so every subsequent search
does
not pay for a request known to fail, and let it expire so sort order
comes
    back on its own if the index is fixed upstream.
- Log rejections that no sort can explain, and retries that also fail,
at
    ERROR instead of discarding them.

While the fallback is active, results fall back to Typesense's default
ordering regardless of the selected sort. Degraded ordering beats no
results,
and it is now logged rather than silent.

SORT_MAPPING itself is unchanged: all five of its values were verified
against
the live API and return results. The `sort: "relevance"` reported in
#1179 was
the raw SortOrder value sent by v1.3.5; the mapping already fixed that.
What
remained unfixed, and is fixed here, is that the failure was invisible.

Fixes #1179
2026-08-11 01:35:41 -04:00
CaliBrainandClaude Opus 5 21f2b6b95c Fix torrent post-import category follow-ups and clear the lint backlog (#1154)
rTorrent set_category and remove now uppercase the info hash, which
reaches us lowercase while rTorrent's XML-RPC lookups are case
sensitive. remove() had this bug before #1148, making
PROWLARR_TORRENT_ACTION=remove a silent no-op for rTorrent.

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

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

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

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 00:17:28 -04:00
CaliBrain 10554444a9 Create a Tor watchguard and make iptables non mandatory (#1152)
Fix https://github.com/calibrain/shelfmark/issues/1150
2026-07-30 23:41:55 -04:00
CaliBrain ce026e2eb8 IRC health checks (#1151) 2026-07-30 23:39:12 -04:00
CaliBrain a1367f431d Ship Prowlarr per-entry rows as opt-in, not the default (#1145)
#1140 fixed the guid-only dedup that hid results from filter-specific
indexer entries, but shipped the new behaviour on by default:
PROWLARR_COLLAPSE_DUPLICATES defaulted off, so every existing Prowlarr
user got extra rows for any release that two indexer entries both
returned, and the setting only let them opt back into what they already
had.

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

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

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

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

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

The rest is fallout from the ruff 0.16.0 bump in #1139, which enabled a
much larger default rule set and started formatting Python code blocks
in Markdown: _find_existing_alias_user() uses min() instead of
sorted()[0] (FURB192, currently failing Python Quality on main), and the
two READMEs get their code blocks reformatted.
2026-07-28 01:23:31 -04:00
CaliBrain 7fdaf3f67d Add Audiobook support to IRC (#1136) 2026-07-24 18:34:12 -04:00
Calibrain 1747b3466e Fix linting issues 2026-07-21 19:52:25 +00:00
CaliBrain 81a057c8a0 Try and fix OIDC (#1127) 2026-07-21 15:43:54 -04:00
CaliBrain 6bab9989ab fix: fetch tracker .torrent links once per add and surface fetch failures (#1115)
## Summary

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

Two problems compounded here:

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

## What changed

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

## Validation

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

Fixes #1111
2026-07-14 16:21:31 -04:00
CaliBrain 9b1d4322b7 fix: resolve remaining "Could not determine torrent hash from URL" failures (#1012) (#1108) 2026-07-08 14:20:31 -04:00
CaliBrain 404e8cc5c5 path timeouts (#1103)
- Add configurable completed-path wait for external clients : - Add a
configurable Advanced setting, DOWNLOAD_CLIENT_COMPLETED_PATH_TIMEOUT,
for how long Shelfmark waits after a torrent or usenet client reports
completion before treating the completed path as missing. - Keep the
default at the existing 60-second grace period, with a maximum of 3600
seconds
- Add e2e testing
- Add e2e testing

Should fix #861
2026-07-06 01:51:58 -04:00
CaliBrain 02a45c1a12 fix: pin chromium to 149.0.7827.196 — Debian's 150 breaks the CDP bypasser (#1105) 2026-07-06 01:33:10 -04:00
CaliBrain 36f9a52987 Annotate both the per-platform manifests and the multi-arch image index (#1088)
Fixes #1087
2026-06-24 11:56:57 -04:00
CaliBrain 1536b91a46 Fix e2e scipts (#1086) 2026-06-24 00:09:06 -04:00
CaliBrain fea63269e3 the e2e platform test profiles live in an env/ dir and must be tracked (#1085) 2026-06-23 21:28:25 -04:00
CaliBrain 7886ba7038 fix astral-sh-setup-uv (#1084) 2026-06-23 17:43:59 -04:00
CaliBrain 1c19326bd0 e2e testing 2 (#1083) 2026-06-23 16:58:24 -04:00
CaliBrain 2f70ed36e4 e2e testing (#1082) 2026-06-23 15:38:46 -04:00
CaliBrain 61b3eaf351 Fix internal bypasser using system DNS in subprocess, breaking downloads behind ISP DNS blocks (#1028) (#1080) 2026-06-21 00:42:03 -04:00
CaliBrain e9668371f7 Fix IRC caching (#1072) 2026-06-17 14:25:31 -04:00
CaliBrain d55e42fbc6 Add torrent fix for untrusted URL (#1071) 2026-06-17 13:46:51 -04:00
CaliBrain c64c2d374a Make IRC less spammy and require a bot name for conversatons (#1065)
First step towards fixing the friction created by shelfmark in #997
2026-06-14 01:40:09 -04:00
CaliBrain 75c0b0c33a Update dependabot.yml (#1060) 2026-06-14 01:19:08 -04:00
CaliBrain b914f77748 Fix CI/CD issues (#1052) 2026-06-10 03:17:40 -04: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
CaliBrain e31e9774a3 Update GitHub Actions workflow permissions (#416)
Added permissions for contents and packages.
2026-01-11 18:32:46 -05:00
CaliBrain abf7f24178 Remove .org domain for AA and add alternative domains (#394) 2026-01-05 08:54:10 -05:00
CaliBrain 15a61a5191 Fix tor timeout (#349)
Tentative fix for #340
2025-12-18 15:53:39 -05:00
CaliBrain 85c8c9151d Fix tor timeout (#343)
Fix for #340
2025-12-14 22:18:18 -05:00
CaliBrain b293bee5f4 Always run Gunicorn (even when DEBUG=true) to ensure Socket.IO WebSocket (#341) 2025-12-13 00:21:44 -05:00
CaliBrain 0e2580030b Change APP_ENV from 'prod' to 'dev' default (#331)
Fix for #330
2025-12-08 14:48:33 -05:00
CaliBrain 78c61e88b3 Fix kwargs bug in tracing log (#327) 2025-11-23 01:06:00 -05:00
CaliBrain 57d85d0748 fix format (#326)
- Fix function signature error
2025-11-23 00:10:00 -05:00