Compare commits

...
93 Commits
Author SHA1 Message Date
Alex 019d36b27e Rename Booklore to Grimmory 2 (#792) 2026-03-21 15:48:06 +00:00
Alex 698eb07e71 Rename Booklore to Grimmory (#791) 2026-03-21 15:38:38 +00:00
Alex 8f949a73d5 Remove audible provider (#778) 2026-03-18 18:29:13 +00:00
Alex 2c6f46fc88 Combined mode follow-up (#777) 2026-03-18 18:27:39 +00:00
Alex 3f90c3805f Use title+author query for whitelisted indexers (#774) 2026-03-16 19:52:43 +00:00
Alex cb093f61c6 Feature: Combined book+audiobook downloads (#773)
- Adds a combined search option in the search bar selector
- Choose both a book and audiobook file in a two-step release modal, and
download both simultaneously from a single search result.
- Works for requests. Request both a book+audiobook at once, or works
seamlessly with request policies that differ between book + audiobook
(E.g. automatically download the ebook portion, while the audiobook gets
sent as a request)
- Hidden for users who have book or audiobooks blocked. 

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

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

  ## Summary

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

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

  ## Notes

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

  ## Validation

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

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

  ## Scope

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

Left out Vite / tooling updates

---------

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

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

---------

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

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

## Fix

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

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

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

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

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

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

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

## What's New

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


## How It Works

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

## Testing

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

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

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

## Summary

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

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

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

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

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

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

## Migration

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

## Test Plan

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

---------

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

## Changes

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

## Issues Addressed

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

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

## Testing

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

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

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

But it definitely adds to rtorrent and gives progress.

**rTorrent client integration:**

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

**Settings and configuration:**

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

**Test environment and scripts:**

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

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

---------

Co-authored-by: Joshua Tag Howard <git@jthoward.dev>
Co-authored-by: Alex <alex.bilbie1@gmail.com>
2026-01-15 13:27:50 +00:00
418 changed files with 78067 additions and 10526 deletions
+1
View File
@@ -1,6 +1,7 @@
.git
.github
.vscode
.local
.mypy_cache
README_images
.gitignore
+29
View File
@@ -0,0 +1,29 @@
version: 2
updates:
# Python dependencies
- package-ecosystem: "pip"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 10
# Frontend npm dependencies
- package-ecosystem: "npm"
directory: "/src/frontend"
schedule:
interval: "weekly"
open-pull-requests-limit: 10
# Dockerfile base images
- package-ecosystem: "docker"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 5
# GitHub Actions
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 5
@@ -6,6 +6,8 @@ on:
tags:
- 'v*'
workflow_dispatch:
permissions: read-all
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository_owner }}/shelfmark
@@ -29,18 +31,18 @@ jobs:
run: echo "date=$(date +'%Y-%m-%d')" >> $GITHUB_OUTPUT
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Log in to the Container registry
uses: docker/login-action@v3
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata for ${{ matrix.target }} image
id: meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}${{ matrix.image_name_suffix }}
tags: |
@@ -50,13 +52,13 @@ jobs:
type=semver,pattern={{major}}.{{minor}}
type=sha
type=ref,event=tag
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
- name: Build and push ${{ matrix.target }} Docker image
id: push
uses: docker/build-push-action@v5
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
with:
platforms: linux/amd64,linux/arm64
context: .
@@ -67,10 +69,10 @@ jobs:
RELEASE_VERSION=${{ github.ref_name }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
- name: Generate artifact attestation for ${{ matrix.target }} image
if: github.event_name != 'pull_request'
uses: actions/attest-build-provenance@v2
uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0
with:
subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}${{ matrix.image_name_suffix }}
subject-digest: ${{ steps.push.outputs.digest }}
@@ -89,14 +91,14 @@ jobs:
LEGACY_NAME: calibre-web-automated-book-downloader
steps:
- name: Log in to registry
uses: docker/login-action@v3
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
- name: Create legacy aliases
run: |
+55
View File
@@ -0,0 +1,55 @@
name: CI
on:
pull_request:
branches: [main]
permissions:
contents: read
jobs:
backend-tests:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: "3.10"
cache: "pip"
- name: Install dependencies
run: |
pip install -r requirements-base.txt
pip install -r requirements-shelfmark.txt
pip install pytest
- name: Run tests
run: pytest tests/ -x --tb=short
frontend-checks:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: 20
cache: "npm"
cache-dependency-path: src/frontend/package-lock.json
- name: Install dependencies
working-directory: src/frontend
run: npm ci
- name: Typecheck
working-directory: src/frontend
run: npm run typecheck
- name: Unit tests
working-directory: src/frontend
run: npm run test:unit
+38
View File
@@ -0,0 +1,38 @@
name: CodeQL
on:
push:
branches: [main]
pull_request:
branches: [main]
schedule:
- cron: "0 6 * * 1" # Weekly on Monday at 6am UTC
permissions:
contents: read
jobs:
analyze:
runs-on: ubuntu-latest
permissions:
security-events: write
strategy:
fail-fast: false
matrix:
language: [python, javascript-typescript]
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Initialize CodeQL
uses: github/codeql-action/init@820e3160e279568db735cee8ed8f8e77a6da7818 # v3
with:
languages: ${{ matrix.language }}
- name: Autobuild
uses: github/codeql-action/autobuild@820e3160e279568db735cee8ed8f8e77a6da7818 # v3
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@820e3160e279568db735cee8ed8f8e77a6da7818 # v3
with:
category: "/language:${{ matrix.language }}"
+3
View File
@@ -229,5 +229,8 @@ pyrightconfig.json
/downloaded_files
/.local/
*.local.*
AGENTS.md
.claude/
.playwright-mcp/
frontend-dist/
node_modules/
+7 -8
View File
@@ -25,7 +25,7 @@ COPY src/frontend/ ./
RUN npm run build
# Use python-slim as the base image
FROM python:3.10-slim AS base
FROM python:3.14-slim AS base
# Add build argument for version
ARG BUILD_VERSION
@@ -68,7 +68,7 @@ RUN apt-get update && \
# For debug
zip iputils-ping \
# For user switching
sudo \
gosu \
# --- Tor support (activated via USING_TOR=true) ---
tor \
supervisor \
@@ -130,10 +130,11 @@ RUN apt-get update && \
xvfb \
# For screen recording
ffmpeg \
# --- Chromium ---
# --- Chromium (unpinned - uses latest from Debian repos) ---
# Chrome 144+ requires --enable-unsafe-swiftshader for WebGL in Docker.
# This flag is set in internal_bypasser.py _get_browser_args()
chromium \
# --- ChromeDriver ---
chromium-driver \
chromium-common \
# For tkinter (pyautogui)
python3-tk \
# For RAR extraction
@@ -150,9 +151,7 @@ RUN --mount=type=cache,target=/root/.cache/pip \
pip install -r requirements-shelfmark.txt
# Grant read/execute permissions to others
RUN chmod -R o+rx /usr/bin/chromium && \
chmod -R o+rx /usr/bin/chromedriver && \
chmod -R o+w /usr/local/lib/python3.10/site-packages/seleniumbase/drivers/
RUN chmod -R o+rx /usr/bin/chromium
# Default command to run the application entrypoint script
CMD ["/app/entrypoint.sh"]
+15 -1
View File
@@ -1,4 +1,4 @@
.PHONY: help install dev build preview typecheck clean up down docker-build refresh restart
.PHONY: help install dev build preview typecheck frontend-test clean up down docker-build refresh restart build-serve
# Frontend directory
FRONTEND_DIR := src/frontend
@@ -14,8 +14,10 @@ help:
@echo " install - Install frontend dependencies"
@echo " dev - Start development server"
@echo " build - Build frontend for production"
@echo " build-serve - Build and serve via Flask (test prod build without Docker)"
@echo " preview - Preview production build"
@echo " typecheck - Run TypeScript type checking"
@echo " frontend-test - Run frontend unit tests"
@echo " clean - Remove node_modules and build artifacts"
@echo ""
@echo "Backend (Docker):"
@@ -40,6 +42,13 @@ build:
@echo "Building frontend for production..."
cd $(FRONTEND_DIR) && npm run build
# Build frontend and sync to frontend-dist for the running container to serve
build-serve: build
@echo "Syncing build to frontend-dist..."
@mkdir -p frontend-dist
rsync -a --delete $(FRONTEND_DIR)/dist/ frontend-dist/
@echo "Done. Hit the Flask backend (port 8084) to test the production build."
# Preview production build
preview:
@echo "Previewing production build..."
@@ -50,6 +59,11 @@ typecheck:
@echo "Running TypeScript type checking..."
cd $(FRONTEND_DIR) && npm run typecheck
# Run frontend unit tests
frontend-test:
@echo "Running frontend unit tests..."
cd $(FRONTEND_DIR) && npm run test:unit
# Clean build artifacts and dependencies
clean:
@echo "Cleaning build artifacts and dependencies..."
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 MiB

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 151 KiB

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 854 KiB

After

Width:  |  Height:  |  Size: 848 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 MiB

After

Width:  |  Height:  |  Size: 2.1 MiB

@@ -1,16 +1,16 @@
services:
shelfmark-lite:
image: ghcr.io/calibrain/shelfmark-lite:latest
container_name: shelfmark-lite
environment:
# TZ: America/New_York
# EXT_BYPASSER_URL: http://flaresolverr:8191 #If using Flaresolverr
# PUID: 1000
# PGID: 1000
PUID: 1000
PGID: 1000
ports:
- 8084:8084
restart: unless-stopped
volumes:
- /path/to/books:/books # Book destination directory
- /path/to/books:/books # Default destination for book downloads
- /path/to/config:/config # App configuration
# Download client mount - path must match your torrent/usenet client's volume exactly
# - /path/to/downloads:/path/to/downloads
# Required for torrent / usenet - path must match your download client's volume exactly
# - /path/to/downloads:/path/to/downloads
@@ -4,10 +4,9 @@ services:
image: ghcr.io/calibrain/shelfmark:latest
environment:
FLASK_PORT: 8084
# TZ: America/New_York
USING_TOR: true
# PUID: 1000
# PGID: 1000
PUID: 1000
PGID: 1000
cap_add:
- NET_ADMIN
- NET_RAW
@@ -15,7 +14,7 @@ services:
- 8084:8084
restart: unless-stopped
volumes:
- /path/to/books:/books # Book destination directory
- /path/to/books:/books # Default destination for book downloads
- /path/to/config:/config # App configuration
# Download client mount - path must match your torrent/usenet client's volume exactly
# Required for torrent / usenet - path must match your download client's volume exactly
# - /path/to/downloads:/path/to/downloads
@@ -3,14 +3,13 @@ services:
image: ghcr.io/calibrain/shelfmark:latest
container_name: shelfmark
environment:
# TZ: America/New_York
# PUID: 1000
# PGID: 1000
PUID: 1000
PGID: 1000
ports:
- 8084:8084
restart: unless-stopped
volumes:
- /path/to/books:/books # Book destination directory
- /path/to/books:/books # Default destination for book downloads
- /path/to/config:/config # App configuration
# Download client mount - path must match your torrent/usenet client's volume exactly
# Required for torrent / usenet - path must match your download client's volume exactly
# - /path/to/downloads:/path/to/downloads
-20
View File
@@ -1,20 +0,0 @@
# Uses external Cloudflare bypasser (FlareSolverr/ByParr) instead of built-in Selenium
services:
shelfmark-lite:
image: ghcr.io/calibrain/shelfmark-lite:dev
environment:
# TZ: America/New_York
EXT_BYPASSER_URL: http://flaresolverr:8191
# PUID: 1000
# PGID: 1000
ports:
- 8084:8084
restart: unless-stopped
volumes:
- /path/to/books:/books # Book destination directory
- /path/to/config:/config # App configuration
# Download client mount - path must match your torrent/usenet client's volume exactly
# - /path/to/downloads:/path/to/downloads
flaresolverr:
image: ghcr.io/flaresolverr/flaresolverr:latest
-21
View File
@@ -1,21 +0,0 @@
# Routes all traffic through Tor - requires NET_ADMIN capability
services:
shelfmark-tor:
image: ghcr.io/calibrain/shelfmark:dev
environment:
FLASK_PORT: 8084
# TZ: America/New_York
USING_TOR: true
# PUID: 1000
# PGID: 1000
cap_add:
- NET_ADMIN
- NET_RAW
ports:
- 8084:8084
restart: unless-stopped
volumes:
- /path/to/books:/books # Book destination directory
- /path/to/config:/config # App configuration
# Download client mount - path must match your torrent/usenet client's volume exactly
# - /path/to/downloads:/path/to/downloads
-16
View File
@@ -1,16 +0,0 @@
services:
shelfmark:
image: ghcr.io/calibrain/shelfmark:dev
container_name: shelfmark
environment:
# TZ: America/New_York
# PUID: 1000
# PGID: 1000
ports:
- 8084:8084
restart: unless-stopped
volumes:
- /path/to/books:/books # Book destination directory
- /path/to/config:/config # App configuration
# Download client mount - path must match your torrent/usenet client's volume exactly
# - /path/to/downloads:/path/to/downloads
+2 -2
View File
@@ -2,7 +2,7 @@
services:
shelfmark-lite-dev:
extends:
file: ./compose/edge/docker-compose.extbp.yml
file: ./compose/docker-compose.lite.yml
service: shelfmark-lite
build:
context: .
@@ -18,7 +18,7 @@ services:
- ./.local/books:/books
- ./.local/log:/var/log/shelfmark
- ./.local/tmp:/tmp/shelfmark
# Download client mount - path must match your torrent/usenet client's volume exactly
# Required for torrent / usenet - path must match your download client's volume exactly
# - /path/to/downloads:/path/to/downloads
flaresolverr:
+2 -2
View File
@@ -2,7 +2,7 @@
services:
shelfmark-tor-dev:
extends:
file: ./compose/edge/docker-compose.tor.yml
file: ./compose/docker-compose.tor.yml
service: shelfmark-tor
build:
context: .
@@ -16,5 +16,5 @@ services:
- ./.local/books:/books
- ./.local/log:/var/log/shelfmark
- ./.local/tmp:/tmp/shelfmark
# Download client mount - path must match your torrent/usenet client's volume exactly
# Required for torrent / usenet - path must match your download client's volume exactly
# - /path/to/downloads:/path/to/downloads
+5 -2
View File
@@ -2,7 +2,7 @@
services:
shelfmark-dev:
extends:
file: ./compose/edge/docker-compose.yml
file: ./compose/docker-compose.yml
service: shelfmark
build:
context: .
@@ -12,11 +12,14 @@ services:
- SYS_PTRACE
environment:
DEBUG: true
# HIDE_LOCAL_AUTH: true
OIDC_AUTO_REDIRECT: true
volumes:
- ./.local/config:/config
- ./.local/books:/books
- ./.local/log:/var/log/shelfmark
- ./.local/tmp:/tmp/shelfmark
- ./shelfmark:/app/shelfmark:ro
# Download client mount - path must match your torrent/usenet client's volume exactly
- ./frontend-dist:/app/frontend-dist:ro
# Required for torrent / usenet - path must match your download client's volume exactly
# - /path/to/downloads:/path/to/downloads
+23 -3
View File
@@ -11,12 +11,12 @@
# - Prowlarr: http://localhost:9696 (no auth by default)
# - qBittorrent: http://localhost:8080 (check container logs for temp password)
# - Transmission: http://localhost:9091 (admin / admin)
# - Deluge: http://localhost:8112 (admin / deluge)
# - Deluge: http://localhost:8112 (password: deluge)
# - NZBGet: http://localhost:6789 (nzbget / tegbzn6789)
# - SABnzbd: http://localhost:8085 (complete setup wizard for API key)
# - rTorrent: http://localhost:8000 (admin / admin - if auth enabled)
#
services:
shelfmark:
build:
@@ -33,9 +33,10 @@ services:
# Use Docker service names for URLs:
# - qBittorrent: http://qbittorrent:8080
# - Transmission: http://transmission:9091
# - Deluge host: deluge (port 58846)
# - Deluge Web UI: http://deluge:8112
# - NZBGet: http://nzbget:6789
# - SABnzbd: http://sabnzbd:8080
# - rTorrent: http://rtorrent:80 (XMLRPC via HTTP) or rtorrent (port 5000 for SCGI)
ports:
- "8084:8084"
volumes:
@@ -62,6 +63,7 @@ services:
- qbittorrent
- transmission
- deluge
- rtorrent
restart: unless-stopped
prowlarr:
@@ -159,3 +161,21 @@ services:
- "6881:6881"
- "6881:6881/udp"
restart: unless-stopped
rtorrent:
image: crazymax/rtorrent-rutorrent:latest # linuxserver has deprecated their rtorrent image
container_name: test-rtorrent
environment:
- PUID=1000
- PGID=1000
- TZ=UTC
volumes:
- ./.local/test-clients/rtorrent/config:/config
- ./.local/test-clients/downloads:/downloads
ports:
- "8000:8000" # XMLRPC
- "8089:8080" # ruTorrent Web UI
- "9000:9000" # SCGI port
- "50000:50000" # Incoming connections
- "6881:6881/udp"
restart: unless-stopped
+125
View File
@@ -0,0 +1,125 @@
# Directory and Volume Setup
This guide explains how to configure directories and Docker volumes for Shelfmark. It focuses on the difference between the destination folder and your download client paths, and how to make those paths line up inside containers.
## Conceptual Overview
```
DIRECT DOWNLOADS
Shelfmark downloads directly -> destination
TORRENT / USENET
Prowlarr -> Download client saves to <client path>
-> Shelfmark reads from <client path>
-> Shelfmark processes to destination
```
Key point: For torrent and usenet downloads, Shelfmark must see the same file path that your download client reports. The container path must match in both containers.
## Direct Download Setup
Direct downloads do not use an external download client. A simple two-folder setup is enough.
Required volumes:
| Container path | Purpose | Notes |
| --- | --- | --- |
| `/config` | Settings, database, cover cache | Configurable via `CONFIG_DIR` |
| `/books` | Destination folder for completed files | Configurable via `INGEST_DIR` and Settings -> Downloads -> Destination |
Example `docker-compose`:
```yaml
services:
shelfmark:
image: ghcr.io/calibrain/shelfmark:latest
volumes:
- /path/to/config:/config
- /path/to/books:/books
```
Notes:
- Point `/books` to your library ingest folder (Calibre-Web, Booklore, Audiobookshelf, etc) for automatic import.
- If you set Books Output Mode to Booklore (API), books are uploaded via API instead of written to `/books`. Audiobooks still use a destination folder.
- Ensure `PUID`/`PGID` (or legacy `UID`/`GID`) match the owner of the host directories to avoid permission errors.
## Torrent / Usenet Setup
For torrents and usenet, your download client reports a path (for example `/data/torrents/books/MyBook.epub`). Shelfmark must be able to read that exact path inside its own container.
Required volumes:
| Container path | Purpose | Notes |
| --- | --- | --- |
| `/config` | Settings, database, cover cache | Configurable via `CONFIG_DIR` |
| `/books` | Destination folder for processed files | Configurable via `INGEST_DIR` |
| `<client path>` | Download client path | Must match the download client container path exactly |
Side-by-side example with qBittorrent:
```yaml
services:
shelfmark:
volumes:
- /path/to/config:/config
- /path/to/books:/books
- /path/to/downloads:/data/torrents # Must match client
qbittorrent:
volumes:
- /path/to/downloads:/data/torrents # Same container path
```
Host paths can be anything. The container path (for example `/data/torrents`) must be identical in both containers.
### Remote Path Mappings
If paths cannot match (different machines or a fixed setup), use Remote Path Mappings.
Where to configure:
- Settings -> Advanced -> Remote Path Mappings
Example:
- Client reports `/data/torrents/books/...`
- Shelfmark can see the same files at `/downloads/books/...`
- Add a mapping from Remote Path `/data/torrents` to Local Path `/downloads`
## File Processing Options
### Transfer Method (Torrent / Usenet Only)
Available methods:
- Copy (default). Works everywhere.
- Hardlink. Preserves seeding without duplicating files.
Hardlink requirements and behavior:
- Source and destination must be on the same filesystem.
- If hardlinking is enabled but not possible, Shelfmark falls back to copying.
- Archive extraction is disabled while hardlinking is enabled.
- Do not use hardlinking if your destination is a library ingest folder.
### File Organization
Shelfmark supports three organization modes for the destination:
- None. Keep original filenames from the source.
- Rename Only. Rename files using a template.
- Rename and Organize. Create folders and rename using templates. Do not use with ingest folders.
Configure templates in Settings -> Downloads. Template syntax details are documented separately.
## Common Mistakes
- "Download failed - file not found": Path mismatch between Shelfmark and the download client. Ensure container paths match or use Remote Path Mappings.
- "Permission denied": `PUID`/`PGID` do not match the host directories. Ensure Shelfmark can read the client path and write to the destination.
- "Hardlinks not working" or "Files being copied instead": Source and destination are on different filesystems. Move the destination or accept copy fallback.
- "Downloads work but library does not see them": Destination does not point to the library ingest folder. Check Settings -> Downloads -> Destination.
- CIFS/SMB shares: Use the `nobrl` mount option to avoid database lock errors. Example: `//server/share /mnt/share cifs nobrl,... 0 0`
## Related Documentation
- Environment Variables Reference: `docs/environment-variables.md`
- Custom Scripts: `docs/custom-scripts.md`
- Installation: `docs/installation.md`
- Troubleshooting: `docs/troubleshooting.md`
+184
View File
@@ -0,0 +1,184 @@
# Custom Scripts
Shelfmark can run an executable you provide after a download task completes successfully. The script runs after the selected output has finished (for example: transfer to the folder destination, or upload to Booklore).
## Quick Start (Recommended)
1. Put your script on the machine that runs Shelfmark.
1. Make it executable.
1. Set it in Shelfmark (Settings -> Advanced -> Custom Script Path).
Example:
```bash
chmod +x /path/to/your/scripts/post_process.sh
```
### Docker Users
If you run Shelfmark in Docker, the script must exist inside the container. The easiest way is to mount a folder of scripts, then point Shelfmark at the container path in the UI.
```yaml
services:
shelfmark:
image: ghcr.io/calibrain/shelfmark:latest
volumes:
- /path/to/your/scripts:/scripts:ro
```
Then set:
- Settings -> Advanced -> Custom Script Path: `/scripts/post_process.sh`
<details>
<summary>Docker Compose: Configure Via Environment Variables (Optional)</summary>
```yaml
services:
shelfmark:
environment:
- CUSTOM_SCRIPT=/scripts/post_process.sh
- CUSTOM_SCRIPT_PATH_MODE=absolute
- CUSTOM_SCRIPT_JSON_PAYLOAD=true
```
</details>
## Script Behaviour
When enabled, Shelfmark runs your script once per successful task:
```bash
<custom_script_path> "<target_path>"
```
- `$1` is always set to the target path.
- If **Custom Script JSON Payload** is enabled, Shelfmark writes a JSON document to stdin (UTF-8).
- If JSON payload is disabled, stdin is empty (EOF).
- Timeout: 300 seconds (5 minutes)
- Exit code: `0` = success; anything else = the task is marked as **Error**
- Concurrency: downloads can run in parallel, so your script may be invoked concurrently for different tasks.
- Runtime: the script runs inside the Shelfmark container (if you use Docker) under the same user as Shelfmark.
## The Target Path (`$1`)
Shelfmark chooses a "best single path" for the task:
- If the output produced exactly one local file: that file path.
- If the output produced multiple local files: a directory path (the common parent directory of those files).
What the target path refers to depends on the output mode:
- Folder output (`output.mode=folder`, `phase=post_transfer`): the final imported file or folder inside your destination.
- Booklore output (`output.mode=booklore`, `phase=post_upload`): the local file or folder that was uploaded (the destination is remote).
By default, `$1` is an absolute path inside the Shelfmark container (or on your host, if you are not using Docker).
## JSON Payload (stdin)
Configure in: Settings -> Advanced -> Custom Script JSON Payload
When enabled, Shelfmark sends a versioned JSON payload to your script via stdin (and still passes `$1`). This is the recommended way to write robust scripts, especially for multi-file imports (audiobooks) and output-specific context (like Booklore).
- The JSON payload always includes absolute paths in `paths.*`, even if you set Custom Script Path Mode to `relative` for `$1`.
- `output.mode` tells you which output ran.
- `output.details` is output-specific. For Booklore output, `output.details.booklore` includes connection details such as `base_url`, `library_id`, and `path_id`.
- `phase` indicates when the script is running. Current values: `post_transfer` (folder output), `post_upload` (Booklore output).
- `transfer` is only included for outputs that do a local transfer (for example the folder output).
If JSON payload is disabled, stdin is empty (EOF). Don't `cat` stdin unless you've enabled the payload.
Example payload shape:
```json
{
"version": 1,
"phase": "post_transfer",
"task": {
"task_id": "abc123",
"source": "direct",
"title": "Foundation",
"author": "Isaac Asimov"
},
"output": {
"mode": "folder",
"organization_mode": "organize"
},
"paths": {
"destination": "/data/library/books",
"target": "/data/library/books/Isaac Asimov/Foundation/Foundation.epub",
"final_paths": [
"/data/library/books/Isaac Asimov/Foundation/Foundation.epub"
]
},
"transfer": {
"op_counts": {"copy": 1, "move": 0, "hardlink": 0},
"use_hardlink": false,
"is_torrent": false,
"preserve_source": false
}
}
```
Example (bash + jq) (JSON payload must be enabled):
```bash
payload="$(cat)"
mode="$(echo "$payload" | jq -r '.output.mode')"
title="$(echo "$payload" | jq -r '.task.title')"
final_paths="$(echo "$payload" | jq -r '.paths.final_paths[]')"
echo "mode=$mode title=$title" >&2
echo "$final_paths" >&2
```
Example (Python) (works whether JSON payload is enabled or not):
```python
#!/usr/bin/env python3
import json
import sys
target = sys.argv[1]
raw = sys.stdin.read()
payload = json.loads(raw) if raw.strip() else None
print(f"target={target}", file=sys.stderr)
if payload:
print(f"mode={payload['output']['mode']} phase={payload['phase']}", file=sys.stderr)
```
<details>
<summary>Advanced Options</summary>
### Absolute vs Relative Target Paths
Configure in: Settings -> Advanced -> Custom Script Path Mode
This setting controls what gets passed as `$1`:
- `absolute` (default): pass an absolute path.
- `relative`: pass a path relative to the output's "destination root", and run the script with `$PWD` set to that root.
For folder output, the destination root is your configured destination folder. For Booklore output, it's the local upload folder.
Example (folder destination is `/data/library/books`, and the imported file ended up in `Isaac Asimov/Foundation/Foundation.epub`):
```bash
# Absolute mode:
$PWD is unchanged
$1 = /data/library/books/Isaac Asimov/Foundation/Foundation.epub
# Relative mode:
$PWD = /data/library/books
$1 = Isaac Asimov/Foundation/Foundation.epub
```
Note: if the target is the destination folder itself, `relative` mode may pass `.`.
</details>
## Notes And Caveats
- **Hardlinks and torrents:** if you use hardlinking to keep seeding, avoid scripts that modify file contents, since hardlinked files share data with the seeding copy.
- **Booklore output mode:** scripts run after upload. `$1` will point at the local uploaded file (or staging folder).
+3
View File
@@ -0,0 +1,3 @@
# Developer Documentation
TODO
@@ -190,6 +190,31 @@ HeadingField(
)
```
### CustomComponentField
Render a frontend-registered custom settings component while still using the
decorator-based schema.
```python
from shelfmark.core.settings_registry import CustomComponentField
CustomComponentField(
key="request_policy_editor",
component="request_policy_grid", # frontend registry key
label="Request Policy Rules",
description="Custom editor for policy defaults and matrix rules.",
value_fields=[
SelectField(key="REQUEST_POLICY_DEFAULT_EBOOK", label="Default Ebook Mode", default="download"),
SelectField(key="REQUEST_POLICY_DEFAULT_AUDIOBOOK", label="Default Audiobook Mode", default="download"),
TableField(key="REQUEST_POLICY_RULES", label="Rules", columns=_rule_columns, default=[]),
],
wrap_in_field_wrapper=True, # use standard FieldWrapper label/description layout
)
```
When `value_fields` is provided, those backing fields are included in
serialization/save/validation automatically and are hidden from the default renderer.
## Common Field Properties
All field types support these common properties:
@@ -206,6 +231,7 @@ All field types support these common properties:
| `requires_restart` | `bool` | `False` | Whether changes require container restart |
| `show_when` | `dict` | `None` | Conditional visibility (see below) |
| `disabled_when` | `dict` | `None` | Conditional disable (see below) |
| `hidden_in_ui` | `bool` | `False` | Hide from default renderer but keep in schema/save path |
## Conditional Visibility
File diff suppressed because it is too large Load Diff
+3
View File
@@ -0,0 +1,3 @@
# Shelfmark Documentation
TODO
+3
View File
@@ -0,0 +1,3 @@
# Installation
TODO
+50
View File
@@ -0,0 +1,50 @@
# OpenID Connect (OIDC) Authentication
## Callback URL
```
https://<your-shelfmark-domain>/api/auth/oidc/callback
```
With a subpath (`URL_BASE=/shelfmark/`):
```
https://<your-shelfmark-domain>/shelfmark/api/auth/oidc/callback
```
The callback URL is constructed from the incoming request, so your reverse proxy must forward `X-Forwarded-Proto` and `X-Forwarded-Host` correctly. PKCE (S256) is used automatically.
## Settings
Configure in **Settings → Security → Authentication Method → OIDC**.
| Setting | Description | Default |
|---------|-------------|---------|
| Discovery URL | `/.well-known/openid-configuration` endpoint | — |
| Client ID | OAuth2 client ID | — |
| Client Secret | OAuth2 client secret | — |
| Scopes | Scopes to request. The group claim is added automatically when admin group authorization is enabled | `openid email profile` |
| Group Claim Name | Claim containing user groups | `groups` |
| Admin Group Name | Group granted admin access. Leave empty for database-only roles | — |
| Use Admin Group for Authorization | Toggle group-based admin detection | `true` |
| Auto-Provision Users | Create accounts on first login | `true` |
| Login Button Label | Custom text for the sign-in button | — |
Use **Test Connection** to verify discovery and client configuration before attempting login.
## Environment Variables
These optional environment variables control login page behavior when OIDC is enabled.
| Variable | Description | Default |
|----------|-------------|---------|
| `HIDE_LOCAL_AUTH` | Hide the username/password login option, so only the OIDC button is shown | `false` |
| `OIDC_AUTO_REDIRECT` | Automatically redirect to the OIDC provider instead of showing the login page | `false` |
If both are enabled, users are redirected straight to the OIDC provider. On failure they return to the login page with an error message but no password fallback.
## Troubleshooting
- **Issuer validation failed** — The issuer in the token doesn't match the discovery document. Check your provider's external URL / issuer configuration.
- **Callback URL mismatch** — Reverse proxy isn't forwarding `X-Forwarded-Proto` or `X-Forwarded-Host`, so the constructed callback URL doesn't match what's registered in the provider.
- **Account not found** — Auto-provision is disabled and the user hasn't been pre-created by an admin.
+168
View File
@@ -0,0 +1,168 @@
# Reverse Proxy & Subpath Hosting
Shelfmark can run behind a reverse proxy at the root path (recommended) or under a subpath like `/shelfmark`.
## Root path setup (Recommended)
If you can serve Shelfmark at the root path (`https://shelfmark.example.com/`), leave `URL_BASE` empty. This is the simplest option and avoids extra subpath configuration.
Define this once in your Nginx `http` block so websocket upgrades are only sent when the client actually requests them:
```nginx
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
```
```nginx
server {
listen 443 ssl;
server_name shelfmark.example.com;
location / {
proxy_pass http://shelfmark:8084;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
}
}
```
## Subpath setup
Running Shelfmark under a subpath like `/shelfmark` is supported without extra rewrite rules.
### 1. Set the base path in Shelfmark
- **UI**: Settings → Advanced → Base Path → `/shelfmark/`
- **Environment variable**: `URL_BASE=/shelfmark/`
### 2. Configure your reverse proxy
All Shelfmark paths (UI, API, assets, Socket.IO) are served under the base path. A single location block is enough.
---
### Without Authentication Proxy
**Complete Nginx configuration for subpath deployment:**
```nginx
location /shelfmark/ {
proxy_pass http://shelfmark:8084/shelfmark/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_read_timeout 86400;
proxy_send_timeout 86400;
proxy_buffering off;
}
```
---
### With Authentication Proxy (Authelia, Authentik, etc.)
Shelfmark supports Proxy Authentication. When enabled, Shelfmark trusts the authenticated user from headers set by your auth proxy.
#### Shelfmark Settings
Configure in Settings → Security:
| Setting | Value |
|---------|-------|
| Authentication Method | Proxy Authentication |
| Proxy Auth User Header | `Remote-User` |
| Proxy Auth Logout URL | `https://auth.example.com/logout` |
| Proxy Auth Admin Group Header | `Remote-Groups` |
| Proxy Auth Admin Group Name | `admins` (or your admin group) |
#### Nginx Configuration with Authelia
This example uses Authelia snippets. Adapt for your auth proxy.
**Authelia auth request snippet** (`/etc/nginx/snippets/authelia-authrequest.conf`):
```nginx
location /authelia {
internal;
proxy_pass http://authelia:9091/api/authz/auth-request;
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_set_header Host $host;
proxy_set_header X-Original-URL $scheme://$http_host$request_uri;
proxy_set_header X-Original-Method $request_method;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
```
**Authelia location snippet** (`/etc/nginx/snippets/authelia-location.conf`):
```nginx
auth_request /authelia;
auth_request_set $target_url $scheme://$http_host$request_uri;
auth_request_set $user $upstream_http_remote_user;
auth_request_set $groups $upstream_http_remote_groups;
auth_request_set $name $upstream_http_remote_name;
auth_request_set $email $upstream_http_remote_email;
proxy_set_header Remote-User $user;
proxy_set_header Remote-Groups $groups;
proxy_set_header Remote-Name $name;
proxy_set_header Remote-Email $email;
error_page 401 =302 https://auth.example.com/?rd=$target_url;
```
**Complete Nginx configuration with Authelia:**
```nginx
# Include Authelia auth endpoint in your server block
include /etc/nginx/snippets/authelia-authrequest.conf;
# Main shelfmark location
location /shelfmark/ {
include /etc/nginx/snippets/authelia-location.conf;
proxy_pass http://shelfmark:8084/shelfmark/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_read_timeout 86400;
proxy_send_timeout 86400;
proxy_buffering off;
}
```
---
## Troubleshooting false network errors
If login, settings saves, or downloads appear to fail in the browser but the action still completes on the server, check your proxy headers first.
- Do not force `Connection: upgrade` on every request. That can break normal `POST` and `PUT` responses while the backend still processes them.
- If your proxy UI does not support conditional websocket headers, remove the forced websocket headers entirely and let Shelfmark fall back to polling.
- Keep the standard forwarded headers: `Host`, `X-Forwarded-For`, `X-Forwarded-Proto`, and `X-Forwarded-Host` when using a subpath or OIDC.
This is especially relevant for Nginx Proxy Manager or custom advanced config snippets that add websocket headers globally.
---
## Health checks
Health checks work at `/shelfmark/api/health` when using a subpath configuration.
+3
View File
@@ -0,0 +1,3 @@
# Troubleshooting
TODO
+8 -1
View File
@@ -19,6 +19,7 @@ http://your-server:8084/?q=harry+potter
| `lang` | Filter by language (ISO 639-1 code) | `/?lang=en` |
| `format` | Filter by file format | `/?format=epub` |
| `content` | Filter by content type | `/?content=fiction` |
| `content_type` | Select media type (`ebook` or `audiobook`) in Universal mode only | `/?q=dune&content_type=audiobook` |
| `sort` | Sort order for results | `/?sort=newest` |
## Multiple Values
@@ -57,15 +58,21 @@ Some parameters support multiple values by repeating the parameter:
/?q=science+fiction&sort=newest
```
**Universal search as audiobook:**
```
/?q=dune&content_type=audiobook
```
## Search Mode Behavior
### Direct Download Mode (default)
All parameters are used to filter results from the direct download source.
`content_type` is ignored in Direct mode.
### Universal Mode
Only `q` and `sort` are used. Other parameters (author, title, format, etc.) are silently ignored since metadata providers have their own search capabilities.
`q`, `sort`, and `content_type` are used. Other parameters (author, title, format, etc.) are silently ignored since metadata providers have their own search capabilities.
## Notes
+98
View File
@@ -0,0 +1,98 @@
# Users & Requests
Configure in **Settings → Users & Requests**.
## Authentication Methods
Shelfmark supports four authentication methods, configured in **Settings → Security**.
### Local
You create user accounts directly in Shelfmark with a username and password. At least one local admin account must exist before this mode can be enabled.
### Proxy Authentication
Your reverse proxy handles authentication and passes the username to Shelfmark via a header (e.g. `Remote-User`). Accounts are created automatically on first sign-in. If a local user with the same username already exists, the proxy identity will be linked to that account rather than creating a duplicate. Admin status can optionally be derived from a groups header.
### OIDC (OpenID Connect)
Users sign in through your identity provider. Accounts are created automatically on first login (unless auto-provisioning is disabled, in which case you need to pre-create them). If a local user with a matching verified email already exists, the OIDC identity will be linked to that account on first sign-in. Admin status can optionally be derived from a group claim.
A local admin account is required as a fallback. See [OIDC](oidc.md) for provider setup.
### Calibre-Web Database
User accounts are synced from your Calibre-Web `app.db`. If a local user with a matching email already exists, the CWA identity will be linked to that account. Roles are kept in sync with CWA. Users removed from CWA are cleaned up on the next sync.
Requires mounting your Calibre-Web `app.db` to `/auth/app.db`.
## Per-User Settings
Admins can configure per-user settings by editing a user in the user management panel. Non-admin users can also edit their own settings through **My Account** (accessible from the user menu). Admins control which sections are visible in My Account via the **Visible Self-Settings Sections** option.
There are three categories of per-user settings:
### Delivery Preferences
Override where a user's downloads are sent. Options depend on the global output mode configuration:
- **Output mode** — Folder, Email (SMTP), or BookLore (API)
- **Destination** — A custom folder path for this user's ebook downloads
- **Audiobook destination** — A custom folder path for audiobook downloads
- **BookLore library/path** — Per-user BookLore target (when using BookLore output mode)
- **Email recipient** — Per-user email address (when using Email output mode)
### Notifications
Users can configure personal notification routes, separate from the global notification settings. Each route targets a URL (e.g. an Apprise-compatible endpoint) and can be scoped to specific event types or all events.
### Request Policy (admin-only)
Admins can override the default ebook/audiobook modes and request rules for individual users. See [Per-User Overrides](#per-user-overrides) below.
---
## Requests
The request system controls whether users can download directly or need admin approval first.
### Policy Modes
Each content type (ebook, audiobook) has a default mode that sets the baseline:
| Mode | Behaviour |
|------|-----------|
| **Download** | Users download directly, no approval needed |
| **Request Release** | Users pick a specific release, then submit it for admin approval |
| **Request Book** | Users request the book itself — an admin picks the release and fulfils it |
| **Blocked** | No downloads or requests allowed |
### Settings
| Setting | Description | Default |
|---------|-------------|---------|
| Enable Requests | Master toggle. When off, everyone downloads directly | Off |
| Default Ebook Mode | Baseline mode for all ebook sources | Download |
| Default Audiobook Mode | Baseline mode for all audiobook sources | Download |
| Request Rules | Per-source overrides (see below) | None |
| Max Pending Requests Per User | Open request limit per user | 20 |
| Allow Notes on Requests | Let users attach a note when submitting | On |
### Request Rules
The rules matrix lets you override the mode for specific source + content type combinations. Rules can only be **equal to or more restrictive** than the content-type default — they cannot grant more access than the baseline.
For example, if the default ebook mode is "Download", a rule can restrict a specific source to "Request Release" or "Blocked", but not the other way around. If no rule matches, the content-type default applies.
### Per-User Overrides
Admins can override the default ebook/audiobook modes and request rules for individual users. Per-user rules are overlaid on the global rules, not replacing them.
### Request Lifecycle
1. User submits a request (book or release level, depending on the resolved policy mode)
2. Request appears in the admin request queue as **pending**
3. Admin either **fulfils** (queues a download) or **rejects** the request
4. For fulfilled requests, delivery state is tracked through the download pipeline
5. If delivery fails, an admin can reopen the request to try a different release
6. Users can cancel their own pending requests
+166 -33
View File
@@ -1,10 +1,54 @@
#!/bin/bash
LOG_DIR=${LOG_ROOT:-/var/log/}/shelfmark
mkdir -p $LOG_DIR
LOG_FILE=${LOG_DIR}/shelfmark_entrypoint.log
# Cleanup any existing files or folders in the log directory
rm -rf $LOG_DIR/*
is_truthy() {
case "${1,,}" in
true|yes|1|y) return 0 ;;
*) return 1 ;;
esac
}
ENABLE_LOGGING_VALUE="${ENABLE_LOGGING:-true}"
LOG_PIPE_DIR=""
LOG_PIPE=""
TEE_PID=""
start_file_logging() {
local logfile="$1"
LOG_PIPE_DIR="$(mktemp -d)"
LOG_PIPE="${LOG_PIPE_DIR}/shelfmark-log.pipe"
mkfifo "$LOG_PIPE"
tee -a "$logfile" < "$LOG_PIPE" &
TEE_PID=$!
exec 3>&1 4>&2
exec > "$LOG_PIPE" 2>&1
}
stop_file_logging() {
if [ -z "${TEE_PID:-}" ]; then
return 0
fi
exec 1>&3 2>&4
exec 3>&- 4>&-
rm -f "$LOG_PIPE"
rmdir "$LOG_PIPE_DIR" 2>/dev/null || true
wait "$TEE_PID" 2>/dev/null || true
TEE_PID=""
}
if is_truthy "$ENABLE_LOGGING_VALUE"; then
LOG_DIR=${LOG_ROOT:-/var/log/}/shelfmark
mkdir -p "$LOG_DIR"
LOG_FILE="${LOG_DIR}/shelfmark_entrypoint.log"
# Keep the previous entrypoint log instead of deleting all history on boot.
[ -f "${LOG_FILE}.prev" ] && rm -f "${LOG_FILE}.prev"
[ -f "$LOG_FILE" ] && mv "$LOG_FILE" "${LOG_FILE}.prev"
fi
(
if [ "$USING_TOR" = "true" ]; then
@@ -12,10 +56,16 @@ rm -rf $LOG_DIR/*
fi
)
exec 3>&1 4>&2
exec > >(tee -a $LOG_FILE) 2>&1
if is_truthy "$ENABLE_LOGGING_VALUE"; then
start_file_logging "$LOG_FILE"
fi
echo "Starting entrypoint script"
echo "Log file: $LOG_FILE"
if is_truthy "$ENABLE_LOGGING_VALUE"; then
echo "Log file: $LOG_FILE"
else
echo "File logging disabled (ENABLE_LOGGING=$ENABLE_LOGGING_VALUE)"
fi
set -e
# Print build version
@@ -77,14 +127,24 @@ USERNAME=$(getent passwd "$RUN_UID" | cut -d: -f1)
echo "Username for UID $RUN_UID is $USERNAME"
test_write() {
folder=$1
test_file=$folder/shelfmark_TEST_WRITE
mkdir -p $folder
(
echo 0123456789_TEST | sudo -E -u "$USERNAME" HOME=/app tee $test_file > /dev/null
)
FILE_CONTENT=$(cat $test_file || echo "")
rm -f $test_file
local folder=$1
local test_file="$folder/shelfmark_TEST_WRITE"
local FILE_CONTENT
local result
local result_text
if ! mkdir -p "$folder"; then
echo "Failed to create directory for write test: $folder"
return 1
fi
if ! gosu "$USERNAME" sh -c 'echo 0123456789_TEST > "$1"' _ "$test_file"; then
echo "Failed to write test file in $folder as $USERNAME"
return 1
fi
FILE_CONTENT=$(cat "$test_file" 2>/dev/null || echo "")
rm -f "$test_file"
[ "$FILE_CONTENT" = "0123456789_TEST" ]
result=$?
if [ $result -eq 0 ]; then
@@ -98,6 +158,7 @@ test_write() {
make_writable() {
folder=$1
did_full_chown=0
set +e
test_write $folder
is_writable=$?
@@ -108,31 +169,106 @@ make_writable() {
echo "Folder $folder is not writable, changing ownership"
change_ownership $folder
chmod -R g+r,g+w $folder || echo "Failed to change group permissions for ${folder}, continuing..."
did_full_chown=1
fi
# Fix any misowned subdirectories/files (e.g., from previous runs as root)
if [ -d "$folder" ]; then
misowned_count=$(find "$folder" -mindepth 1 \( ! -user "$RUN_UID" -o ! -group "$RUN_GID" \) 2>/dev/null | wc -l)
if [ "$misowned_count" -gt 0 ]; then
echo "Fixing ownership of $misowned_count files/directories in $folder"
find "$folder" -mindepth 1 \( ! -user "$RUN_UID" -o ! -group "$RUN_GID" \) \
-exec chown "$RUN_UID:$RUN_GID" {} \; 2>/dev/null || true
fi
if [ "$did_full_chown" -eq 0 ] && [ -d "$folder" ]; then
echo "Checking for misowned files/directories in $folder"
# Stay on the same filesystem to avoid traversing mounted subpaths
# (for example read-only bind mounts under /app in dev setups).
find "$folder" -xdev -mindepth 1 \( ! -user "$RUN_UID" -o ! -group "$RUN_GID" \) \
-exec chown "$RUN_UID:$RUN_GID" {} + 2>/dev/null || true
fi
test_write $folder || echo "Failed to test write to ${folder}, continuing..."
}
fix_misowned() {
folder=$1
mkdir -p $folder
echo "Checking for misowned files/directories in $folder"
# Stay on the same filesystem to avoid traversing mounted subpaths
# (for example read-only bind mounts under /app in dev setups).
find "$folder" -xdev \( ! -user "$RUN_UID" -o ! -group "$RUN_GID" \) \
-exec chown "$RUN_UID:$RUN_GID" {} + 2>/dev/null || true
}
# Ensure proper ownership of application directories
change_ownership() {
folder=$1
mkdir -p $folder
echo "Changing ownership of $folder to $USERNAME:$RUN_GID"
chown -R "${RUN_UID}" "${folder}" || echo "Failed to change user ownership for ${folder}, continuing..."
chown -R ":${RUN_GID}" "${folder}" || echo "Failed to change group ownership for ${folder}, continuing..."
chown -R "${RUN_UID}:${RUN_GID}" "${folder}" || echo "Failed to change ownership for ${folder}, continuing..."
}
change_ownership /app
change_ownership /var/log/shelfmark
change_ownership /tmp/shelfmark
ensure_tree_writable() {
local folder="$1"
make_writable "$folder"
if [ -d "$folder" ]; then
chmod -R u+rwX,g+rwX "$folder" || echo "Failed to relax permissions for ${folder}, continuing..."
fi
}
ensure_symlinked_dir() {
local link_path="$1"
local target_path="$2"
ensure_tree_writable "$target_path"
if [ -L "$link_path" ]; then
local current_target
current_target=$(readlink "$link_path" 2>/dev/null || echo "")
if [ "$current_target" = "$target_path" ]; then
echo "$link_path already points to $target_path"
return 0
fi
echo "Replacing symlink $link_path -> $current_target with $target_path"
rm -f "$link_path" || echo "Failed to replace symlink ${link_path}, continuing..."
elif [ -d "$link_path" ]; then
echo "Moving existing scratch files from $link_path to $target_path"
find "$link_path" -xdev -mindepth 1 -maxdepth 1 -exec mv -t "$target_path" {} + 2>/dev/null || true
ensure_tree_writable "$target_path"
if ! rmdir "$link_path" 2>/dev/null; then
echo "Could not replace $link_path with symlink, leaving existing directory in place"
ensure_tree_writable "$link_path"
return 0
fi
elif [ -e "$link_path" ]; then
echo "$link_path exists and is not a directory, leaving it in place"
return 0
fi
if [ ! -e "$link_path" ]; then
ln -s "$target_path" "$link_path" || echo "Failed to create symlink ${link_path}, continuing..."
fi
}
fix_misowned /app
fix_misowned /var/log/shelfmark
fix_misowned /tmp/shelfmark
# Keep SeleniumBase on its default /app-based paths, but redirect the scratch
# directories into /tmp so bypasser startup doesn't depend on image-layer writes.
if [ "${USING_EXTERNAL_BYPASSER}" != "true" ]; then
ensure_symlinked_dir /app/downloaded_files /tmp/shelfmark/seleniumbase/downloaded_files
ensure_symlinked_dir /app/archived_files /tmp/shelfmark/seleniumbase/archived_files
# Keep SeleniumBase's bundled drivers directory writable as well for
# compatibility with legacy UC code paths that still probe bundled assets.
set +e
SELENIUMBASE_DRIVERS_DIR=$(python3 -c "import pathlib, seleniumbase; print(pathlib.Path(seleniumbase.__file__).resolve().parent / 'drivers')" 2>/dev/null)
set -e
if [ -n "$SELENIUMBASE_DRIVERS_DIR" ] && [ -d "$SELENIUMBASE_DRIVERS_DIR" ]; then
change_ownership "$SELENIUMBASE_DRIVERS_DIR"
# If the legacy driver already exists, ensure it's executable for the runtime user.
if [ -f "${SELENIUMBASE_DRIVERS_DIR}/uc_driver" ]; then
chmod +x "${SELENIUMBASE_DRIVERS_DIR}/uc_driver" || echo "Failed to chmod uc_driver, continuing..."
fi
fi
fi
# Test write to all folders
make_writable ${CONFIG_DIR:-/config}
@@ -263,8 +399,5 @@ UMASK_VALUE=${UMASK:-0022}
echo "Setting umask to $UMASK_VALUE"
umask $UMASK_VALUE
# Stop logging
exec 1>&3 2>&4
exec 3>&- 4>&-
exec sudo -E -u "$USERNAME" HOME=/app $command
stop_file_logging
exec gosu "$USERNAME" env HOME=/app $command
+22
View File
@@ -0,0 +1,22 @@
{
"name": "shelfmark",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"devDependencies": {
"baseline-browser-mapping": "^2.9.19"
}
},
"node_modules/baseline-browser-mapping": {
"version": "2.9.19",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz",
"integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"baseline-browser-mapping": "dist/cli.js"
}
}
}
}
+5
View File
@@ -0,0 +1,5 @@
{
"devDependencies": {
"baseline-browser-mapping": "^2.9.19"
}
}
+66 -60
View File
@@ -4,19 +4,26 @@ Formerly *Calibre Web Automated Book Downloader (CWABD)*
<img src="src/frontend/public/logo.png" alt="Shelfmark" width="200">
Shelfmark is a unified web interface for searching and aggregating books and audiobook downloads from multiple sources - all in one place. Works out of the box with popular web sources, no configuration required. Add metadata providers, additional release sources, and download clients to create a single hub for building your digital library.
Shelfmark is a self-hosted web interface for searching and downloading books and audiobooks from multiple sources. Works out of the box with popular web sources, no configuration required. Add metadata providers, additional release sources, and download clients to build a single hub for your digital library. Supports multiple users with a built-in request system, so you can share your instance with others and let them browse and request books on their own.
**Fully standalone** - no external dependencies required. Works great alongside library tools like [Calibre-Web-Automated](https://github.com/crocodilestick/Calibre-Web-Automated), [Booklore](https://github.com/booklore-app/booklore) or [Audiobookshelf](https://github.com/advplyr/audiobookshelf) for automatic import.
**Fully standalone** - no external dependencies required. Works great alongside the following library tools, with support for automatic imports:
- [Calibre](https://calibre-ebook.com/)
- [Calibre-Web](https://github.com/janeczku/calibre-web)
- [Calibre-Web-Automated](https://github.com/crocodilestick/Calibre-Web-Automated)
- [Grimmory](https://github.com/grimmory-tools/grimmory)
- [Audiobookshelf](https://github.com/advplyr/audiobookshelf)
## ✨ Features
- **One-Stop Interface** - A clean, modern UI to search, browse, and download from multiple sources in one place
- **Multiple sources** - Popular archive websites, Torrent, Usenet and IRC download support
- **Audiobook support** - Full audiobook search and download with dedicated processing
- **Real-Time Progress** - Unified download queue with live status updates across all sources
- **Multiple Sources** - Popular archive websites, Torrent, Usenet, and IRC download support
- **Audiobook Support** - Full audiobook search and download with dedicated processing
- **Two Search Modes**:
- **Direct** - Search popular web sources
- **Universal** - Search metadata providers (Hardcover, Open Library) for richer book and audiobook discovery, with multi-source downloads
- **Multi-User & Requests** - Share your instance with others, let users browse and request books, and manage approvals with configurable notifications
- **Authentication** - Built-in login, OIDC single sign-on, proxy auth, and Calibre-Web database support
- **Real-Time Progress** - Unified download queue with live status updates across all sources
- **Cloudflare Bypass** - Built-in bypasser for reliable access to protected sources
## 🖼️ Screenshots
@@ -41,9 +48,9 @@ Shelfmark is a unified web interface for searching and aggregating books and aud
### Installation
1. Download the docker-compose file:
1. Download the [docker-compose file](compose/docker-compose.yml):
```bash
curl -O https://raw.githubusercontent.com/calibrain/shelfmark/main/compose/stable/docker-compose.yml
curl -O https://raw.githubusercontent.com/calibrain/shelfmark/main/compose/docker-compose.yml
```
2. Start the service:
@@ -51,8 +58,6 @@ Shelfmark is a unified web interface for searching and aggregating books and aud
docker compose up -d
```
> **Edge users**: If you're tracking the main branch (`:dev` tag), use compose files from `compose/edge/` instead.
3. Open `http://localhost:8084`
That's it! Configure settings through the web interface as needed.
@@ -66,7 +71,7 @@ volumes:
- /client/path:/client/path # Optional: For Torrent/Usenet downloads, match your client directory exactly.
```
> **Tip**: Point the download volume to your CWA or Booklore ingest folder for automatic import.
> **Tip**: Point the download volume to your CWA or Grimmory ingest folder for automatic import.
> **Note**: CIFS shares require `nobrl` mount option to avoid database lock errors.
@@ -98,11 +103,14 @@ Environment variables work for initial setup and Docker deployments. They serve
| `SEARCH_MODE` | `direct` or `universal` | `direct` |
| `USING_TOR` | Enable Tor routing (requires `NET_ADMIN` capability) | `false` |
See the full [Environment Variables Reference](docs/environment-variables.md) for all available options.
Some of the additional options available in Settings:
- **Fast Download Key** - Use your paid account to skip Cloudflare challenges entirely and use faster, direct downloads
- **Prowlarr** - Configure indexers and download clients to download books and audiobooks
- **AudiobookBay** - Web scraping source for audiobook torrents (audiobooks only)
- **IRC** - Add details for IRC book sources and download directly from the UI
- **Library Link** - Add a link to your Calibre-Web or Booklore instance in the UI header
- **Library Link** - Add a link to your Calibre-Web or Grimmory instance in the UI header
- **File processing** - Customiseable download paths, file renaming and directory creation with template-based renaming
- **Network Resilience** - Auto DNS rotation and mirror fallback when sources are unreachable. Custom proxy support (SOCK5 + HTTP/S), Tor routing.
- **Format & Language** - Filter downloads by preferred formats, languages and sorting order
@@ -120,7 +128,7 @@ The full-featured image with built-in Cloudflare bypass.
#### Enable Tor Routing
Routes all traffic through Tor for enhanced privacy:
```bash
curl -O https://raw.githubusercontent.com/calibrain/shelfmark/main/compose/stable/docker-compose.tor.yml
curl -O https://raw.githubusercontent.com/calibrain/shelfmark/main/compose/docker-compose.tor.yml
docker compose -f docker-compose.tor.yml up -d
```
@@ -134,11 +142,11 @@ A smaller image without the built-in Cloudflare bypasser. Ideal for:
- **External bypassers** - Already running FlareSolverr or ByParr for other services
- **Fast downloads** - Using fast download sources
- **Alternative sources only** - Exclusively using Prowlarr, IRC, or other sources
- **Alternative sources only** - Exclusively using Prowlarr, AudiobookBay, IRC, or other sources
- **Audiobooks** - Using Shelfmark exclusively for audiobooks
```bash
curl -O https://raw.githubusercontent.com/calibrain/shelfmark/main/compose/stable/docker-compose.lite.yml
curl -O https://raw.githubusercontent.com/calibrain/shelfmark/main/compose/docker-compose.lite.yml
docker compose -f docker-compose.lite.yml up -d
```
@@ -146,15 +154,50 @@ If you need Cloudflare bypass with the Lite image, configure an external resolve
## 🔐 Authentication
Authentication is optional but recommended for shared or exposed instances. Enable in Settings.
Authentication is optional but recommended for shared or exposed instances. Multiple authentication methods are available in Settings:
**Alternative**: If you're running Calibre-Web, you can reuse its user database by mounting it:
**1. Single Username/Password**
**2. Proxy (Forward) Authentication**
Proxy auth trusts headers set by your reverse proxy (e.g. `X-Auth-User`). Ensure Shelfmark is not directly exposed, and configure your proxy to strip/overwrite these headers for all inbound requests.
**3. OIDC (OpenID Connect)**
Integrate with your identity provider (Authelia, Authentik, Keycloak, etc.) for single sign-on. Supports PKCE flow, auto-discovery, group-based admin mapping, and auto-provisioning of new users.
**4. Calibre-Web Database**
If you're running Calibre-Web, you can reuse its user database by mounting it:
```yaml
volumes:
- /path/to/calibre-web/app.db:/auth/app.db:ro
```
### Multi-User Support
With any authentication method enabled, Shelfmark supports multi-user management with admin/user roles. Users can have per-user settings for download destinations, email recipients, and notification preferences. Non-admin users only see their own downloads and can submit book requests for admin review. Admins can configure request policies per source to control whether users can download directly, must submit a request, or are blocked entirely.
## Project Scope
Shelfmark is a manual search and download tool, the entry point to your book library, not a library manager. It finds books, downloads them, and sends them to a configured destination. That's the full scope.
Shelfmark intentionally does not:
- **Track or manage your library** - it doesn't know or care what you already own
- **Integrate with library software** - what happens after delivery is up to your library tool
- **Monitor authors, series, or new releases** - there is no background automation
- **Queue future downloads** - if a book isn't available now, Shelfmark won't watch for it
These are non-goals, not missing features.
## Contributing
Shelfmark's core feature set is complete. Development focuses on stability, bug fixes, quality-of-life improvements, and refining the search experience. Contributions in these areas are welcome, please file issues or submit pull requests on GitHub.
Feature requests that fall outside the project scope (library integration, automation, collection management) will be closed. If you're unsure whether something fits, open a discussion first.
## Health Monitoring
The application exposes a health endpoint at `/api/health` (no authentication required). Add a health check to your compose:
@@ -193,57 +236,20 @@ make restart # Restart container
The frontend dev server proxies to the backend on port 8084.
### Architecture
```
┌─────────────────────────────────────────────────────────────┐
│ Web Interface │
│ (React + TypeScript + Vite) │
├─────────────────────────────────────────────────────────────┤
│ Flask Backend │
│ (REST API + WebSocket) │
├───────────────────┬─────────────────────┬───────────────────┤
│ Metadata Providers│ Download Queue │ Cloudflare │
│ │ & Orchestrator │ Bypass │
├───────────────────┼─────────────────────┼───────────────────┤
│ • Hardcover │ • Task scheduling │ • Internal │
│ • Open Library │ • Progress tracking │ • External │
│ │ • Retry logic │ (FlareSolverr) │
├───────────────────┴─────────────────────┴───────────────────┤
│ Release Sources │
├─────────────────────────────────────────────────────────────┤
│ • Direct Download (Web Sources → Mirrors → Fallbacks) │
├─────────────────────────────────────────────────────────────┤
│ Network Layer │
├─────────────────────────────────────────────────────────────┤
│ • Auto DNS rotation • Mirror failover • Resume support │
└─────────────────────────────────────────────────────────────┘
```
The backend uses a plugin architecture. Metadata providers and release sources register via decorators and are automatically discovered.
## Contributing
Contributions are welcome! Please file issues or submit pull requests on GitHub.
> **Note**: Additional release sources and download clients are under active development. Want to add support for your favorite source? Check out the plugin architecture above and submit a PR!
## License
MIT License - see [LICENSE](LICENSE) for details.
## ⚠️ Disclaimers
## ⚠️ Disclaimer
### Copyright Notice
Shelfmark is a search interface that displays results from external metadata providers and sources. It does not host, store, or distribute any content. The developers are not responsible for how the tool is used or what is accessed through it.
This tool can access various sources including those that might contain copyrighted material. Users are responsible for:
- Ensuring they have the right to download requested materials
- Respecting copyright laws and intellectual property rights
- Using the tool in compliance with their local regulations
Users are solely responsible for:
- Ensuring they have the legal right to download any material they access
- Complying with copyright laws and intellectual property rights in their jurisdiction
- Understanding and accepting the terms of any sources they configure
### Library Integration
Downloads are written atomically (via intermediate `.crdownload` files) to prevent partial files from being ingested. However, if your library tool (CWA, Booklore, Calibre) is actively scanning or importing, there's a small chance of race conditions. If you experience database errors or import failures, try pausing your library's auto-import during bulk downloads.
Use of this tool is entirely at your own risk.
## Support
+3 -1
View File
@@ -3,6 +3,7 @@ flask-cors
flask-socketio
python-socketio
requests[socks]
defusedxml
beautifulsoup4
tqdm
dnspython
@@ -14,4 +15,5 @@ emoji
rarfile
qbittorrent-api
transmission-rpc
deluge-client
authlib>=1.6.6,<1.7
apprise>=1.9.0
+1 -1
View File
@@ -1,4 +1,4 @@
pyvirtualdisplay
pyautogui
seleniumbase>=4.45.6
seleniumbase==4.47.3
python-xlib
+246
View File
@@ -0,0 +1,246 @@
#!/usr/bin/env bash
set -euo pipefail
LATEST_IMAGE="${LATEST_IMAGE:-ghcr.io/calibrain/shelfmark:latest}"
LEGACY_IMAGE="${LEGACY_IMAGE:-ghcr.io/calibrain/shelfmark:v1.0.2}"
WAIT_SECONDS="${WAIT_SECONDS:-5}"
STARTUP_TIMEOUT_SECONDS="${STARTUP_TIMEOUT_SECONDS:-120}"
require_cmd() {
command -v "$1" >/dev/null 2>&1 || {
echo "Missing required command: $1" >&2
exit 1
}
}
cleanup() {
local name="$1"
docker rm -f "$name" >/dev/null 2>&1 || true
}
wait_for_startup() {
local name="$1"
local elapsed=0
while [ "$elapsed" -lt "$STARTUP_TIMEOUT_SECONDS" ]; do
if ! docker inspect "$name" >/dev/null 2>&1; then
echo "Container $name no longer exists" >&2
return 1
fi
if [ "$(docker inspect -f '{{.State.Status}}' "$name" 2>/dev/null)" != "running" ]; then
echo "Container $name exited before startup completed" >&2
docker logs --tail 120 "$name" 2>&1 || true
return 1
fi
if docker exec "$name" sh -lc "id appuser >/dev/null 2>&1 && ps -eo comm,args | awk '\$1 == \"gunicorn\" && index(\$0, \"shelfmark.main:app\") { found=1 } END { exit(found ? 0 : 1) }'" >/dev/null 2>&1; then
return 0
fi
sleep 1
elapsed=$((elapsed + 1))
done
echo "Timed out waiting for $name to finish startup" >&2
docker logs --tail 120 "$name" 2>&1 || true
return 1
}
start_container() {
local name="$1"
local image="$2"
local pre_entrypoint_script="${3:-}"
cleanup "$name"
if [ -n "$pre_entrypoint_script" ]; then
docker run -d \
--name "$name" \
--entrypoint sh \
-e PUID=1000 \
-e PGID=1000 \
-e TZ=UTC \
"$image" \
-lc "$pre_entrypoint_script
exec /app/entrypoint.sh" >/dev/null
else
docker run -d \
--name "$name" \
-e PUID=1000 \
-e PGID=1000 \
-e TZ=UTC \
"$image" >/dev/null
sleep "$WAIT_SECONDS"
fi
wait_for_startup "$name"
}
run_probe() {
local name="$1"
local mode="${2:-default}"
docker exec -u appuser -e PROBE_MODE="$mode" "$name" sh -lc 'python3 - <<'"'"'PY'"'"'
import asyncio
import os
import shelfmark.bypass.internal_bypasser as ib
async def run_probe():
driver = None
probe_mode = os.environ.get("PROBE_MODE", "default")
if probe_mode == "proxy_auth" and hasattr(ib, "_get_proxy_string"):
ib._get_proxy_string = lambda _url: "user:pass@127.0.0.1:8888"
if hasattr(ib, "_create_cdp_browser"):
try:
driver = await ib._create_cdp_browser("https://example.com")
profile = getattr(getattr(driver, "config", None), "user_data_dir", "")
print(f"PROBE=OK mode={probe_mode} fn=_create_cdp_browser profile={profile}")
except Exception as e:
print(f"PROBE=ERR mode={probe_mode} fn=_create_cdp_browser type={type(e).__name__} msg={e}")
finally:
if driver and hasattr(ib, "_close_cdp_driver"):
await ib._close_cdp_driver(driver)
return
if hasattr(ib, "_create_driver"):
try:
driver = await ib._create_driver()
print(f"PROBE=OK mode={probe_mode} fn=_create_driver driver_type={type(driver).__name__}")
except Exception as e:
print(f"PROBE=ERR mode={probe_mode} fn=_create_driver type={type(e).__name__} msg={e}")
finally:
if driver and hasattr(ib, "_quit_driver"):
await ib._quit_driver(driver)
return
print(f"PROBE=ERR mode={probe_mode} fn=unknown type=RuntimeError msg=no supported startup function found")
asyncio.run(run_probe())
PY'
}
show_logs() {
local name="$1"
docker logs --tail 80 "$name" 2>&1 | tail -n 20
}
scenario_latest_baseline() {
local name="sb-lab-latest-baseline"
echo
echo "== latest baseline =="
start_container "$name" "$LATEST_IMAGE"
run_probe "$name"
cleanup "$name"
}
scenario_latest_drivers_readonly() {
local name="sb-lab-latest-drivers"
echo
echo "== latest drivers readonly =="
start_container "$name" "$LATEST_IMAGE" '
chown -R root:root /usr/local/lib/python3.10/site-packages/seleniumbase/drivers &&
chmod -R a-w /usr/local/lib/python3.10/site-packages/seleniumbase/drivers &&
ls -ld /usr/local/lib/python3.10/site-packages/seleniumbase/drivers
'
run_probe "$name"
cleanup "$name"
}
scenario_latest_proxy_auth_baseline() {
local name="sb-lab-latest-proxy-baseline"
echo
echo "== latest proxy auth baseline =="
start_container "$name" "$LATEST_IMAGE"
run_probe "$name" "proxy_auth"
cleanup "$name"
}
scenario_latest_downloads_readonly() {
local name="sb-lab-latest-downloads"
echo
echo "== latest downloaded_files readonly =="
start_container "$name" "$LATEST_IMAGE" '
mkdir -p /app/downloaded_files &&
touch /app/downloaded_files/pipfinding.lock /app/downloaded_files/proxy_dir.lock &&
chown -R root:root /app/downloaded_files &&
chmod -R a-w /app/downloaded_files &&
find /app/downloaded_files -maxdepth 2 -printf "%M %u:%g %p\n"
'
run_probe "$name"
show_logs "$name"
cleanup "$name"
}
scenario_latest_proxy_auth_downloads_readonly() {
local name="sb-lab-latest-proxy-downloads"
echo
echo "== latest proxy auth with readonly downloaded_files =="
start_container "$name" "$LATEST_IMAGE" '
mkdir -p /app/downloaded_files &&
touch /app/downloaded_files/pipfinding.lock /app/downloaded_files/proxy_dir.lock &&
chown appuser:appuser /app/downloaded_files/pipfinding.lock /app/downloaded_files/proxy_dir.lock &&
chmod 0666 /app/downloaded_files/pipfinding.lock /app/downloaded_files/proxy_dir.lock &&
chown root:root /app/downloaded_files &&
chmod 0555 /app/downloaded_files &&
ls -ld /app/downloaded_files &&
ls -la /app/downloaded_files
'
run_probe "$name" "proxy_auth"
show_logs "$name"
cleanup "$name"
}
scenario_latest_bind_mount_readonly() {
local name="sb-lab-latest-bind-ro"
local bind_dir
bind_dir="$(mktemp -d /tmp/sb-lab-bind.XXXXXX)"
echo
echo "== latest readonly bind mount for downloaded_files =="
chmod 0555 "$bind_dir"
cleanup "$name"
docker run -d \
--name "$name" \
-e PUID=1000 \
-e PGID=1000 \
-e TZ=UTC \
--mount "type=bind,src=${bind_dir},target=/app/downloaded_files,readonly" \
"$LATEST_IMAGE" >/dev/null
wait_for_startup "$name"
run_probe "$name"
show_logs "$name"
cleanup "$name"
rm -rf "$bind_dir"
}
scenario_legacy_drivers_readonly() {
local name="sb-lab-legacy-drivers"
echo
echo "== legacy drivers readonly =="
start_container "$name" "$LEGACY_IMAGE" '
chown -R root:root /usr/local/lib/python3.10/site-packages/seleniumbase/drivers &&
chmod -R a-w /usr/local/lib/python3.10/site-packages/seleniumbase/drivers &&
ls -ld /usr/local/lib/python3.10/site-packages/seleniumbase/drivers
'
run_probe "$name"
show_logs "$name"
cleanup "$name"
}
main() {
require_cmd docker
scenario_latest_baseline
scenario_latest_drivers_readonly
scenario_latest_proxy_auth_baseline
scenario_latest_downloads_readonly
scenario_latest_proxy_auth_downloads_readonly
scenario_latest_bind_mount_readonly
scenario_legacy_drivers_readonly
}
main "$@"
+440
View File
@@ -0,0 +1,440 @@
#!/usr/bin/env python3
"""Generate markdown documentation for environment variables from the settings registry.
This script extracts all settings that support environment variable configuration
and generates a comprehensive markdown file documenting each option.
Usage:
python scripts/generate_env_docs.py [--output path/to/output.md]
The generated documentation includes:
- Environment variable name
- Description
- Type (string, number, boolean, etc.)
- Default value
- Organizational grouping by settings tab/group
"""
import argparse
import sys
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional
# Add project root to path
project_root = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(project_root))
def get_field_type_name(field) -> str:
"""Get a human-readable type name for a field."""
from shelfmark.core.settings_registry import (
CheckboxField,
MultiSelectField,
NumberField,
OrderableListField,
PasswordField,
SelectField,
TextField,
)
if isinstance(field, CheckboxField):
return "boolean"
elif isinstance(field, NumberField):
return "number"
elif isinstance(field, SelectField):
return "string (choice)"
elif isinstance(field, MultiSelectField):
return "string (comma-separated)"
elif isinstance(field, OrderableListField):
return "JSON array"
elif isinstance(field, PasswordField):
return "string (secret)"
elif isinstance(field, TextField):
return "string"
else:
return "string"
def format_default_value(field) -> str:
"""Format the default value for display."""
default = field.default
if default is None:
return "_none_"
elif isinstance(default, bool):
return f"`{str(default).lower()}`"
elif isinstance(default, (int, float)):
return f"`{default}`"
elif isinstance(default, str):
if default == "":
return "_empty string_"
return f"`{default}`"
elif isinstance(default, list):
if not default:
return "_empty list_"
# For simple lists, show comma-separated values
if all(isinstance(item, str) for item in default):
return f"`{','.join(default)}`"
# For complex lists (e.g., OrderableListField defaults), summarize
return f"_see UI for defaults_"
else:
return f"`{default}`"
def get_select_options(field) -> Optional[List[str]]:
"""Get the available options for a SelectField.
Returns options formatted as 'value (label)' or just 'value' if they match,
so users know the actual values to use in environment variables.
"""
from shelfmark.core.settings_registry import SelectField
if not isinstance(field, SelectField):
return None
options = field.options
if callable(options):
try:
options = options()
except Exception:
return None
if not options:
return None
result = []
for opt in options:
value = opt.get("value", "")
label = opt.get("label", "")
# Format as "value (label)" unless they're the same or value is empty
if value == "":
result.append(f'`""` ({label})')
elif value == label or not label:
result.append(f"`{value}`")
else:
result.append(f"`{value}` ({label})")
return result
def _generate_bootstrap_env_docs() -> List[str]:
"""Generate documentation for bootstrap environment variables from env.py."""
# These are environment variables defined in env.py that are used before
# the settings registry is available
bootstrap_vars = [
{
"name": "CONFIG_DIR",
"description": "Directory for storing configuration files and plugin settings.",
"type": "string (path)",
"default": "/config",
},
{
"name": "LOG_ROOT",
"description": "Root directory for log files.",
"type": "string (path)",
"default": "/var/log/",
},
{
"name": "TMP_DIR",
"description": "Staging directory for downloads before moving to destination.",
"type": "string (path)",
"default": "/tmp/shelfmark",
},
{
"name": "ENABLE_LOGGING",
"description": "Enable file logging under LOG_ROOT/shelfmark/ (including shelfmark.log and startup logs).",
"type": "boolean",
"default": "true",
},
{
"name": "FLASK_HOST",
"description": "Host address for the Flask web server.",
"type": "string",
"default": "0.0.0.0",
},
{
"name": "FLASK_PORT",
"description": "Port number for the Flask web server.",
"type": "number",
"default": "8084",
},
{
"name": "SESSION_COOKIE_SECURE",
"description": "Enable secure cookies (requires HTTPS).",
"type": "boolean",
"default": "false",
},
{
"name": "CWA_DB_PATH",
"description": "Path to the Calibre-Web database for authentication integration.",
"type": "string (path)",
"default": "/auth/app.db",
},
{
"name": "DOCKERMODE",
"description": "Indicates the application is running inside a Docker container.",
"type": "boolean",
"default": "false",
},
{
"name": "ONBOARDING",
"description": "Show the onboarding wizard on first run. Set to false to skip (useful for ephemeral storage).",
"type": "boolean",
"default": "true",
},
]
lines = [
"## Bootstrap Configuration",
"",
"These environment variables are used at startup before the settings system loads. They typically configure paths and server settings.",
"",
"| Variable | Description | Type | Default |",
"|----------|-------------|------|---------|",
]
for var in bootstrap_vars:
lines.append(f"| `{var['name']}` | {var['description']} | {var['type']} | `{var['default']}` |")
lines.append("")
lines.append("<details>")
lines.append("<summary>Detailed descriptions</summary>")
lines.append("")
for var in bootstrap_vars:
lines.append(f"#### `{var['name']}`")
lines.append("")
lines.append(var["description"])
lines.append("")
lines.append(f"- **Type:** {var['type']}")
lines.append(f"- **Default:** `{var['default']}`")
lines.append("")
lines.append("</details>")
lines.append("")
return lines
def generate_env_docs() -> str:
"""Generate markdown documentation for all environment variables."""
# Import settings modules to ensure all settings are registered
import shelfmark.config.settings # noqa: F401
import shelfmark.config.security # noqa: F401
import shelfmark.release_sources.irc.settings # noqa: F401
import shelfmark.release_sources.prowlarr.settings # noqa: F401
import shelfmark.metadata_providers.hardcover # noqa: F401
import shelfmark.metadata_providers.openlibrary # noqa: F401
import shelfmark.metadata_providers.googlebooks # noqa: F401
from shelfmark.core.settings_registry import (
ActionButton,
HeadingField,
get_all_groups,
get_all_settings_tabs,
)
tabs = get_all_settings_tabs()
groups = {g.name: g for g in get_all_groups()}
# Organize tabs by group
grouped_tabs: Dict[Optional[str], List] = {None: []}
for group_name in groups:
grouped_tabs[group_name] = []
for tab in tabs:
group_name = tab.group
if group_name not in grouped_tabs:
grouped_tabs[group_name] = []
grouped_tabs[group_name].append(tab)
# Build markdown output
lines = [
"# Environment Variables",
"",
"This document lists all configuration options that can be set via environment variables.",
"",
"> **Auto-generated** - Do not edit manually. Run `python scripts/generate_env_docs.py` to regenerate.",
"",
"## Table of Contents",
"",
]
# Generate TOC
toc_entries = [
"- [Bootstrap Configuration](#bootstrap-configuration)",
]
# Ungrouped tabs first
for tab in grouped_tabs.get(None, []):
anchor = tab.display_name.lower().replace(" ", "-")
toc_entries.append(f"- [{tab.display_name}](#{anchor})")
# Then grouped tabs
for group_name, group in groups.items():
group_tabs = grouped_tabs.get(group_name, [])
if group_tabs:
anchor = group.display_name.lower().replace(" ", "-")
toc_entries.append(f"- [{group.display_name}](#{anchor})")
for tab in group_tabs:
sub_anchor = f"{group.display_name}-{tab.display_name}".lower().replace(" ", "-")
toc_entries.append(f" - [{tab.display_name}](#{sub_anchor})")
lines.extend(toc_entries)
lines.append("")
lines.append("---")
lines.append("")
# Add bootstrap environment variables documentation
lines.extend(_generate_bootstrap_env_docs())
# Generate documentation for ungrouped tabs
for tab in grouped_tabs.get(None, []):
lines.extend(_generate_tab_docs(tab))
# Generate documentation for grouped tabs
for group_name, group in groups.items():
group_tabs = grouped_tabs.get(group_name, [])
if not group_tabs:
continue
lines.append(f"## {group.display_name}")
lines.append("")
for tab in group_tabs:
lines.extend(_generate_tab_docs(tab, group_prefix=group.display_name))
return "\n".join(lines)
def _generate_tab_docs(tab, group_prefix: Optional[str] = None) -> List[str]:
"""Generate documentation for a single settings tab."""
from shelfmark.core.settings_registry import ActionButton, CustomComponentField, HeadingField
lines = []
# Section header
if group_prefix:
lines.append(f"### {group_prefix}: {tab.display_name}")
anchor_id = f"{group_prefix}-{tab.display_name}".lower().replace(" ", "-")
else:
lines.append(f"## {tab.display_name}")
lines.append("")
# Collect env-supported fields
env_fields = []
for field in tab.fields:
# Skip non-value fields
if isinstance(field, (ActionButton, CustomComponentField, HeadingField)):
continue
# Skip fields that don't support ENV vars
if not getattr(field, "env_supported", True):
continue
env_fields.append(field)
if not env_fields:
lines.append("_No environment variables for this section._")
lines.append("")
return lines
# Generate table
lines.append("| Variable | Description | Type | Default |")
lines.append("|----------|-------------|------|---------|")
for field in env_fields:
env_var = field.get_env_var_name()
description = field.description or field.label
# Clean up description for table (remove newlines, escape pipes)
description = description.replace("\n", " ").replace("|", "\\|").strip()
field_type = get_field_type_name(field)
default = format_default_value(field)
lines.append(f"| `{env_var}` | {description} | {field_type} | {default} |")
lines.append("")
# Add detailed documentation for each field
lines.append("<details>")
lines.append("<summary>Detailed descriptions</summary>")
lines.append("")
for field in env_fields:
env_var = field.get_env_var_name()
lines.append(f"#### `{env_var}`")
lines.append("")
lines.append(f"**{field.label}**")
lines.append("")
if field.description:
lines.append(field.description)
lines.append("")
lines.append(f"- **Type:** {get_field_type_name(field)}")
lines.append(f"- **Default:** {format_default_value(field)}")
if getattr(field, "required", False):
lines.append("- **Required:** Yes")
if getattr(field, "requires_restart", False):
lines.append("- **Requires restart:** Yes")
# Show options for SelectField
options = get_select_options(field)
if options:
lines.append(f"- **Options:** {', '.join(options)}")
# Show constraints for NumberField
from shelfmark.core.settings_registry import NumberField
if isinstance(field, NumberField):
constraints = []
if field.min_value is not None:
constraints.append(f"min: {field.min_value}")
if field.max_value is not None:
constraints.append(f"max: {field.max_value}")
if constraints:
lines.append(f"- **Constraints:** {', '.join(constraints)}")
lines.append("")
lines.append("</details>")
lines.append("")
return lines
def main():
parser = argparse.ArgumentParser(
description="Generate markdown documentation for environment variables"
)
parser.add_argument(
"--output",
"-o",
type=Path,
default=project_root / "docs" / "environment-variables.md",
help="Output file path (default: docs/environment-variables.md)",
)
parser.add_argument(
"--stdout",
action="store_true",
help="Print to stdout instead of file",
)
args = parser.parse_args()
docs = generate_env_docs()
if args.stdout:
print(docs)
else:
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(docs)
print(f"Generated: {args.output}")
if __name__ == "__main__":
main()
+163 -39
View File
@@ -23,9 +23,10 @@ Web UIs:
- Deluge: http://localhost:8112
- NZBGet: http://localhost:6789
- SABnzbd: http://localhost:8085
- rTorrent: http://localhost:8000 (web ui http://localhost:8089 via ruTorrent)
Prerequisites (for running this script locally):
pip install requests transmission-rpc deluge-client qbittorrent-api
pip install requests transmission-rpc qbittorrent-api
First-Time Setup:
qBittorrent:
@@ -37,10 +38,7 @@ First-Time Setup:
- No setup needed, credentials pre-configured (admin/admin)
Deluge:
1. Access Web UI at http://localhost:8112 (default password: deluge)
2. Add auth line to .local/test-clients/deluge/config/auth:
echo "admin:admin:10" >> .local/test-clients/deluge/config/auth
3. Restart: docker restart test-deluge
- Access Web UI at http://localhost:8112 (default password: deluge)
NZBGet:
- No setup needed, credentials pre-configured (admin/admin)
@@ -53,6 +51,7 @@ First-Time Setup:
import sys
import time
from xmlrpc import client
# Test configuration - matches docker-compose.test-clients.yml
CONFIG = {
@@ -78,10 +77,11 @@ CONFIG = {
"password": "admin",
},
"deluge": {
"host": "localhost",
"port": 58846,
"username": "admin",
"password": "admin",
"url": "http://localhost:8112",
"password": "deluge",
},
"rtorrent": {
"url": "http://localhost:8000/RPC2",
},
}
@@ -325,46 +325,79 @@ def test_transmission():
def test_deluge():
"""Test Deluge connection."""
"""Test Deluge Web UI (JSON-RPC) connection."""
import requests
print("\n" + "=" * 50)
print("Testing Deluge")
print("=" * 50)
base_url = CONFIG["deluge"]["url"].rstrip("/")
password = CONFIG["deluge"]["password"]
rpc_url = f"{base_url}/json"
def rpc_call(session: requests.Session, rpc_id: int, method: str, *params):
payload = {"id": rpc_id, "method": method, "params": list(params)}
resp = session.post(rpc_url, json=payload, timeout=10)
resp.raise_for_status()
data = resp.json()
if data.get("error"):
err = data["error"]
if isinstance(err, dict):
raise Exception(err.get("message") or str(err))
raise Exception(str(err))
return data.get("result")
try:
from deluge_client import DelugeRPCClient
session = requests.Session()
client = DelugeRPCClient(
host=CONFIG["deluge"]["host"],
port=CONFIG["deluge"]["port"],
username=CONFIG["deluge"]["username"],
password=CONFIG["deluge"]["password"],
)
# Authenticate to Deluge Web
if rpc_call(session, 1, "auth.login", password) is not True:
raise Exception("Authentication failed (check Deluge Web UI password)")
# Test connection
client.connect()
version = client.call("daemon.info")
# Ensure Deluge Web is connected to a daemon
if rpc_call(session, 2, "web.connected") is not True:
hosts = rpc_call(session, 3, "web.get_hosts") or []
if not hosts:
raise Exception(
"Deluge Web UI isn't connected to Deluge core (no hosts configured). "
"Add/connect a daemon in Deluge Web UI → Connection Manager."
)
host_id = hosts[0][0]
for entry in hosts:
if isinstance(entry, list) and len(entry) >= 2 and entry[1] in {"127.0.0.1", "localhost"}:
host_id = entry[0]
break
rpc_call(session, 4, "web.connect", host_id)
if rpc_call(session, 5, "web.connected") is not True:
raise Exception(
"Deluge Web UI couldn't connect to Deluge core. "
"Check Deluge Web UI → Connection Manager."
)
version = rpc_call(session, 6, "daemon.info")
print(f" Connected to Deluge {version}")
# Get torrent list
torrents = client.call("core.get_torrents_status", {}, ["name"])
torrents = rpc_call(session, 7, "core.get_torrents_status", {}, ["name"]) or {}
print(f" Active torrents: {len(torrents)}")
# Test adding a torrent (then remove it)
print(" Testing add/remove torrent...")
torrent_id = client.call("core.add_torrent_magnet", TEST_MAGNET, {"add_paused": True})
torrent_id = rpc_call(session, 8, "core.add_torrent_magnet", TEST_MAGNET, {"add_paused": True})
if torrent_id:
torrent_id = str(torrent_id)
print(f" Added test torrent: {torrent_id[:20]}...")
# Get status
status = client.call("core.get_torrent_status", torrent_id, ["state", "progress"])
state = status.get(b"state", b"unknown")
if isinstance(state, bytes):
state = state.decode()
print(f" Status: {state}")
status = rpc_call(session, 9, "core.get_torrent_status", torrent_id, ["state", "progress"]) or {}
state = status.get("state", "unknown") if isinstance(status, dict) else "unknown"
progress = status.get("progress", 0) if isinstance(status, dict) else 0
print(f" Status: {state} ({progress:.1f}%)")
# Remove it
client.call("core.remove_torrent", torrent_id, True)
rpc_call(session, 10, "core.remove_torrent", torrent_id, True)
print(" Removed test torrent")
else:
print(" WARNING: Could not add test torrent")
@@ -372,19 +405,109 @@ def test_deluge():
print(" SUCCESS: Deluge is working!")
return True
except requests.exceptions.ConnectionError:
print(" ERROR: Could not connect to Deluge Web UI")
print(" Is the container running? docker ps | grep deluge")
return False
except requests.exceptions.Timeout:
print(" ERROR: Deluge Web UI connection timed out")
return False
except Exception as e:
print(f" ERROR: {e}")
if "auth" in str(e).lower() or "login" in str(e).lower():
print(" Check Deluge Web UI password (default: deluge)")
return False
def test_rtorrent():
"""Test rTorrent connection."""
print("\n" + "=" * 50)
print("Testing rTorrent")
print("=" * 50)
try:
import xmlrpc.client
url = "http://localhost:8000/RPC2"
client = xmlrpc.client.ServerProxy(url)
# Test connection
version = client.system.library_version()
print(f" Connected to rTorrent {version}")
# default download directory test
default_dir = client.directory.default()
print(f" Default download directory: {default_dir}")
# Get torrent list
torrents = client.download_list()
print(f" Active torrents: {len(torrents)}")
# Test adding a torrent (then remove it)
print(" Testing add/remove torrent...")
label = "automated"
commands = []
if label:
commands.append(f"d.custom1.set={label}")
download_dir = "/downloads"
if download_dir:
commands.append(f"d.directory_base.set={download_dir}")
# rtorrent is weird in that it doesn't return the torrent ID/hash on add
client.load.start("", TEST_MAGNET, ";".join(commands))
# but we know that it is 3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0 from the magnet link
torrent_id = "3B245504CF5F11BBDBE1201CEA6A6BF45AEE1BC0" # rtorrent uses uppercase hashes
print(f" Added test torrent: {torrent_id}")
torrents = client.download_list()
print(f" Active torrents: {len(torrents)}")
torrent_list = client.d.multicall.filtered(
"",
"default",
f"equal={{d.hash=,cat={torrent_id}}}"
"d.hash=",
"d.state=",
"d.completed_bytes=",
"d.size_bytes=",
"d.down.rate=",
"d.up.rate=",
"d.custom1=",
"d.complete=",
)
torrent = torrent_list[0]
if not torrent:
print(" ERROR: Could not find added torrent in list")
return False
# let's test the base path call
details = client.d.multicall.filtered(
"",
"default",
f"equal=d.hash=,cat={torrent_id}",
"d.base_path=",
)
base_path = details[0][0] if details else None
print(f" Base path: {base_path}")
client.d.erase(torrent_id)
print(" Removed test torrent")
print(" SUCCESS: rTorrent is working!")
return True
except ImportError:
print(" ERROR: deluge-client not installed")
print(" Run: pip install deluge-client")
print(" ERROR: xmlrpc.client not available")
return False
except Exception as e:
print(f" ERROR: {e}")
if "Connection refused" in str(e):
print(" Is the container running? docker ps | grep deluge")
elif "Bad login" in str(e) or "auth" in str(e).lower():
print("\n Deluge auth setup required:")
print(" 1. Add 'admin:admin:10' to .local/test-clients/deluge/config/auth")
print(" 2. Restart: docker restart test-deluge")
print(" 3. Or access Web UI at http://localhost:8112 (password: deluge)")
print(" Is the container running? docker ps | grep rtorrent")
return False
@@ -410,6 +533,7 @@ def main():
results["qbittorrent"] = test_qbittorrent()
results["transmission"] = test_transmission()
results["deluge"] = test_deluge()
results["rtorrent"] = test_rtorrent()
# Summary
print("\n" + "=" * 50)
+85 -8
View File
@@ -4,7 +4,7 @@ import logging
import threading
from typing import Optional, Dict, Any, Callable, List
from flask_socketio import SocketIO
from flask_socketio import SocketIO, join_room, leave_room
logger = logging.getLogger(__name__)
@@ -20,6 +20,10 @@ class WebSocketManager:
self._on_first_connect_callbacks: List[Callable[[], None]] = []
self._on_all_disconnect_callbacks: List[Callable[[], None]] = []
self._needs_rewarm = False # Flag to trigger warmup callbacks on next connect
self._user_rooms: Dict[str, int] = {} # room_name -> ref count
self._sid_rooms: Dict[str, str] = {} # sid -> room_name
self._rooms_lock = threading.Lock()
self._queue_status_fn: Optional[Callable] = None # Reference to queue_status()
def init_app(self, app, socketio: SocketIO):
"""Initialize the WebSocket manager with Flask-SocketIO instance."""
@@ -100,19 +104,86 @@ class WebSocketManager:
"""Check if WebSocket is enabled and ready."""
return self._enabled and self.socketio is not None
def set_queue_status_fn(self, fn: Callable):
"""Set the queue_status function reference for per-room filtering."""
self._queue_status_fn = fn
def _increment_user_room_locked(self, room: str):
self._user_rooms[room] = self._user_rooms.get(room, 0) + 1
def _decrement_user_room_locked(self, room: str):
count = self._user_rooms.get(room, 1) - 1
if count <= 0:
self._user_rooms.pop(room, None)
else:
self._user_rooms[room] = count
def _set_sid_room_locked(self, sid: str, room: Optional[str]):
current_room = self._sid_rooms.get(sid)
if current_room == room:
return
if current_room is not None:
leave_room(current_room, sid=sid)
if current_room.startswith("user_"):
self._decrement_user_room_locked(current_room)
self._sid_rooms.pop(sid, None)
if room is not None:
join_room(room, sid=sid)
self._sid_rooms[sid] = room
if room.startswith("user_"):
self._increment_user_room_locked(room)
def sync_user_room(self, sid: str, is_admin: bool, db_user_id: Optional[int] = None):
"""Ensure a SID is in exactly one room matching the current session scope."""
room: Optional[str] = None
if is_admin:
room = "admins"
elif db_user_id is not None:
room = f"user_{db_user_id}"
with self._rooms_lock:
self._set_sid_room_locked(sid, room)
def join_user_room(self, sid: str, is_admin: bool, db_user_id: Optional[int] = None):
"""Join the appropriate room based on user role."""
self.sync_user_room(sid, is_admin, db_user_id)
def leave_user_room(self, sid: str, is_admin: bool = False, db_user_id: Optional[int] = None):
"""Leave whichever room the SID currently belongs to."""
del is_admin, db_user_id # Backward-compatible signature; routing is SID-based.
with self._rooms_lock:
self._set_sid_room_locked(sid, None)
def broadcast_status_update(self, status_data: Dict[str, Any]):
"""Broadcast status update to all connected clients."""
"""Broadcast status update to all connected clients, filtered by user room."""
if not self.is_enabled():
return
try:
# When calling socketio.emit() outside event handlers, it broadcasts by default
self.socketio.emit('status_update', status_data)
logger.debug(f"Broadcasted status update to all clients")
# Admins (and no-auth users) get full status
self.socketio.emit('status_update', status_data, to="admins")
# Each user room gets filtered status
with self._rooms_lock:
active_rooms = list(self._user_rooms.keys())
if active_rooms and self._queue_status_fn:
for room in active_rooms:
try:
# Extract user_id from room name "user_123"
uid = int(room.split("_", 1)[1])
filtered = self._queue_status_fn(user_id=uid)
self.socketio.emit('status_update', filtered, to=room)
except Exception as e:
logger.error(f"Failed to send status update for room {room}: {e}")
logger.debug("Broadcasted status update to all rooms")
except Exception as e:
logger.error(f"Error broadcasting status update: {e}")
def broadcast_download_progress(self, book_id: str, progress: float, status: str):
def broadcast_download_progress(self, book_id: str, progress: float, status: str, user_id: Optional[int] = None):
"""Broadcast download progress update for a specific book."""
if not self.is_enabled():
return
@@ -123,8 +194,14 @@ class WebSocketManager:
'progress': progress,
'status': status
}
# When calling socketio.emit() outside event handlers, it broadcasts by default
self.socketio.emit('download_progress', data)
# Admins always see all progress
self.socketio.emit('download_progress', data, to="admins")
# If task belongs to a specific user, send to their room too
if user_id is not None:
room = f"user_{user_id}"
with self._rooms_lock:
if room in self._user_rooms:
self.socketio.emit('download_progress', data, to=room)
logger.debug(f"Broadcasted progress for book {book_id}: {progress}%")
except Exception as e:
logger.error(f"Error broadcasting download progress: {e}")
+6 -2
View File
@@ -10,6 +10,8 @@ import requests
from shelfmark.bypass import BypassCancelledException
from shelfmark.core.config import config
from shelfmark.core.logger import setup_logger
from shelfmark.core.utils import normalize_http_url
from shelfmark.download.network import get_ssl_verify
if TYPE_CHECKING:
from shelfmark.download import network
@@ -29,10 +31,11 @@ BACKOFF_CAP = 10.0
def _fetch_via_bypasser(target_url: str) -> Optional[str]:
"""Make a single request to the external bypasser service. Returns HTML or None."""
bypasser_url = config.get("EXT_BYPASSER_URL", "http://flaresolverr:8191")
raw_bypasser_url = config.get("EXT_BYPASSER_URL", "http://flaresolverr:8191")
bypasser_path = config.get("EXT_BYPASSER_PATH", "/v1")
bypasser_timeout = config.get("EXT_BYPASSER_TIMEOUT", 60000)
bypasser_url = normalize_http_url(raw_bypasser_url)
if not bypasser_url or not bypasser_path:
logger.error("External bypasser not configured. Check EXT_BYPASSER_URL and EXT_BYPASSER_PATH.")
return None
@@ -44,7 +47,8 @@ def _fetch_via_bypasser(target_url: str) -> Optional[str]:
f"{bypasser_url}{bypasser_path}",
headers={"Content-Type": "application/json"},
json={"cmd": "request.get", "url": target_url, "maxTimeout": bypasser_timeout},
timeout=(CONNECT_TIMEOUT, read_timeout)
timeout=(CONNECT_TIMEOUT, read_timeout),
verify=get_ssl_verify(bypasser_url),
)
response.raise_for_status()
result = response.json()
File diff suppressed because it is too large Load Diff
+197
View File
@@ -0,0 +1,197 @@
from __future__ import annotations
from typing import Any
from shelfmark.core.config import config
from shelfmark.core.logger import setup_logger
from shelfmark.download.outputs.booklore import (
BookloreConfig,
BookloreError,
booklore_list_libraries,
booklore_login,
)
logger = setup_logger(__name__)
_BOOKLORE_OPTIONS_CACHE: dict[str, Any] = {
"key": None,
"library_options": [],
"path_options": [],
}
def _get_booklore_cache_key(base_url: str, username: str, password: str) -> str:
return f"{base_url}|{username}|{hash(password)}"
def _get_booklore_select_options(
base_url: str,
username: str,
password: str,
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
# library_id/path_id are not used for login/library listing
booklore_config = BookloreConfig(
base_url=base_url.rstrip("/"),
username=username,
password=password,
library_id=1,
path_id=1,
verify_tls=True,
refresh_after_upload=True,
)
token = booklore_login(booklore_config)
libraries = booklore_list_libraries(booklore_config, token) or []
logger.debug("Booklore libraries response: %s", libraries)
library_options: list[dict[str, Any]] = []
path_options: list[dict[str, Any]] = []
for library in libraries:
if not isinstance(library, dict):
continue
library_id = library.get("id")
if library_id is None:
continue
library_name = str(library.get("name") or f"Library {library_id}")
library_id_str = str(library_id)
library_options.append({"value": library_id_str, "label": library_name})
paths = library.get("paths") or []
if not isinstance(paths, list):
continue
for path in paths:
if not isinstance(path, dict):
continue
path_id = path.get("id")
if path_id is None:
continue
path_label = str(path.get("path") or f"Path {path_id}")
path_options.append(
{
"value": str(path_id),
"label": f"{library_name}: {path_label}",
"childOf": library_id_str,
}
)
logger.debug(
"Booklore options built: libraries=%d paths=%d",
len(library_options),
len(path_options),
)
cache_key = _get_booklore_cache_key(base_url, username, password)
_BOOKLORE_OPTIONS_CACHE.update(
{
"key": cache_key,
"library_options": library_options,
"path_options": path_options,
}
)
return library_options, path_options
def _get_booklore_cached_options(
base_url: str,
username: str,
password: str,
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
cache_key = _get_booklore_cache_key(base_url, username, password)
if _BOOKLORE_OPTIONS_CACHE.get("key") == cache_key:
return (
_BOOKLORE_OPTIONS_CACHE.get("library_options", []),
_BOOKLORE_OPTIONS_CACHE.get("path_options", []),
)
return _get_booklore_select_options(base_url, username, password)
def get_booklore_library_options() -> list[dict[str, Any]]:
"""Build Booklore library options dynamically from config."""
if config.get("BOOKS_OUTPUT_MODE", "folder") != "booklore":
return []
base_url = str(config.get("BOOKLORE_HOST", "") or "").strip().rstrip("/")
username = str(config.get("BOOKLORE_USERNAME", "") or "").strip()
password = config.get("BOOKLORE_PASSWORD", "") or ""
if not base_url or not username or not password:
return []
cache_key = _get_booklore_cache_key(base_url, username, password)
try:
library_options, _ = _get_booklore_cached_options(base_url, username, password)
return library_options
except Exception as exc:
logger.error(f"Failed to fetch Booklore libraries: {exc}")
if _BOOKLORE_OPTIONS_CACHE.get("key") == cache_key:
return _BOOKLORE_OPTIONS_CACHE.get("library_options", [])
return []
def get_booklore_path_options() -> list[dict[str, Any]]:
"""Build Booklore path options dynamically from config."""
if config.get("BOOKS_OUTPUT_MODE", "folder") != "booklore":
return []
base_url = str(config.get("BOOKLORE_HOST", "") or "").strip().rstrip("/")
username = str(config.get("BOOKLORE_USERNAME", "") or "").strip()
password = config.get("BOOKLORE_PASSWORD", "") or ""
if not base_url or not username or not password:
return []
cache_key = _get_booklore_cache_key(base_url, username, password)
try:
_, path_options = _get_booklore_cached_options(base_url, username, password)
return path_options
except Exception as exc:
logger.error(f"Failed to fetch Booklore paths: {exc}")
if _BOOKLORE_OPTIONS_CACHE.get("key") == cache_key:
return _BOOKLORE_OPTIONS_CACHE.get("path_options", [])
return []
def test_booklore_connection(current_values: dict[str, Any] | None = None) -> dict[str, Any]:
"""Test the Booklore connection using current form values."""
current_values = current_values or {}
def _get_value(key: str, default: Any = None) -> Any:
value = current_values.get(key)
if value not in (None, ""):
return value
if default is None:
return config.get(key)
return config.get(key, default)
base_url = str(_get_value("BOOKLORE_HOST", "") or "").strip().rstrip("/")
username = str(_get_value("BOOKLORE_USERNAME", "") or "").strip()
password = _get_value("BOOKLORE_PASSWORD", "") or ""
if not base_url:
return {"success": False, "message": "Grimmory URL is required"}
if not username:
return {"success": False, "message": "Grimmory username is required"}
if not password:
return {"success": False, "message": "Grimmory password is required"}
try:
library_options, _ = _get_booklore_select_options(base_url, username, password)
message = "Connected to Grimmory"
if library_options:
message = f"Connected to Grimmory ({len(library_options)} libraries)"
return {"success": True, "message": message}
except BookloreError as exc:
return {"success": False, "message": str(exc)}
+42
View File
@@ -0,0 +1,42 @@
from __future__ import annotations
from typing import Any
from shelfmark.core.config import config
from shelfmark.download.outputs.email import EmailOutputError, build_email_smtp_config, test_smtp_connection
def test_email_connection(current_values: dict[str, Any] | None = None) -> dict[str, Any]:
"""Test SMTP connectivity using current form values (including unsaved changes)."""
current_values = current_values or {}
def _get_value(key: str, default: Any = None) -> Any:
value = current_values.get(key)
if value not in (None, ""):
return value
if default is None:
return config.get(key)
return config.get(key, default)
settings = {
"EMAIL_SMTP_HOST": _get_value("EMAIL_SMTP_HOST", ""),
"EMAIL_SMTP_PORT": _get_value("EMAIL_SMTP_PORT", 587),
"EMAIL_SMTP_SECURITY": _get_value("EMAIL_SMTP_SECURITY", "starttls"),
"EMAIL_SMTP_USERNAME": _get_value("EMAIL_SMTP_USERNAME", ""),
"EMAIL_SMTP_PASSWORD": _get_value("EMAIL_SMTP_PASSWORD", ""),
"EMAIL_FROM": _get_value("EMAIL_FROM", ""),
"EMAIL_SUBJECT_TEMPLATE": _get_value("EMAIL_SUBJECT_TEMPLATE", "{Title}"),
"EMAIL_SMTP_TIMEOUT_SECONDS": _get_value("EMAIL_SMTP_TIMEOUT_SECONDS", 60),
"EMAIL_ALLOW_UNVERIFIED_TLS": _get_value("EMAIL_ALLOW_UNVERIFIED_TLS", False),
}
try:
smtp_config = build_email_smtp_config(settings)
test_smtp_connection(smtp_config)
return {"success": True, "message": "Connected to SMTP server"}
except EmailOutputError as exc:
return {"success": False, "message": str(exc)}
except Exception as exc:
return {"success": False, "message": f"SMTP test failed: {exc}"}
+11
View File
@@ -113,7 +113,10 @@ FLASK_PORT = int(os.getenv("FLASK_PORT", "8084"))
# =============================================================================
SESSION_COOKIE_SECURE_ENV = os.getenv("SESSION_COOKIE_SECURE", "false")
SESSION_COOKIE_NAME = "shelfmark_session"
CWA_DB_PATH = _resolve_cwa_db_path()
HIDE_LOCAL_AUTH = string_to_bool(os.getenv("HIDE_LOCAL_AUTH", "false"))
OIDC_AUTO_REDIRECT = string_to_bool(os.getenv("OIDC_AUTO_REDIRECT", "false"))
# =============================================================================
@@ -133,6 +136,14 @@ TOR_VARIANT_AVAILABLE = shutil.which("tor") is not None
USING_TOR = string_to_bool(os.getenv("USING_TOR", "false"))
# =============================================================================
# Onboarding
# =============================================================================
# Set to false to skip the onboarding wizard entirely (useful for ephemeral storage)
ONBOARDING = string_to_bool(os.getenv("ONBOARDING", "true"))
# =============================================================================
# Debug/development settings
# =============================================================================
+135
View File
@@ -0,0 +1,135 @@
"""Configuration migration helpers."""
import json
from typing import Any, Callable
_DEPRECATED_SETTINGS_RESTRICTION_KEYS = (
"PROXY_AUTH_RESTRICT_SETTINGS_TO_ADMIN",
"CWA_RESTRICT_SETTINGS_TO_ADMIN",
"RESTRICT_SETTINGS_TO_ADMIN",
)
def _as_bool(value: Any) -> bool:
if isinstance(value, bool):
return value
if isinstance(value, str):
return value.strip().lower() in {"1", "true", "yes", "on"}
return bool(value)
def _pick_legacy_settings_restriction(config: dict[str, Any]) -> bool | None:
"""Pick the best legacy admin-restriction value to migrate."""
auth_method = str(config.get("AUTH_METHOD", "")).strip().lower()
if (
auth_method == "proxy"
and "PROXY_AUTH_RESTRICT_SETTINGS_TO_ADMIN" in config
):
return _as_bool(config.get("PROXY_AUTH_RESTRICT_SETTINGS_TO_ADMIN"))
if auth_method == "cwa" and "CWA_RESTRICT_SETTINGS_TO_ADMIN" in config:
return _as_bool(config.get("CWA_RESTRICT_SETTINGS_TO_ADMIN"))
if "RESTRICT_SETTINGS_TO_ADMIN" in config:
return _as_bool(config.get("RESTRICT_SETTINGS_TO_ADMIN"))
if "PROXY_AUTH_RESTRICT_SETTINGS_TO_ADMIN" in config:
return _as_bool(config.get("PROXY_AUTH_RESTRICT_SETTINGS_TO_ADMIN"))
if "CWA_RESTRICT_SETTINGS_TO_ADMIN" in config:
return _as_bool(config.get("CWA_RESTRICT_SETTINGS_TO_ADMIN"))
return None
def migrate_security_settings(
*,
load_security_config: Callable[[], dict[str, Any]],
load_users_config: Callable[[], dict[str, Any]],
save_users_config: Callable[[dict[str, Any]], None],
ensure_config_dir: Callable[[], None],
get_config_path: Callable[[], Any],
sync_builtin_admin_user: Callable[[str, str], None],
logger: Any,
) -> None:
"""Migrate legacy security keys and sync builtin admin credentials."""
try:
config = load_security_config()
users_config = load_users_config()
migrated_security = False
migrated_users = False
if "USE_CWA_AUTH" in config:
old_value = config.pop("USE_CWA_AUTH")
if "AUTH_METHOD" not in config:
if old_value:
config["AUTH_METHOD"] = "cwa"
logger.info("Migrated USE_CWA_AUTH=True to AUTH_METHOD='cwa'")
else:
if config.get("BUILTIN_USERNAME") and config.get("BUILTIN_PASSWORD_HASH"):
config["AUTH_METHOD"] = "builtin"
logger.info("Migrated USE_CWA_AUTH=False to AUTH_METHOD='builtin'")
else:
config["AUTH_METHOD"] = "none"
logger.info("Migrated USE_CWA_AUTH=False to AUTH_METHOD='none'")
migrated_security = True
else:
logger.info("Removed deprecated USE_CWA_AUTH setting (AUTH_METHOD already exists)")
migrated_security = True
# Backfill AUTH_METHOD for configs that have builtin credentials but
# were never migrated from USE_CWA_AUTH (e.g. dev builds that predated
# the AUTH_METHOD field).
if "AUTH_METHOD" not in config:
if config.get("BUILTIN_USERNAME") and config.get("BUILTIN_PASSWORD_HASH"):
config["AUTH_METHOD"] = "builtin"
migrated_security = True
logger.info(
"Backfilled AUTH_METHOD='builtin' from legacy "
"BUILTIN_USERNAME/BUILTIN_PASSWORD_HASH credentials"
)
if "RESTRICT_SETTINGS_TO_ADMIN" not in users_config:
legacy_restrict = _pick_legacy_settings_restriction(config)
if legacy_restrict is not None:
save_users_config({"RESTRICT_SETTINGS_TO_ADMIN": legacy_restrict})
migrated_users = True
logger.info(
"Migrated legacy settings-admin restriction to users.RESTRICT_SETTINGS_TO_ADMIN="
f"{legacy_restrict}"
)
for deprecated_key in _DEPRECATED_SETTINGS_RESTRICTION_KEYS:
if deprecated_key in config:
config.pop(deprecated_key, None)
migrated_security = True
logger.info(f"Removed deprecated security setting: {deprecated_key}")
try:
sync_builtin_admin_user(
config.get("BUILTIN_USERNAME", ""),
config.get("BUILTIN_PASSWORD_HASH", ""),
)
except Exception as exc:
logger.error(
"Failed to sync builtin credentials to users database during migration: "
f"{exc}"
)
if migrated_security:
ensure_config_dir()
config_path = get_config_path()
with open(config_path, "w") as f:
json.dump(config, f, indent=2)
logger.info("Security settings migration completed successfully")
elif migrated_users:
logger.info("Users settings migration completed successfully")
else:
logger.debug("No security settings migration needed")
except FileNotFoundError:
logger.debug("No existing security config file found - nothing to migrate")
except Exception as exc:
logger.error(f"Failed to migrate security settings: {exc}")
+337
View File
@@ -0,0 +1,337 @@
"""Notifications settings tab registration."""
from __future__ import annotations
import re
from typing import Any
from urllib.parse import urlsplit
from shelfmark.core.notifications import NotificationEvent, send_test_notification
from shelfmark.core.settings_registry import (
ActionButton,
HeadingField,
TableField,
load_config_file,
register_on_save,
register_settings,
)
_URL_SCHEME_RE = re.compile(r"^[a-zA-Z][a-zA-Z0-9+.-]*$")
_ROUTE_EVENT_ALL = "all"
_ADMIN_EVENT_OPTIONS = [
{"value": NotificationEvent.REQUEST_CREATED.value, "label": "New request submitted"},
{"value": NotificationEvent.REQUEST_FULFILLED.value, "label": "Request approved"},
{"value": NotificationEvent.REQUEST_REJECTED.value, "label": "Request rejected"},
{"value": NotificationEvent.DOWNLOAD_COMPLETE.value, "label": "Download complete"},
{"value": NotificationEvent.DOWNLOAD_FAILED.value, "label": "Download failed"},
]
_ROUTE_EVENT_OPTIONS = [
{"value": _ROUTE_EVENT_ALL, "label": "All"},
*_ADMIN_EVENT_OPTIONS,
]
_ROUTE_EVENT_ORDER = [option["value"] for option in _ROUTE_EVENT_OPTIONS]
_ROUTE_EVENT_INDEX = {event: index for index, event in enumerate(_ROUTE_EVENT_ORDER)}
_ALLOWED_ROUTE_EVENTS = set(_ROUTE_EVENT_ORDER)
_DEFAULT_ROUTE_ROWS = [{"event": [_ROUTE_EVENT_ALL], "url": ""}]
def _looks_like_apprise_url(url: str) -> bool:
split = urlsplit(url)
if not split.scheme:
return False
if not _URL_SCHEME_RE.match(split.scheme):
return False
return " " not in url
def _coerce_route_rows(value: Any) -> list[dict[str, Any]]:
if value is None:
return []
if isinstance(value, list):
return [row for row in value if isinstance(row, dict)]
if isinstance(value, dict):
return [value]
return []
def _coerce_route_event_values(value: Any) -> list[Any]:
if isinstance(value, list):
return value
if isinstance(value, (tuple, set)):
return list(value)
return [value]
def _normalize_route_events(value: Any) -> list[str]:
normalized: list[str] = []
seen: set[str] = set()
for raw_event in _coerce_route_event_values(value):
event = str(raw_event or "").strip().lower()
if not event or event not in _ALLOWED_ROUTE_EVENTS:
continue
if event in seen:
continue
seen.add(event)
normalized.append(event)
if _ROUTE_EVENT_ALL in seen:
return [_ROUTE_EVENT_ALL]
return sorted(normalized, key=lambda event: _ROUTE_EVENT_INDEX[event])
def _normalize_routes(value: Any) -> list[dict[str, Any]]:
normalized: list[dict[str, Any]] = []
seen: set[tuple[tuple[str, ...], str]] = set()
for row in _coerce_route_rows(value):
events = _normalize_route_events(row.get("event"))
if not events:
continue
url = str(row.get("url") or "").strip()
key = (tuple(events), url)
if key in seen:
continue
seen.add(key)
normalized.append({"event": events, "url": url})
return normalized
def _count_invalid_route_events(value: Any) -> int:
invalid = 0
for row in _coerce_route_rows(value):
raw_events = _coerce_route_event_values(row.get("event"))
if not raw_events:
invalid += 1
continue
for raw_event in raw_events:
event = str(raw_event or "").strip().lower()
if not event or event not in _ALLOWED_ROUTE_EVENTS:
invalid += 1
return invalid
def _count_invalid_route_urls(routes: list[dict[str, Any]]) -> int:
return sum(1 for row in routes if row["url"] and not _looks_like_apprise_url(row["url"]))
def _ensure_default_route_row(routes: list[dict[str, Any]]) -> list[dict[str, Any]]:
return routes if routes else [dict(row) for row in _DEFAULT_ROUTE_ROWS]
def _extract_unique_route_urls(routes: list[dict[str, Any]]) -> list[str]:
urls: list[str] = []
seen: set[str] = set()
for row in routes:
url = row.get("url", "")
if not url:
continue
if url in seen:
continue
seen.add(url)
urls.append(url)
return urls
def build_notification_test_result(routes_input: Any, *, scope_label: str) -> dict[str, Any]:
invalid_event_count = _count_invalid_route_events(routes_input)
if invalid_event_count:
return {
"success": False,
"message": (
f"Found {invalid_event_count} invalid {scope_label} notification route event value(s). "
"Fix route events before running a test."
),
}
normalized_routes = _normalize_routes(routes_input)
invalid_url_count = _count_invalid_route_urls(normalized_routes)
if invalid_url_count:
return {
"success": False,
"message": (
f"Found {invalid_url_count} invalid {scope_label} notification URL(s). "
"Fix route URLs before running a test."
),
}
urls = _extract_unique_route_urls(normalized_routes)
if not urls:
return {
"success": False,
"message": f"Add at least one {scope_label} notification URL route first.",
}
return send_test_notification(urls)
def normalize_notification_routes(value: Any) -> list[dict[str, Any]]:
"""Normalize route table rows for notification preferences."""
return _normalize_routes(value)
def is_valid_notification_url(url: str) -> bool:
"""Shared URL validation for notifications preferences."""
return _looks_like_apprise_url(url)
def _on_save_notifications(values: dict[str, Any]) -> dict[str, Any]:
existing = load_config_file("notifications")
effective: dict[str, Any] = dict(existing)
effective.update(values)
admin_routes_input = effective.get("ADMIN_NOTIFICATION_ROUTES", [])
invalid_admin_event_count = _count_invalid_route_events(admin_routes_input)
if invalid_admin_event_count:
return {
"error": True,
"message": (
f"Found {invalid_admin_event_count} invalid global notification route event value(s)."
),
"values": values,
}
normalized_admin_routes = _normalize_routes(admin_routes_input)
invalid_admin_url_count = _count_invalid_route_urls(normalized_admin_routes)
if invalid_admin_url_count:
return {
"error": True,
"message": (
f"Found {invalid_admin_url_count} invalid global notification URL(s). "
"Use URL values with a valid scheme, e.g. discord://... or ntfys://..."
),
"values": values,
}
user_routes_input = effective.get("USER_NOTIFICATION_ROUTES", [])
invalid_user_event_count = _count_invalid_route_events(user_routes_input)
if invalid_user_event_count:
return {
"error": True,
"message": (
f"Found {invalid_user_event_count} invalid personal notification route event value(s)."
),
"values": values,
}
normalized_user_routes = _normalize_routes(user_routes_input)
invalid_user_url_count = _count_invalid_route_urls(normalized_user_routes)
if invalid_user_url_count:
return {
"error": True,
"message": (
f"Found {invalid_user_url_count} invalid personal notification URL(s). "
"Use URL values with a valid scheme, e.g. discord://... or ntfys://..."
),
"values": values,
}
admin_routes_touched = "ADMIN_NOTIFICATION_ROUTES" in values
if admin_routes_touched:
values["ADMIN_NOTIFICATION_ROUTES"] = _ensure_default_route_row(normalized_admin_routes)
user_routes_touched = "USER_NOTIFICATION_ROUTES" in values
if user_routes_touched:
values["USER_NOTIFICATION_ROUTES"] = _ensure_default_route_row(normalized_user_routes)
return {"error": False, "values": values}
def _test_admin_notification_action(current_values: dict[str, Any]) -> dict[str, Any]:
persisted = load_config_file("notifications")
effective: dict[str, Any] = dict(persisted)
if isinstance(current_values, dict):
effective.update(current_values)
routes_input = effective.get("ADMIN_NOTIFICATION_ROUTES", [])
return build_notification_test_result(routes_input, scope_label="global")
register_on_save("notifications", _on_save_notifications)
@register_settings("notifications", "Notifications", icon="bell", order=7)
def notifications_settings():
"""Global notifications settings."""
return [
HeadingField(
key="notifications_heading",
title="Global Notifications",
description=(
"Global notifications send selected events for all users to configured routes. "
"Users can manage personal notifications in User Preferences."
),
),
TableField(
key="ADMIN_NOTIFICATION_ROUTES",
label="",
description=(
"Create one route per URL. Start with All, then add event-specific routes "
"for targeted delivery. Need format examples? "
"[View Apprise URL formats](https://appriseit.com/services/)."
),
columns=[
{
"key": "event",
"label": "Event",
"type": "multiselect",
"options": _ROUTE_EVENT_OPTIONS,
"defaultValue": [_ROUTE_EVENT_ALL],
"placeholder": "Select events...",
},
{
"key": "url",
"label": "Notification URL",
"type": "text",
"placeholder": "e.g. ntfys://ntfy.sh/shelfmark",
},
],
default=[dict(row) for row in _DEFAULT_ROUTE_ROWS],
add_label="Add Route",
empty_message="No routes configured.",
),
ActionButton(
key="test_admin_notification",
label="Test Notification",
description="Send a test notification to all configured global route URLs.",
style="primary",
callback=_test_admin_notification_action,
),
TableField(
key="USER_NOTIFICATION_ROUTES",
label="",
description=(
"Create one route per URL. Start with All, then add event-specific routes "
"for targeted delivery. Need format examples? "
"[View Apprise URL formats](https://appriseit.com/services/)."
),
columns=[
{
"key": "event",
"label": "Event",
"type": "multiselect",
"options": _ROUTE_EVENT_OPTIONS,
"defaultValue": [_ROUTE_EVENT_ALL],
"placeholder": "Select events...",
},
{
"key": "url",
"label": "Notification URL",
"type": "text",
"placeholder": "e.g. ntfys://ntfy.sh/username-topic",
},
],
default=[dict(row) for row in _DEFAULT_ROUTE_ROWS],
add_label="Add Route",
empty_message="No routes configured.",
user_overridable=True,
hidden_in_ui=True,
),
]
+235 -117
View File
@@ -1,100 +1,67 @@
"""Authentication settings registration."""
from typing import Any, Dict
from werkzeug.security import generate_password_hash
from typing import Any, Dict, Callable
from shelfmark.config.migrations import migrate_security_settings
from shelfmark.config.security_handlers import (
on_save_security,
test_oidc_connection,
)
from shelfmark.core.logger import setup_logger
from shelfmark.core.settings_registry import (
register_settings,
register_on_save,
load_config_file,
TextField,
SelectField,
PasswordField,
CheckboxField,
ActionButton,
TagListField,
CustomComponentField,
)
from shelfmark.core.user_db import sync_builtin_admin_user
logger = setup_logger(__name__)
def _clear_builtin_credentials() -> Dict[str, Any]:
"""Clear built-in credentials to allow public access."""
import json
from shelfmark.core.settings_registry import _get_config_file_path, _ensure_config_dir
def _auth_condition(auth_method: str) -> dict[str, str]:
return {"field": "AUTH_METHOD", "value": auth_method}
try:
config = load_config_file("security")
config.pop("BUILTIN_USERNAME", None)
config.pop("BUILTIN_PASSWORD_HASH", None)
_ensure_config_dir("security")
config_path = _get_config_file_path("security")
with open(config_path, 'w') as f:
json.dump(config, f, indent=2)
def _auth_field(factory: Callable[..., Any], auth_method: str, **kwargs: Any) -> Any:
return factory(show_when=_auth_condition(auth_method), **kwargs)
logger.info("Cleared credentials")
return {"success": True, "message": "Credentials cleared. The app is now publicly accessible."}
except Exception as e:
logger.error(f"Failed to clear credentials: {e}")
return {"success": False, "message": f"Failed to clear credentials: {str(e)}"}
def _migrate_security_settings() -> None:
from shelfmark.core.settings_registry import (
_get_config_file_path,
_ensure_config_dir,
save_config_file,
)
migrate_security_settings(
load_security_config=lambda: load_config_file("security"),
load_users_config=lambda: load_config_file("users"),
save_users_config=lambda values: save_config_file("users", values),
ensure_config_dir=lambda: _ensure_config_dir("security"),
get_config_path=lambda: _get_config_file_path("security"),
sync_builtin_admin_user=sync_builtin_admin_user,
logger=logger,
)
def _on_save_security(values: Dict[str, Any]) -> Dict[str, Any]:
"""
Custom save handler for security settings.
return on_save_security(values)
Handles password validation and hashing:
- If new password is provided, validate confirmation and hash it
- If password fields are empty, preserve existing hash
- Never store raw passwords
- Ensure username is present if password is set
Returns:
Dict with processed values to save and any validation errors.
"""
password = values.get("BUILTIN_PASSWORD", "")
password_confirm = values.get("BUILTIN_PASSWORD_CONFIRM", "")
# Remove raw password fields - they should never be persisted
values.pop("BUILTIN_PASSWORD", None)
values.pop("BUILTIN_PASSWORD_CONFIRM", None)
# If password is provided, validate and hash it
if password:
if not values.get("BUILTIN_USERNAME"):
return {
"error": True,
"message": "Username cannot be empty",
"values": values
}
if password != password_confirm:
return {
"error": True,
"message": "Passwords do not match",
"values": values
}
if len(password) < 4:
return {
"error": True,
"message": "Password must be at least 4 characters",
"values": values
}
# Hash the password
values["BUILTIN_PASSWORD_HASH"] = generate_password_hash(password)
logger.info("Password hash updated")
# If no password provided but username is being set, preserve existing hash
elif "BUILTIN_USERNAME" in values:
existing = load_config_file("security")
if "BUILTIN_PASSWORD_HASH" in existing:
values["BUILTIN_PASSWORD_HASH"] = existing["BUILTIN_PASSWORD_HASH"]
return {"error": False, "values": values}
def _test_oidc_connection(current_values: Dict[str, Any] = None) -> Dict[str, Any]:
return test_oidc_connection(
load_security_config=lambda: load_config_file("security"),
current_values=current_values or {},
logger=logger,
)
@register_settings("security", "Security", icon="shield", order=5)
@@ -104,63 +71,214 @@ def security_settings():
cwa_db_available = CWA_DB_PATH is not None and CWA_DB_PATH.exists()
auth_method_options = [
{"label": "No Authentication", "value": "none"},
{"label": "Local", "value": "builtin"},
{"label": "Proxy Authentication", "value": "proxy"},
{"label": "OIDC (OpenID Connect)", "value": "oidc"},
{"label": "Calibre-Web Database", "value": "cwa"},
]
fields = [
TextField(
key="BUILTIN_USERNAME",
label="Username",
description="Set a username and password to require login. Leave both empty for public access.",
placeholder="Enter username",
env_supported=False,
disabled_when={"field": "USE_CWA_AUTH", "value": True, "reason": "Using Calibre-Web database for authentication."},
SelectField(
key="AUTH_METHOD",
label="Authentication Method",
description="Select the authentication method for accessing Shelfmark.",
options=auth_method_options,
default="none",
),
PasswordField(
key="BUILTIN_PASSWORD",
label="Set Password",
description="Fill in to set or change the password.",
placeholder="Enter new password",
env_supported=False,
disabled_when={"field": "USE_CWA_AUTH", "value": True, "reason": "Using Calibre-Web database for authentication."},
CustomComponentField(
key="builtin_admin_requirement",
component="oidc_admin_hint",
label=(
"Local authentication is inactive until a local admin account with a "
"password is created."
),
show_when=_auth_condition("builtin"),
),
PasswordField(
key="BUILTIN_PASSWORD_CONFIRM",
label="Confirm Password",
placeholder="Confirm new password",
env_supported=False,
disabled_when={"field": "USE_CWA_AUTH", "value": True, "reason": "Using Calibre-Web database for authentication."},
CustomComponentField(
key="oidc_admin_requirement",
component="oidc_admin_hint",
label="A local admin account is required before OIDC can be enabled.",
show_when=_auth_condition("oidc"),
),
*([] if cwa_db_available else [
CustomComponentField(
key="cwa_db_missing",
component="oidc_admin_hint",
label=(
"Calibre-Web database not detected. Mount your app.db to "
"/auth/app.db to enable this method. Authentication will fall "
"back to none until the database is available."
),
show_when=_auth_condition("cwa"),
),
]),
ActionButton(
key="clear_credentials",
label="Clear Credentials",
description="Remove login requirement and make the app publicly accessible.",
style="danger",
callback=_clear_builtin_credentials,
disabled_when={"field": "USE_CWA_AUTH", "value": True, "reason": "Using Calibre-Web database for authentication."},
key="open_users_tab",
label="Go to Users",
description="Configure local users and admin access in the Users tab.",
style="primary",
show_when={"field": "AUTH_METHOD", "value": ["builtin", "oidc"]},
),
CheckboxField(
key="USE_CWA_AUTH",
label="Use Calibre-Web Database",
description=(
"Use your existing Calibre-Web user credentials for authentication."
),
default=False,
env_supported=False,
disabled=not cwa_db_available,
disabled_reason="Mount your Calibre-Web app.db to /auth/app.db in docker compose to enable.",
_auth_field(
TextField,
"proxy",
key="PROXY_AUTH_USER_HEADER",
label="Proxy Auth User Header",
description="The HTTP header your proxy uses to pass the authenticated username.",
placeholder="e.g. X-Auth-User",
default="X-Auth-User",
),
CheckboxField(
key="RESTRICT_SETTINGS_TO_ADMIN",
label="Restrict Settings to Admins",
description=(
"Only users with admin role in Calibre-Web can access settings."
),
default=False,
env_supported=False,
show_when={"field": "USE_CWA_AUTH", "value": True},
_auth_field(
TextField,
"proxy",
key="PROXY_AUTH_LOGOUT_URL",
label="Proxy Auth Logout URL",
description="The URL to redirect users to for logging out. Leave empty to disable logout functionality.",
placeholder="https://myauth.example.com/logout",
default="",
),
_auth_field(
TextField,
"proxy",
key="PROXY_AUTH_ADMIN_GROUP_HEADER",
label="Proxy Auth Admin Group Header",
description="Optional: header your proxy uses to pass user groups/roles.",
placeholder="e.g. X-Auth-Groups",
default="X-Auth-Groups",
),
_auth_field(
TextField,
"proxy",
key="PROXY_AUTH_ADMIN_GROUP_NAME",
label="Proxy Auth Admin Group",
description="Optional: users in this group are treated as admins. Leave blank to skip group-based admin detection.",
placeholder="e.g. admins",
default="",
),
]
fields.append(
CustomComponentField(
key="oidc_callback_url",
component="settings_label",
label="Callback URL",
description="{origin}/api/auth/oidc/callback",
show_when=_auth_condition("oidc"),
)
)
oidc_specs = [
(
TextField,
{
"key": "OIDC_DISCOVERY_URL",
"label": "Discovery URL",
"description": "OpenID Connect discovery endpoint URL. Usually ends with /.well-known/openid-configuration.",
"placeholder": "https://auth.example.com/.well-known/openid-configuration",
"required": True,
},
),
(
TextField,
{
"key": "OIDC_CLIENT_ID",
"label": "Client ID",
"description": "OAuth2 client ID from your identity provider.",
"placeholder": "shelfmark",
"required": True,
},
),
(
PasswordField,
{
"key": "OIDC_CLIENT_SECRET",
"label": "Client Secret",
"description": "OAuth2 client secret from your identity provider.",
"required": True,
},
),
(
TagListField,
{
"key": "OIDC_SCOPES",
"label": "Scopes",
"description": "OAuth2 scopes to request from the identity provider. Managed automatically: includes essential scopes and the group claim when using admin group authorization.",
"default": ["openid", "email", "profile"],
},
),
(
TextField,
{
"key": "OIDC_GROUP_CLAIM",
"label": "Group Claim Name",
"description": "The name of the claim in the ID token that contains user groups.",
"placeholder": "groups",
"default": "groups",
},
),
(
TextField,
{
"key": "OIDC_ADMIN_GROUP",
"label": "Admin Group Name",
"description": "Users in this group will be given admin access (if enabled below). Leave empty to use database roles only.",
"placeholder": "shelfmark-admins",
"default": "",
},
),
(
CheckboxField,
{
"key": "OIDC_USE_ADMIN_GROUP",
"label": "Use Admin Group for Authorization",
"description": "When enabled, users in the Admin Group are granted admin access. When disabled, admin access is determined solely by database roles.",
"default": True,
},
),
(
CheckboxField,
{
"key": "OIDC_AUTO_PROVISION",
"label": "Auto-Provision Users",
"description": "Automatically create a user account on first OIDC login. When disabled, users must be pre-created by an admin.",
"default": True,
},
),
(
TextField,
{
"key": "OIDC_BUTTON_LABEL",
"label": "Login Button Label",
"description": "Custom label for the OIDC sign-in button on the login page.",
"placeholder": "Sign in with OIDC",
"default": "",
},
),
]
fields.extend(_auth_field(factory, "oidc", **spec) for factory, spec in oidc_specs)
fields.append(
ActionButton(
key="test_oidc",
label="Test Connection",
description="Fetch the OIDC discovery document and validate configuration.",
style="primary",
callback=_test_oidc_connection,
show_when=_auth_condition("oidc"),
)
)
fields.append(
CustomComponentField(
key="oidc_env_info",
component="oidc_env_info",
label="Environment-Only Options",
description="These options can only be set via environment variables because changing them through the UI could lock you out of the application.",
wrap_in_field_wrapper=True,
show_when=_auth_condition("oidc"),
)
)
return fields
# Register the on_save handler for this tab
register_on_save("security", _on_save_security)
+75
View File
@@ -0,0 +1,75 @@
"""Operational handlers for security settings (save/actions)."""
import os
from typing import Any, Callable
from shelfmark.core.utils import normalize_http_url
from shelfmark.core.user_db import UserDB
from shelfmark.download.network import get_ssl_verify
_OIDC_LOCKOUT_MESSAGE = "A local admin account with a password is required before enabling OIDC. Use the 'Go to Users' button above to create one. This ensures you can still sign in if your identity provider is unavailable."
def _has_local_password_admin() -> bool:
root = os.environ.get("CONFIG_DIR", "/config")
user_db = UserDB(os.path.join(root, "users.db"))
user_db.initialize()
return any(user.get("password_hash") and user.get("role") == "admin" for user in user_db.list_users())
def on_save_security(
values: dict[str, Any],
) -> dict[str, Any]:
"""Validate security values before persistence."""
normalized_values = values.copy()
discovery_url = normalized_values.get("OIDC_DISCOVERY_URL")
if discovery_url is not None:
normalized_values["OIDC_DISCOVERY_URL"] = normalize_http_url(
str(discovery_url),
default_scheme="https",
)
proxy_logout_url = normalized_values.get("PROXY_AUTH_LOGOUT_URL")
if proxy_logout_url is not None:
normalized_values["PROXY_AUTH_LOGOUT_URL"] = normalize_http_url(
str(proxy_logout_url),
default_scheme="https",
strip_trailing_slash=False,
)
if normalized_values.get("AUTH_METHOD") == "oidc" and not _has_local_password_admin():
return {"error": True, "message": _OIDC_LOCKOUT_MESSAGE, "values": normalized_values}
return {"error": False, "values": normalized_values}
def test_oidc_connection(
*,
load_security_config: Callable[[], dict[str, Any]],
current_values: dict[str, Any] | None = None,
logger: Any,
) -> dict[str, Any]:
"""Fetch and validate the configured OIDC discovery document."""
import requests
try:
# Prefer the current (unsaved) form value over the saved config
discovery_url = (current_values or {}).get("OIDC_DISCOVERY_URL") or load_security_config().get("OIDC_DISCOVERY_URL", "")
if not discovery_url:
return {"success": False, "message": "Discovery URL is not configured."}
response = requests.get(discovery_url, timeout=10, verify=get_ssl_verify(discovery_url))
response.raise_for_status()
document = response.json()
required_fields = ["issuer", "authorization_endpoint", "token_endpoint"]
missing_fields = [field for field in required_fields if field not in document]
if missing_fields:
return {"success": False, "message": f"Discovery document missing fields: {', '.join(missing_fields)}"}
return {"success": True, "message": f"Connected to {document['issuer']}"}
except Exception as exc:
logger.error(f"OIDC connection test failed: {exc}")
return {"success": False, "message": f"Connection failed: {str(exc)}"}
File diff suppressed because it is too large Load Diff
+409
View File
@@ -0,0 +1,409 @@
"""Users settings tab registration.
This registers a 'users' tab in the settings sidebar.
The actual user management is handled by a custom frontend component
that talks to /api/admin/users endpoints.
"""
from typing import Any
from shelfmark.core.settings_registry import (
CheckboxField,
CustomComponentField,
HeadingField,
MultiSelectField,
NumberField,
SelectField,
TableField,
register_on_save,
register_settings,
)
from shelfmark.core.request_policy import (
get_source_content_type_capabilities,
parse_policy_mode,
validate_policy_rules,
)
_REQUEST_DEFAULT_MODE_OPTIONS = [
{
"value": "download",
"label": "Download",
"description": "Everything can be downloaded directly.",
},
{
"value": "request_release",
"label": "Request Release",
"description": "Users must request a specific release.",
},
{
"value": "request_book",
"label": "Request Book",
"description": "Users request a book, admin picks the release.",
},
{
"value": "blocked",
"label": "Blocked",
"description": "No downloads or requests allowed.",
},
]
_REQUEST_MATRIX_MODE_OPTIONS = [
option for option in _REQUEST_DEFAULT_MODE_OPTIONS if option["value"] != "request_book"
]
_SELF_SETTINGS_SECTION_OPTIONS = [
{
"value": "delivery",
"label": "Delivery Preferences",
"description": "Show personal delivery output and destination settings.",
},
{
"value": "search",
"label": "Search Preferences",
"description": "Show personal search mode and provider settings.",
},
{
"value": "notifications",
"label": "Notifications",
"description": "Show personal notification route settings.",
},
]
_SELF_SETTINGS_SECTION_VALUES = {option["value"] for option in _SELF_SETTINGS_SECTION_OPTIONS}
_SELF_SETTINGS_SECTION_DEFAULTS = [option["value"] for option in _SELF_SETTINGS_SECTION_OPTIONS]
_SEARCH_MODE_VALUES = {"direct", "universal"}
_SEARCH_PREFERENCE_PROVIDER_KEYS = {"METADATA_PROVIDER", "METADATA_PROVIDER_AUDIOBOOK", "METADATA_PROVIDER_COMBINED"}
_SEARCH_PREFERENCE_VALIDATABLE_KEYS = {
"SEARCH_MODE",
"DEFAULT_RELEASE_SOURCE",
"DEFAULT_RELEASE_SOURCE_AUDIOBOOK",
"SHOW_COMBINED_SELECTOR",
*_SEARCH_PREFERENCE_PROVIDER_KEYS,
}
_USERS_HEADING_DESCRIPTION_BY_AUTH_MODE = {
"builtin": (
"Create and manage user accounts directly. Passwords are stored locally and users sign in "
"with their username and password."
),
"oidc": (
"Users sign in through your identity provider. New accounts can be created automatically on "
"first login when auto-provisioning is enabled, or you can pre-create users here and they\u2019ll "
"be linked by email on first sign-in."
),
"proxy": (
"Users are authenticated by your reverse proxy. Accounts are automatically created on first "
"sign-in. If a local user with a matching username already exists, it will be linked instead."
),
"cwa": (
"User accounts are synced from your Calibre-Web database. Users are matched by email, and new "
"accounts are created here when new CWA users are found."
),
"none": "Authentication is disabled. Anyone can access Shelfmark without signing in.",
"default": "Authentication is disabled. Anyone can access Shelfmark without signing in.",
}
def _get_request_source_options():
"""Build request-policy source options from registered release sources."""
from shelfmark.release_sources import list_available_sources
options = []
for source in list_available_sources():
options.append(
{
"value": source["name"],
"label": source["display_name"],
}
)
return options
def _get_valid_release_source_names_for_content_type(content_type: str) -> set[str]:
"""Return registered release source names that support the requested content type."""
from shelfmark.release_sources import list_available_sources
valid_sources: set[str] = set()
for source in list_available_sources():
supported_types = source.get("supported_content_types", ["ebook", "audiobook"])
if content_type in supported_types:
valid_sources.add(source["name"])
return valid_sources
def _get_request_policy_rule_columns():
source_capabilities = get_source_content_type_capabilities()
content_type_options = []
for source_name, supported_types in source_capabilities.items():
normalized_types = [t for t in ("ebook", "audiobook") if t in supported_types]
for content_type in normalized_types:
content_type_options.append(
{
"value": content_type,
"label": "Ebook" if content_type == "ebook" else "Audiobook",
"childOf": source_name,
}
)
return [
{
"key": "source",
"label": "Source",
"type": "select",
"options": _get_request_source_options(),
"defaultValue": "",
"placeholder": "Select source...",
},
{
"key": "content_type",
"label": "Content Type",
"type": "select",
"options": content_type_options,
"defaultValue": "",
"placeholder": "Select content type...",
"filterByField": "source",
},
{
"key": "mode",
"label": "Mode",
"type": "select",
"options": _REQUEST_MATRIX_MODE_OPTIONS,
"defaultValue": "",
"placeholder": "Select mode...",
},
]
def validate_search_preference_value(key: str, value: Any) -> tuple[Any, str | None]:
"""Validate and normalize a search preference value for user overrides."""
if key not in _SEARCH_PREFERENCE_VALIDATABLE_KEYS:
return value, None
if value is None:
return None, None
normalized_value = str(value).strip()
if key == "SEARCH_MODE":
normalized_mode = normalized_value.lower()
if normalized_mode not in _SEARCH_MODE_VALUES:
return value, "SEARCH_MODE must be 'direct' or 'universal'"
return normalized_mode, None
if key in _SEARCH_PREFERENCE_PROVIDER_KEYS:
if normalized_value == "":
return "", None
from shelfmark.metadata_providers import is_provider_registered
if not is_provider_registered(normalized_value):
return (
value,
f"{key} must be a valid metadata provider name or empty",
)
return normalized_value, None
if key in {"DEFAULT_RELEASE_SOURCE", "DEFAULT_RELEASE_SOURCE_AUDIOBOOK"}:
if normalized_value == "":
return "", None
valid_sources = _get_valid_release_source_names_for_content_type(
"audiobook" if key == "DEFAULT_RELEASE_SOURCE_AUDIOBOOK" else "ebook"
)
if normalized_value not in valid_sources:
return (
value,
f"{key} must be a valid release source name or empty",
)
return normalized_value, None
if key == "SHOW_COMBINED_SELECTOR":
if isinstance(value, bool):
return value, None
return bool(value), None
return value, None
def _on_save_users(values):
"""Validate users/request-policy settings before persistence."""
if "VISIBLE_SELF_SETTINGS_SECTIONS" in values:
raw_sections = values["VISIBLE_SELF_SETTINGS_SECTIONS"]
if raw_sections is None:
candidate_sections: list[str] = []
elif isinstance(raw_sections, str):
candidate_sections = [s.strip() for s in raw_sections.split(",") if s.strip()]
elif isinstance(raw_sections, (list, tuple, set)):
candidate_sections = [str(section).strip() for section in raw_sections if str(section).strip()]
else:
return {
"error": True,
"message": "VISIBLE_SELF_SETTINGS_SECTIONS must be a list of section identifiers",
"values": values,
}
normalized_sections: list[str] = []
for section in candidate_sections:
if section not in _SELF_SETTINGS_SECTION_VALUES:
allowed = ", ".join(sorted(_SELF_SETTINGS_SECTION_VALUES))
return {
"error": True,
"message": (
"VISIBLE_SELF_SETTINGS_SECTIONS contains an unsupported section "
f"'{section}'. Supported values: {allowed}"
),
"values": values,
}
if section not in normalized_sections:
normalized_sections.append(section)
values["VISIBLE_SELF_SETTINGS_SECTIONS"] = normalized_sections
if "REQUEST_POLICY_DEFAULT_EBOOK" in values:
if parse_policy_mode(values["REQUEST_POLICY_DEFAULT_EBOOK"]) is None:
return {
"error": True,
"message": "REQUEST_POLICY_DEFAULT_EBOOK must be a valid policy mode",
"values": values,
}
if "REQUEST_POLICY_DEFAULT_AUDIOBOOK" in values:
if parse_policy_mode(values["REQUEST_POLICY_DEFAULT_AUDIOBOOK"]) is None:
return {
"error": True,
"message": "REQUEST_POLICY_DEFAULT_AUDIOBOOK must be a valid policy mode",
"values": values,
}
if "REQUEST_POLICY_RULES" in values:
normalized_rules, errors = validate_policy_rules(values["REQUEST_POLICY_RULES"])
if errors:
return {
"error": True,
"message": "; ".join(errors),
"values": values,
}
values["REQUEST_POLICY_RULES"] = normalized_rules
for key in _SEARCH_PREFERENCE_VALIDATABLE_KEYS:
if key not in values:
continue
normalized_value, validation_error = validate_search_preference_value(key, values[key])
if validation_error:
return {
"error": True,
"message": validation_error,
"values": values,
}
values[key] = normalized_value
return {"error": False, "values": values}
register_on_save("users", _on_save_users)
@register_settings("users", "Users & Requests", icon="users", order=6)
def users_settings():
"""User management tab - rendered as a custom component on the frontend."""
return [
HeadingField(
key="users_heading",
title="Users",
description=_USERS_HEADING_DESCRIPTION_BY_AUTH_MODE["default"],
description_by_auth_mode=_USERS_HEADING_DESCRIPTION_BY_AUTH_MODE,
),
CustomComponentField(
key="users_management",
component="users_management",
),
MultiSelectField(
key="VISIBLE_SELF_SETTINGS_SECTIONS",
label="Visible Self-Settings Sections",
description=(
"Choose which personal settings sections are shown in My Account for non-admin users."
),
options=_SELF_SETTINGS_SECTION_OPTIONS,
default=_SELF_SETTINGS_SECTION_DEFAULTS,
variant="dropdown",
env_supported=False,
),
HeadingField(
key="requests_heading",
title="Requests",
description=(
"Choose what users can download directly and what needs approval first."
),
),
CheckboxField(
key="REQUESTS_ENABLED",
label="Enable Requests",
description=(
"Turn this off to let everyone download directly without needing approval."
),
default=False,
user_overridable=True,
),
CustomComponentField(
key="request_policy_editor",
component="request_policy_grid",
label="Request Rules",
description=(
"Fine-tune access per source. Source rules can only be the same or more restrictive than the default above."
),
show_when={"field": "REQUESTS_ENABLED", "value": True},
wrap_in_field_wrapper=True,
value_fields=[
SelectField(
key="REQUEST_POLICY_DEFAULT_EBOOK",
label="Default Ebook Mode",
description=(
"Sets the baseline for all ebook sources."
),
options=_REQUEST_DEFAULT_MODE_OPTIONS,
default="download",
user_overridable=True,
),
SelectField(
key="REQUEST_POLICY_DEFAULT_AUDIOBOOK",
label="Default Audiobook Mode",
description=(
"Sets the baseline for all audiobook sources."
),
options=_REQUEST_DEFAULT_MODE_OPTIONS,
default="download",
user_overridable=True,
),
TableField(
key="REQUEST_POLICY_RULES",
label="Request Rules",
description=(
"Fine-tune access per source. Source rules can only be the same or more restrictive than the default above."
),
columns=_get_request_policy_rule_columns,
default=[],
add_label="Add Rule",
empty_message="No request policy rules configured.",
env_supported=False,
user_overridable=True,
),
],
),
NumberField(
key="MAX_PENDING_REQUESTS_PER_USER",
label="Max pending requests per user",
description="How many open requests a user can have at a time.",
default=20,
min_value=1,
max_value=1000,
user_overridable=True,
show_when={"field": "REQUESTS_ENABLED", "value": True},
),
CheckboxField(
key="REQUESTS_ALLOW_NOTES",
label="Allow notes on requests",
description="Let users add a note when they submit a request.",
default=True,
user_overridable=True,
show_when={"field": "REQUESTS_ENABLED", "value": True},
),
]
+1 -1
View File
@@ -1,5 +1,5 @@
"""Core module - shared models, queue, and utilities."""
from shelfmark.core.models import BookInfo, QueueItem, SearchFilters, QueueStatus
from shelfmark.core.models import QueueItem, SearchFilters, QueueStatus
from shelfmark.core.queue import BookQueue, book_queue
from shelfmark.core.logger import setup_logger
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,310 @@
"""Persistence helpers for per-viewer activity visibility state."""
from __future__ import annotations
import sqlite3
import threading
from typing import Any
from shelfmark.core.request_helpers import now_utc_iso
VALID_ACTIVITY_ITEM_TYPES = frozenset({"download", "request"})
ADMIN_VIEWER_SCOPE = "admin:shared"
NOAUTH_VIEWER_SCOPE = "noauth:shared"
USER_VIEWER_SCOPE_PREFIX = "user:"
def user_viewer_scope(user_id: int) -> str:
if not isinstance(user_id, int) or user_id < 1:
raise ValueError("user_id must be a positive integer")
return f"{USER_VIEWER_SCOPE_PREFIX}{user_id}"
def normalize_viewer_scope(viewer_scope: Any) -> str:
if not isinstance(viewer_scope, str) or not viewer_scope.strip():
raise ValueError("viewer_scope must be a non-empty string")
normalized = viewer_scope.strip()
if normalized in {ADMIN_VIEWER_SCOPE, NOAUTH_VIEWER_SCOPE}:
return normalized
if not normalized.startswith(USER_VIEWER_SCOPE_PREFIX):
raise ValueError(
"viewer_scope must be one of: admin:shared, noauth:shared, or user:<id>"
)
raw_user_id = normalized[len(USER_VIEWER_SCOPE_PREFIX):].strip()
try:
parsed_user_id = int(raw_user_id)
except (TypeError, ValueError) as exc:
raise ValueError("viewer_scope user id must be a positive integer") from exc
return user_viewer_scope(parsed_user_id)
def _normalize_item_type(item_type: Any) -> str:
if not isinstance(item_type, str) or not item_type.strip():
raise ValueError("item_type must be a non-empty string")
normalized = item_type.strip().lower()
if normalized not in VALID_ACTIVITY_ITEM_TYPES:
raise ValueError("item_type must be one of: download, request")
return normalized
def _normalize_item_key(item_key: Any, *, item_type: str) -> str:
if not isinstance(item_key, str) or not item_key.strip():
raise ValueError("item_key must be a non-empty string")
normalized = item_key.strip()
expected_prefix = f"{item_type}:"
if not normalized.startswith(expected_prefix):
raise ValueError(f"item_key must be in the format {expected_prefix}<id>")
if not normalized.split(":", 1)[1].strip():
raise ValueError(f"item_key must be in the format {expected_prefix}<id>")
return normalized
class ActivityViewStateService:
"""Service for per-viewer activity dismissal and history visibility."""
def __init__(self, db_path: str):
self._db_path = db_path
self._lock = threading.Lock()
def _connect(self) -> sqlite3.Connection:
conn = sqlite3.connect(self._db_path)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA foreign_keys = ON")
return conn
def list_hidden(
self,
*,
viewer_scope: str,
limit: int | None = None,
) -> list[dict[str, Any]]:
normalized_scope = normalize_viewer_scope(viewer_scope)
normalized_limit = None if limit is None else max(1, int(limit))
query = """
SELECT item_type, item_key, dismissed_at, cleared_at
FROM activity_view_state
WHERE viewer_scope = ?
AND dismissed_at IS NOT NULL
ORDER BY COALESCE(cleared_at, dismissed_at) DESC, id DESC
"""
params: list[Any] = [normalized_scope]
if normalized_limit is not None:
query += "\nLIMIT ?"
params.append(normalized_limit)
conn = self._connect()
try:
rows = conn.execute(query, params).fetchall()
return [dict(row) for row in rows]
finally:
conn.close()
def list_history(
self,
*,
viewer_scope: str,
limit: int = 50,
offset: int = 0,
) -> list[dict[str, Any]]:
normalized_scope = normalize_viewer_scope(viewer_scope)
normalized_limit = max(1, min(int(limit), 5000))
normalized_offset = max(0, int(offset))
conn = self._connect()
try:
rows = conn.execute(
"""
SELECT item_type, item_key, dismissed_at
FROM activity_view_state
WHERE viewer_scope = ?
AND dismissed_at IS NOT NULL
AND cleared_at IS NULL
ORDER BY dismissed_at DESC, id DESC
LIMIT ? OFFSET ?
""",
(normalized_scope, normalized_limit, normalized_offset),
).fetchall()
return [dict(row) for row in rows]
finally:
conn.close()
def dismiss(
self,
*,
viewer_scope: str,
item_type: str,
item_key: str,
) -> int:
normalized_scope = normalize_viewer_scope(viewer_scope)
normalized_type = _normalize_item_type(item_type)
normalized_key = _normalize_item_key(item_key, item_type=normalized_type)
dismissed_at = now_utc_iso()
with self._lock:
conn = self._connect()
try:
cursor = conn.execute(
"""
INSERT INTO activity_view_state (
viewer_scope,
item_type,
item_key,
dismissed_at,
cleared_at
)
VALUES (?, ?, ?, ?, NULL)
ON CONFLICT(viewer_scope, item_type, item_key) DO UPDATE SET
dismissed_at = excluded.dismissed_at,
cleared_at = NULL
""",
(normalized_scope, normalized_type, normalized_key, dismissed_at),
)
conn.commit()
rowcount = int(cursor.rowcount) if cursor.rowcount is not None else 0
return max(rowcount, 0)
finally:
conn.close()
def dismiss_many(
self,
*,
viewer_scope: str,
items: list[dict[str, str]],
) -> int:
normalized_scope = normalize_viewer_scope(viewer_scope)
if not items:
return 0
seen: set[tuple[str, str]] = set()
normalized_items: list[tuple[str, str]] = []
for item in items:
normalized_type = _normalize_item_type(item.get("item_type"))
normalized_key = _normalize_item_key(item.get("item_key"), item_type=normalized_type)
marker = (normalized_type, normalized_key)
if marker in seen:
continue
seen.add(marker)
normalized_items.append(marker)
if not normalized_items:
return 0
dismissed_at = now_utc_iso()
with self._lock:
conn = self._connect()
try:
total = 0
for normalized_type, normalized_key in normalized_items:
cursor = conn.execute(
"""
INSERT INTO activity_view_state (
viewer_scope,
item_type,
item_key,
dismissed_at,
cleared_at
)
VALUES (?, ?, ?, ?, NULL)
ON CONFLICT(viewer_scope, item_type, item_key) DO UPDATE SET
dismissed_at = excluded.dismissed_at,
cleared_at = NULL
""",
(normalized_scope, normalized_type, normalized_key, dismissed_at),
)
rowcount = int(cursor.rowcount) if cursor.rowcount is not None else 0
total += max(rowcount, 0)
conn.commit()
return total
finally:
conn.close()
def clear_history(self, *, viewer_scope: str) -> int:
normalized_scope = normalize_viewer_scope(viewer_scope)
cleared_at = now_utc_iso()
with self._lock:
conn = self._connect()
try:
cursor = conn.execute(
"""
UPDATE activity_view_state
SET cleared_at = ?
WHERE viewer_scope = ?
AND dismissed_at IS NOT NULL
AND cleared_at IS NULL
""",
(cleared_at, normalized_scope),
)
conn.commit()
rowcount = int(cursor.rowcount) if cursor.rowcount is not None else 0
return max(rowcount, 0)
finally:
conn.close()
def clear_item_for_all_viewers(self, *, item_type: str, item_key: str) -> int:
normalized_type = _normalize_item_type(item_type)
normalized_key = _normalize_item_key(item_key, item_type=normalized_type)
with self._lock:
conn = self._connect()
try:
cursor = conn.execute(
"""
DELETE FROM activity_view_state
WHERE item_type = ? AND item_key = ?
""",
(normalized_type, normalized_key),
)
conn.commit()
rowcount = int(cursor.rowcount) if cursor.rowcount is not None else 0
return max(rowcount, 0)
finally:
conn.close()
def delete_viewer_scope(self, *, viewer_scope: str) -> int:
normalized_scope = normalize_viewer_scope(viewer_scope)
with self._lock:
conn = self._connect()
try:
cursor = conn.execute(
"DELETE FROM activity_view_state WHERE viewer_scope = ?",
(normalized_scope,),
)
conn.commit()
rowcount = int(cursor.rowcount) if cursor.rowcount is not None else 0
return max(rowcount, 0)
finally:
conn.close()
def delete_items(self, *, item_type: str, item_keys: list[str]) -> int:
normalized_type = _normalize_item_type(item_type)
normalized_keys = [
_normalize_item_key(item_key, item_type=normalized_type)
for item_key in item_keys
]
if not normalized_keys:
return 0
placeholders = ",".join("?" for _ in normalized_keys)
with self._lock:
conn = self._connect()
try:
cursor = conn.execute(
f"""
DELETE FROM activity_view_state
WHERE item_type = ? AND item_key IN ({placeholders})
""",
(normalized_type, *normalized_keys),
)
conn.commit()
rowcount = int(cursor.rowcount) if cursor.rowcount is not None else 0
return max(rowcount, 0)
finally:
conn.close()
+420
View File
@@ -0,0 +1,420 @@
"""Admin user management API routes.
Registers /api/admin/users CRUD endpoints for managing users.
All endpoints require admin session.
"""
from functools import wraps
import os
import sqlite3
from typing import Any
from flask import Flask, g, jsonify, request, session
from werkzeug.security import generate_password_hash
from shelfmark.config.booklore_settings import (
get_booklore_library_options,
get_booklore_path_options,
)
from shelfmark.config.env import CWA_DB_PATH
from shelfmark.core.admin_settings_routes import (
register_admin_settings_routes,
validate_user_settings,
)
from shelfmark.core.auth_modes import (
AUTH_SOURCE_BUILTIN,
AUTH_SOURCE_CWA,
AUTH_SOURCE_OIDC,
AUTH_SOURCE_PROXY,
is_user_active_for_auth_mode,
load_active_auth_mode,
normalize_auth_source,
)
from shelfmark.core.cwa_user_sync import sync_cwa_users_from_rows
from shelfmark.core.logger import setup_logger
from shelfmark.core.settings_registry import load_config_file
from shelfmark.core.user_db import UserDB
logger = setup_logger(__name__)
def _get_user_edit_capabilities(
user: dict[str, Any],
security_config: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Return backend-authored capability flags for the user edit form."""
auth_source = normalize_auth_source(
user.get("auth_source"),
user.get("oidc_subject"),
)
if security_config is None and auth_source == AUTH_SOURCE_OIDC:
security_config = load_config_file("security")
oidc_use_admin_group = bool((security_config or {}).get("OIDC_USE_ADMIN_GROUP", True))
role_managed_by_oidc_group = auth_source == AUTH_SOURCE_OIDC and oidc_use_admin_group
can_edit_role = auth_source == AUTH_SOURCE_BUILTIN or (
auth_source == AUTH_SOURCE_OIDC and not role_managed_by_oidc_group
)
return {
"authSource": auth_source,
"canSetPassword": auth_source == AUTH_SOURCE_BUILTIN,
"canEditRole": can_edit_role,
"canEditEmail": auth_source in {AUTH_SOURCE_BUILTIN, AUTH_SOURCE_PROXY},
"canEditDisplayName": auth_source != AUTH_SOURCE_OIDC,
}
def _sanitize_user(user: dict) -> dict:
"""Remove sensitive fields from user dict before returning to client."""
sanitized = dict(user)
sanitized.pop("password_hash", None)
return sanitized
def _oidc_role_management_message(security_config: dict[str, Any]) -> str:
admin_group = security_config.get("OIDC_ADMIN_GROUP", "")
if admin_group:
return (
"Admin roles for OIDC users are managed by the "
f"'{admin_group}' group in your identity provider"
)
return (
"Disable 'Use Admin Group for Authorization' in security settings "
"to manage roles manually"
)
def _serialize_user(
user: dict[str, Any],
auth_method: str,
security_config: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Sanitize and enrich a user payload for API responses."""
payload = _sanitize_user(user)
payload["auth_source"] = normalize_auth_source(
payload.get("auth_source"),
payload.get("oidc_subject"),
)
payload["is_active"] = is_user_active_for_auth_mode(payload, auth_method)
payload["edit_capabilities"] = _get_user_edit_capabilities(
payload,
security_config=security_config,
)
return payload
def _sync_all_cwa_users(user_db: UserDB) -> dict[str, int]:
"""Sync all users from the Calibre-Web database into users.db."""
if not CWA_DB_PATH or not CWA_DB_PATH.exists():
raise FileNotFoundError("Calibre-Web database is not available")
db_path = os.fspath(CWA_DB_PATH)
db_uri = f"file:{db_path}?mode=ro&immutable=1"
conn = sqlite3.connect(db_uri, uri=True)
try:
cur = conn.cursor()
cur.execute("SELECT name, role, email FROM user")
rows = cur.fetchall()
finally:
conn.close()
return sync_cwa_users_from_rows(user_db, rows)
def register_admin_routes(app: Flask, user_db: UserDB) -> None:
"""Register admin user management routes on the Flask app."""
def _require_admin(f):
"""Decorator to require admin session for admin routes.
In no-auth mode, everyone has access (is_admin defaults True).
In auth-required modes, requires an authenticated session with admin role.
Caches the resolved auth_mode in ``g.auth_mode`` for the request.
"""
@wraps(f)
def decorated(*args, **kwargs):
auth_mode = load_active_auth_mode(CWA_DB_PATH, user_db=user_db)
g.auth_mode = auth_mode
if auth_mode != "none":
if "user_id" not in session:
return jsonify({"error": "Authentication required"}), 401
if not session.get("is_admin", False):
return jsonify({"error": "Admin access required"}), 403
return f(*args, **kwargs)
return decorated
@app.route("/api/admin/users", methods=["GET"])
@_require_admin
def admin_list_users():
"""List all users."""
users = user_db.list_users()
auth_mode = g.auth_mode
security_config = load_config_file("security")
return jsonify([
_serialize_user(u, auth_mode, security_config=security_config)
for u in users
])
@app.route("/api/admin/users", methods=["POST"])
@_require_admin
def admin_create_user():
"""Create a new user with password authentication."""
data = request.get_json() or {}
auth_mode = g.auth_mode
username = (data.get("username") or "").strip()
password = data.get("password", "")
email = (data.get("email") or "").strip() or None
display_name = (data.get("display_name") or "").strip() or None
role = data.get("role", "user")
if auth_mode in {AUTH_SOURCE_PROXY, AUTH_SOURCE_CWA}:
return jsonify({
"error": "Local user creation is disabled in this authentication mode",
"message": (
"Users are provisioned by your external authentication source. "
"Switch to builtin or OIDC mode to create local users."
),
}), 400
if not username:
return jsonify({"error": "Username is required"}), 400
if not password or len(password) < 4:
return jsonify({"error": "Password must be at least 4 characters"}), 400
if role not in ("admin", "user"):
return jsonify({"error": "Role must be 'admin' or 'user'"}), 400
# First user is always admin
existing_users = user_db.list_users()
if not existing_users:
role = "admin"
# Check if username already exists
if user_db.get_user(username=username):
return jsonify({"error": "Username already exists"}), 409
password_hash = generate_password_hash(password)
try:
user = user_db.create_user(
username=username,
password_hash=password_hash,
email=email,
display_name=display_name,
auth_source=AUTH_SOURCE_BUILTIN,
role=role,
)
except ValueError:
return jsonify({"error": "Username already exists"}), 409
logger.info(
"Shelfmark user created "
f"(source=manual_admin_create, created_by={session.get('user_id', 'unknown')}, "
f"username={username}, role={role}, auth_source={AUTH_SOURCE_BUILTIN})"
)
return jsonify(
_serialize_user(
user,
g.auth_mode,
security_config=load_config_file("security"),
)
), 201
@app.route("/api/admin/users/<int:user_id>", methods=["GET"])
@_require_admin
def admin_get_user(user_id):
"""Get a user by ID with their settings."""
user = user_db.get_user(user_id=user_id)
if not user:
return jsonify({"error": "User not found"}), 404
result = _serialize_user(
user,
g.auth_mode,
security_config=load_config_file("security"),
)
result["settings"] = user_db.get_user_settings(user_id)
return jsonify(result)
@app.route("/api/admin/users/<int:user_id>", methods=["PUT"])
@_require_admin
def admin_update_user(user_id):
"""Update user fields and/or settings."""
user = user_db.get_user(user_id=user_id)
if not user:
return jsonify({"error": "User not found"}), 404
data = request.get_json() or {}
security_config = load_config_file("security")
auth_source = normalize_auth_source(
user.get("auth_source"),
user.get("oidc_subject"),
)
capabilities = _get_user_edit_capabilities(user, security_config=security_config)
# Handle optional password update
password = data.get("password", "")
if password:
if not capabilities["canSetPassword"]:
return jsonify({
"error": f"Cannot set password for {auth_source.upper()} users",
"message": "Password authentication is only available for local users.",
}), 400
if len(password) < 4:
return jsonify({"error": "Password must be at least 4 characters"}), 400
user_db.update_user(user_id, password_hash=generate_password_hash(password))
# Update user fields
user_fields = {}
for field in ("role", "email", "display_name"):
if field in data:
user_fields[field] = data[field]
if "role" in user_fields and user_fields["role"] not in ("admin", "user"):
return jsonify({"error": "Role must be 'admin' or 'user'"}), 400
role_changed = "role" in user_fields and user_fields["role"] != user.get("role")
email_changed = "email" in user_fields and user_fields["email"] != user.get("email")
display_name_changed = (
"display_name" in user_fields
and user_fields["display_name"] != user.get("display_name")
)
if role_changed and not capabilities["canEditRole"]:
if auth_source == AUTH_SOURCE_OIDC:
return jsonify({
"error": "Cannot change role for OIDC user when group-based authorization is enabled",
"message": _oidc_role_management_message(security_config),
}), 400
return jsonify({
"error": f"Cannot change role for {auth_source.upper()} users",
"message": "Role is managed by the external authentication source.",
}), 400
if email_changed and not capabilities["canEditEmail"]:
if auth_source == AUTH_SOURCE_CWA:
return jsonify({
"error": "Cannot change email for CWA users",
"message": "Email is synced from Calibre-Web.",
}), 400
return jsonify({
"error": "Cannot change email for OIDC users",
"message": "Email is managed by your identity provider.",
}), 400
if display_name_changed and not capabilities["canEditDisplayName"]:
return jsonify({
"error": "Cannot change display name for OIDC users",
"message": "Display name is managed by your identity provider.",
}), 400
# Allow demoting the last admin account.
# Auth mode resolution automatically falls back to "none" when no
# local password admin remains.
# Avoid unnecessary writes for no-op field updates.
for field in ("role", "email", "display_name"):
if field in user_fields and user_fields[field] == user.get(field):
user_fields.pop(field)
if user_fields:
user_db.update_user(user_id, **user_fields)
# Update per-user settings
if "settings" in data:
if not isinstance(data["settings"], dict):
return jsonify({"error": "Settings must be an object"}), 400
validated_settings, validation_errors = validate_user_settings(data["settings"])
if validation_errors:
return jsonify({
"error": "Invalid settings payload",
"details": validation_errors,
}), 400
user_db.set_user_settings(user_id, validated_settings)
# Ensure runtime reads see updated per-user overrides immediately.
try:
from shelfmark.core.config import config as app_config
app_config.refresh(force=True)
except Exception:
pass
updated = user_db.get_user(user_id=user_id)
result = _serialize_user(
updated,
g.auth_mode,
security_config=security_config,
)
result["settings"] = user_db.get_user_settings(user_id)
logger.info(f"Admin updated user {user_id}")
return jsonify(result)
@app.route("/api/admin/users/sync-cwa", methods=["POST"])
@_require_admin
def admin_sync_cwa_users():
"""Manually sync users from Calibre-Web into users.db."""
if g.auth_mode != AUTH_SOURCE_CWA:
return jsonify({
"error": "CWA sync is only available when CWA authentication is enabled",
}), 400
try:
summary = _sync_all_cwa_users(user_db)
except FileNotFoundError:
return jsonify({
"error": "Calibre-Web database is not available",
"message": "Verify app.db is mounted and readable at /auth/app.db.",
}), 503
except Exception as exc:
logger.error(f"Failed to sync CWA users: {exc}")
return jsonify({
"error": "Failed to sync users from Calibre-Web",
}), 500
message = (
f"Synced {summary['total']} CWA users "
f"({summary['created']} created, {summary['updated']} updated, "
f"{summary.get('deleted', 0)} deleted)."
)
logger.info(message)
return jsonify({
"success": True,
"message": message,
**summary,
})
register_admin_settings_routes(app, user_db, _require_admin)
@app.route("/api/admin/users/<int:user_id>", methods=["DELETE"])
@_require_admin
def admin_delete_user(user_id):
"""Delete a user."""
# Prevent self-deletion
if session.get("db_user_id") == user_id:
return jsonify({"error": "Cannot delete your own account"}), 400
user = user_db.get_user(user_id=user_id)
if not user:
return jsonify({"error": "User not found"}), 404
auth_source = normalize_auth_source(
user.get("auth_source"),
user.get("oidc_subject"),
)
if auth_source == AUTH_SOURCE_CWA and auth_source == g.auth_mode:
return jsonify({
"error": f"Cannot delete active {auth_source.upper()} users",
"message": f"{auth_source.upper()} users are automatically re-provisioned on login.",
}), 400
# Allow deleting the last local admin account.
# Auth mode resolution automatically falls back to "none" when no
# local password admin remains.
user_db.delete_user(user_id)
logger.info(f"Admin deleted user {user_id}: {user['username']}")
return jsonify({"success": True})
+287
View File
@@ -0,0 +1,287 @@
"""Admin settings-introspection routes and settings validation helpers."""
from typing import Any, Callable
from flask import Flask, jsonify, request
from shelfmark.config.notifications_settings import (
build_notification_test_result,
is_valid_notification_url,
normalize_notification_routes,
)
from shelfmark.config.users_settings import validate_search_preference_value
from shelfmark.core.settings_registry import load_config_file
from shelfmark.core.user_settings_overrides import (
build_user_preferences_payload as _build_user_preferences_payload,
get_ordered_user_overridable_fields as _get_ordered_user_overridable_fields,
get_settings_registry as _get_settings_registry,
)
from shelfmark.core.user_db import UserDB
from shelfmark.core.request_policy import parse_policy_mode, validate_policy_rules
def validate_user_settings(settings: dict[str, Any]) -> tuple[dict[str, Any], list[str]]:
settings_registry = _get_settings_registry()
field_map = settings_registry.get_settings_field_map()
overridable_map = settings_registry.get_user_overridable_fields()
valid: dict[str, Any] = {}
errors: list[str] = []
for key, value in settings.items():
if key not in field_map:
errors.append(f"Unknown setting: {key}")
elif key not in overridable_map:
errors.append(f"Setting not user-overridable: {key}")
else:
# null means "clear the per-user override; use global default"
if value is None:
valid[key] = None
continue
if key in {"REQUEST_POLICY_DEFAULT_EBOOK", "REQUEST_POLICY_DEFAULT_AUDIOBOOK"}:
if parse_policy_mode(value) is None:
errors.append(f"Invalid policy mode for {key}: {value}")
continue
if key == "REQUEST_POLICY_RULES":
normalized_rules, rule_errors = validate_policy_rules(value)
if rule_errors:
errors.extend(rule_errors)
continue
valid[key] = normalized_rules
continue
if key == "USER_NOTIFICATION_ROUTES":
normalized_routes = normalize_notification_routes(value)
invalid_count = sum(
1
for row in normalized_routes
if row.get("url") and not is_valid_notification_url(str(row.get("url")))
)
if invalid_count:
errors.append(
(
f"Invalid value for {key}: found {invalid_count} invalid URL(s). "
"Use URL values with a valid scheme, e.g. discord://... or ntfys://..."
)
)
continue
valid[key] = normalized_routes
continue
normalized_search_value, search_validation_error = validate_search_preference_value(key, value)
if search_validation_error:
errors.append(search_validation_error)
continue
if key in {
"SEARCH_MODE",
"METADATA_PROVIDER",
"METADATA_PROVIDER_AUDIOBOOK",
"DEFAULT_RELEASE_SOURCE",
"DEFAULT_RELEASE_SOURCE_AUDIOBOOK",
}:
valid[key] = normalized_search_value
continue
if key == "DOWNLOAD_TO_BROWSER_CONTENT_TYPES":
if not isinstance(value, list):
errors.append(f"Invalid value for {key}: must be a list")
continue
candidate_values = [
str(entry).strip().lower()
for entry in value
if str(entry).strip()
]
normalized_values: list[str] = []
has_invalid_value = False
for entry in candidate_values:
if entry not in {"book", "audiobook"}:
errors.append(
f"Invalid value for {key}: unsupported content type '{entry}'"
)
has_invalid_value = True
continue
if entry not in normalized_values:
normalized_values.append(entry)
if has_invalid_value:
continue
valid[key] = normalized_values
continue
valid[key] = value
return valid, errors
def build_user_notification_test_response(
*,
user_id: int,
payload: Any,
) -> tuple[dict[str, Any], int]:
from shelfmark.core.config import config as app_config
routes_input = app_config.get("USER_NOTIFICATION_ROUTES", [], user_id=user_id)
if isinstance(payload, dict):
if "USER_NOTIFICATION_ROUTES" in payload:
routes_input = payload.get("USER_NOTIFICATION_ROUTES")
elif "routes" in payload:
routes_input = payload.get("routes")
result = build_notification_test_result(routes_input, scope_label="personal")
status_code = 200 if result.get("success", False) else 400
return result, status_code
def register_admin_settings_routes(
app: Flask,
user_db: UserDB,
require_admin: Callable[[Callable[..., Any]], Callable[..., Any]],
) -> None:
@app.route("/api/admin/download-defaults", methods=["GET"])
@require_admin
def admin_download_defaults():
config = load_config_file("downloads")
defaults = {
key: ("" if (value := config.get(key, field.default)) is None else value)
for key, field in _get_ordered_user_overridable_fields("downloads")
}
security_config = load_config_file("security")
defaults["OIDC_ADMIN_GROUP"] = security_config.get("OIDC_ADMIN_GROUP", "")
defaults["OIDC_USE_ADMIN_GROUP"] = security_config.get("OIDC_USE_ADMIN_GROUP", True)
defaults["OIDC_AUTO_PROVISION"] = security_config.get("OIDC_AUTO_PROVISION", True)
return jsonify(defaults)
@app.route("/api/admin/booklore-options", methods=["GET"])
@require_admin
def admin_booklore_options():
from shelfmark.core import admin_routes
return jsonify({
"libraries": admin_routes.get_booklore_library_options(),
"paths": admin_routes.get_booklore_path_options(),
})
@app.route("/api/admin/users/<int:user_id>/delivery-preferences", methods=["GET"])
@require_admin
def admin_get_delivery_preferences(user_id):
user = user_db.get_user(user_id=user_id)
if not user:
return jsonify({"error": "User not found"}), 404
try:
payload = _build_user_preferences_payload(user_db, user_id, "downloads")
except ValueError:
return jsonify({"error": "Downloads settings tab not found"}), 500
return jsonify(payload)
@app.route("/api/admin/users/<int:user_id>/search-preferences", methods=["GET"])
@require_admin
def admin_get_search_preferences(user_id):
user = user_db.get_user(user_id=user_id)
if not user:
return jsonify({"error": "User not found"}), 404
try:
payload = _build_user_preferences_payload(user_db, user_id, "search_mode")
except ValueError:
return jsonify({"error": "Search mode settings tab not found"}), 500
return jsonify(payload)
@app.route("/api/admin/users/<int:user_id>/notification-preferences", methods=["GET"])
@require_admin
def admin_get_notification_preferences(user_id):
user = user_db.get_user(user_id=user_id)
if not user:
return jsonify({"error": "User not found"}), 404
try:
payload = _build_user_preferences_payload(user_db, user_id, "notifications")
except ValueError:
return jsonify({"error": "Notifications settings tab not found"}), 500
return jsonify(payload)
@app.route("/api/admin/users/<int:user_id>/notification-preferences/test", methods=["POST"])
@require_admin
def admin_test_notification_preferences(user_id):
user = user_db.get_user(user_id=user_id)
if not user:
return jsonify({"error": "User not found"}), 404
payload = request.get_json(silent=True)
result, status_code = build_user_notification_test_response(
user_id=user_id,
payload=payload,
)
return jsonify(result), status_code
@app.route("/api/admin/settings/overrides-summary", methods=["GET"])
@require_admin
def admin_settings_overrides_summary():
settings_registry = _get_settings_registry()
tab_name = (request.args.get("tab") or "downloads").strip()
if not settings_registry.get_settings_tab(tab_name):
return jsonify({"error": f"Unknown settings tab: {tab_name}"}), 404
overridable_keys = list(settings_registry.get_user_overridable_fields(tab_name=tab_name))
keys_payload: dict[str, dict[str, Any]] = {}
for user_record in user_db.list_users():
user_settings = user_db.get_user_settings(user_record["id"])
if not isinstance(user_settings, dict):
continue
for key in overridable_keys:
if key not in user_settings or user_settings[key] is None:
continue
entry = keys_payload.setdefault(key, {"count": 0, "users": []})
entry["users"].append({
"userId": user_record["id"],
"username": user_record["username"],
"value": user_settings[key],
})
for summary in keys_payload.values():
summary["count"] = len(summary["users"])
return jsonify({"tab": tab_name, "keys": keys_payload})
@app.route("/api/admin/users/<int:user_id>/effective-settings", methods=["GET"])
@require_admin
def admin_get_effective_settings(user_id):
user = user_db.get_user(user_id=user_id)
if not user:
return jsonify({"error": "User not found"}), 404
from shelfmark.core.config import config as app_config
from shelfmark.core.settings_registry import is_value_from_env
field_map = _get_settings_registry().get_user_overridable_fields()
user_settings = user_db.get_user_settings(user_id)
tab_config_cache: dict[str, dict[str, Any]] = {}
effective: dict[str, dict[str, Any]] = {}
for key, (field, tab_name) in sorted(field_map.items()):
value = app_config.get(key, field.default, user_id=user_id)
source = "default"
if field.env_supported and is_value_from_env(field):
source = "env_var"
elif key in user_settings and user_settings[key] is not None:
source = "user_override"
value = user_settings[key]
else:
tab_config = tab_config_cache.setdefault(tab_name, load_config_file(tab_name))
if key in tab_config:
source = "global_config"
effective[key] = {"value": value, "source": source}
return jsonify(effective)
+172
View File
@@ -0,0 +1,172 @@
"""Authentication mode, auth-source normalization, and admin access policy helpers."""
import os
from typing import Any, Mapping
AUTH_SOURCE_BUILTIN = "builtin"
AUTH_SOURCE_OIDC = "oidc"
AUTH_SOURCE_PROXY = "proxy"
AUTH_SOURCE_CWA = "cwa"
AUTH_SOURCES = (
AUTH_SOURCE_BUILTIN,
AUTH_SOURCE_OIDC,
AUTH_SOURCE_PROXY,
AUTH_SOURCE_CWA,
)
AUTH_SOURCE_SET = frozenset(AUTH_SOURCES)
_ALWAYS_ADMIN_SETTINGS_TABS = frozenset({"security", "users"})
def has_local_password_admin(user_db: Any | None = None) -> bool:
"""Return True when at least one local admin with a password exists."""
try:
db = user_db
if db is None:
from shelfmark.core.user_db import UserDB
config_root = os.environ.get("CONFIG_DIR", "/config")
db = UserDB(os.path.join(config_root, "users.db"))
db.initialize()
return db.has_admin_with_password()
except Exception:
return False
def normalize_auth_source(
source: Any,
oidc_subject: Any = None,
) -> str:
"""Resolve a stable auth source value from persisted fields."""
normalized = str(source or "").strip().lower()
if normalized in AUTH_SOURCE_SET:
return normalized
if oidc_subject:
return AUTH_SOURCE_OIDC
return AUTH_SOURCE_BUILTIN
def determine_auth_mode(
security_config: Mapping[str, Any],
cwa_db_path: Any | None,
*,
has_local_admin: bool = True,
) -> str:
"""Determine active auth mode from security config and runtime prerequisites."""
auth_mode = security_config.get("AUTH_METHOD", "none")
if auth_mode == AUTH_SOURCE_CWA and cwa_db_path:
return AUTH_SOURCE_CWA
if auth_mode == AUTH_SOURCE_BUILTIN and has_local_admin:
return AUTH_SOURCE_BUILTIN
if auth_mode == AUTH_SOURCE_PROXY and security_config.get("PROXY_AUTH_USER_HEADER"):
return AUTH_SOURCE_PROXY
if (
auth_mode == AUTH_SOURCE_OIDC
and has_local_admin
and security_config.get("OIDC_DISCOVERY_URL")
and security_config.get("OIDC_CLIENT_ID")
):
return AUTH_SOURCE_OIDC
return "none"
def _load_security_config() -> dict[str, Any]:
"""Load security settings with environment-backed values applied."""
from shelfmark.core.settings_registry import (
get_setting_value,
get_settings_field_map,
load_config_file,
)
try:
import shelfmark.config.security # noqa: F401
except Exception:
return load_config_file("security")
config = load_config_file("security")
field_map = get_settings_field_map(tab_name="security")
if not field_map:
return config
resolved = dict(config)
for key, (field, tab_name) in field_map.items():
resolved[key] = get_setting_value(field, tab_name)
return resolved
def load_active_auth_mode(
cwa_db_path: Any | None,
*,
user_db: Any | None = None,
) -> str:
"""Resolve active auth mode using current security config and runtime prerequisites."""
try:
security_config = _load_security_config()
return determine_auth_mode(
security_config,
cwa_db_path,
has_local_admin=has_local_password_admin(user_db),
)
except Exception:
return "none"
def is_user_active_for_auth_mode(user: Mapping[str, Any], auth_mode: str) -> bool:
"""Return whether a user can authenticate under the current auth mode."""
source = normalize_auth_source(user.get("auth_source"), user.get("oidc_subject"))
if source == AUTH_SOURCE_BUILTIN:
return auth_mode in (AUTH_SOURCE_BUILTIN, AUTH_SOURCE_OIDC)
return source == auth_mode
def is_settings_or_onboarding_path(path: str) -> bool:
"""Return True when request path targets protected admin settings routes."""
return path.startswith("/api/settings") or path.startswith("/api/onboarding")
def get_settings_tab_from_path(path: str) -> str | None:
"""Extract tab name from /api/settings/<tab>[...] paths."""
if not path.startswith("/api/settings/"):
return None
suffix = path[len("/api/settings/"):]
if not suffix:
return None
return suffix.split("/", 1)[0] or None
def should_restrict_settings_to_admin(
_users_config: Mapping[str, Any],
) -> bool:
"""Settings/onboarding is always admin-only."""
return True
def requires_admin_for_settings_access(
path: str,
users_config: Mapping[str, Any],
) -> bool:
"""Return whether this settings/onboarding request requires admin privileges."""
tab_name = get_settings_tab_from_path(path)
if tab_name in _ALWAYS_ADMIN_SETTINGS_TABS:
return True
return should_restrict_settings_to_admin(users_config)
def get_auth_check_admin_status(
_auth_mode: str,
_users_config: Mapping[str, Any],
session_data: Mapping[str, Any],
) -> bool:
"""Resolve /api/auth/check `is_admin` as the session's real admin role."""
if "user_id" not in session_data:
return False
return bool(session_data.get("is_admin", False))
+8
View File
@@ -62,6 +62,14 @@ class CacheService:
return True
return False
def invalidate_prefix(self, prefix: str) -> int:
"""Remove all cache entries whose keys start with prefix."""
with self._lock:
matching_keys = [key for key in self._cache if key.startswith(prefix)]
for key in matching_keys:
del self._cache[key]
return len(matching_keys)
def clear(self) -> None:
"""Clear all cache entries."""
with self._lock:
+99 -3
View File
@@ -1,11 +1,15 @@
"""Configuration singleton with ENV > config file > default resolution."""
import os
import sqlite3
import time
from threading import Lock
from typing import Any, Dict, Optional
# Import lazily to avoid circular imports
_registry_module = None
_env_module = None
_user_db_module = None
def _get_registry():
@@ -26,6 +30,15 @@ def _get_env():
return _env_module
def _get_user_db_module():
"""Lazy import of user DB module to avoid optional dependency loops."""
global _user_db_module
if _user_db_module is None:
from shelfmark.core.user_db import UserDB
_user_db_module = UserDB
return _user_db_module
class Config:
"""
Dynamic configuration singleton that provides live settings access.
@@ -36,7 +49,6 @@ class Config:
_instance: Optional['Config'] = None
_lock = Lock()
def __new__(cls) -> 'Config':
if cls._instance is None:
with cls._lock:
@@ -51,8 +63,13 @@ class Config:
self._cache: Dict[str, Any] = {}
self._field_map: Dict[str, tuple] = {} # key -> (field, tab_name)
self._cache_lock = Lock()
self._user_settings_cache: Dict[int, Dict[str, Any]] = {}
self._user_settings_cache_lock = Lock()
self._user_db = None
self._user_db_load_attempted = False
self._initialized = True
self._loaded = False
self._last_refresh_time: float = 0.0
def _ensure_loaded(self) -> None:
"""Ensure settings are loaded from the registry."""
@@ -69,6 +86,9 @@ class Config:
# This handles cases where config is accessed before settings are registered
try:
import shelfmark.config.settings # noqa: F401 - main app settings
import shelfmark.config.security # noqa: F401 - security/auth settings
import shelfmark.config.notifications_settings # noqa: F401 - notifications settings
import shelfmark.config.users_settings # noqa: F401 - users/request settings
import shelfmark.release_sources # noqa: F401 - plugin settings
import shelfmark.metadata_providers # noqa: F401 - plugin settings
except ImportError:
@@ -101,29 +121,105 @@ class Config:
self._loaded = True
def refresh(self) -> None:
def refresh(self, force: bool = False) -> None:
"""
Refresh all cached settings from config files.
Call this after settings are updated via the UI to ensure
the config singleton reflects the new values.
Multiple calls within a short window (50 ms) are coalesced to
avoid redundant disk I/O when several helpers each call refresh()
during the same request. Pass ``force=True`` to bypass the guard
(e.g. after a settings write).
"""
now = time.monotonic()
if not force and (now - self._last_refresh_time) < 0.05:
return
with self._cache_lock:
self._loaded = False
self._load_settings()
with self._user_settings_cache_lock:
self._user_settings_cache.clear()
self._user_db = None
self._user_db_load_attempted = False
self._last_refresh_time = time.monotonic()
def get(self, key: str, default: Any = None) -> Any:
def _get_user_db(self):
"""Get or initialize a UserDB handle if available."""
if self._user_db is not None:
return self._user_db
if self._user_db_load_attempted:
return None
self._user_db_load_attempted = True
try:
user_db_cls = _get_user_db_module()
db_path = os.path.join(os.environ.get("CONFIG_DIR", "/config"), "users.db")
user_db = user_db_cls(db_path)
user_db.initialize()
self._user_db = user_db
return self._user_db
except Exception:
# Multi-user support is optional; fall back to global config when unavailable.
return None
def _get_user_settings(self, user_id: int) -> Dict[str, Any]:
"""Get cached per-user settings from user DB."""
with self._user_settings_cache_lock:
if user_id in self._user_settings_cache:
return self._user_settings_cache[user_id]
user_db = self._get_user_db()
if user_db is None:
return {}
try:
settings = user_db.get_user_settings(user_id)
except (sqlite3.OperationalError, OSError, ValueError, TypeError):
return {}
if not isinstance(settings, dict):
settings = {}
with self._user_settings_cache_lock:
self._user_settings_cache[user_id] = settings
return settings
def _get_user_override(self, user_id: int, key: str) -> Any:
"""Get a user override for a specific key."""
user_settings = self._get_user_settings(user_id)
return user_settings.get(key)
def get(self, key: str, default: Any = None, user_id: Optional[int] = None) -> Any:
"""
Get a setting value by key.
Args:
key: The setting key (e.g., 'MAX_RETRY')
default: Default value if setting not found
user_id: Optional DB user ID for per-user setting overrides
Returns:
The setting value, or default if not found
"""
self._ensure_loaded()
if key in self._field_map:
field, _ = self._field_map[key]
registry = _get_registry()
# Deployment-level ENV values always win.
if field.env_supported and registry.is_value_from_env(field):
return self._cache.get(key, default)
# User overrides are only available for explicitly overridable fields.
if user_id is not None and getattr(field, "user_overridable", False):
user_value = self._get_user_override(user_id, key)
if user_value is not None:
return user_value
return self._cache.get(key, default)
def __getattr__(self, name: str) -> Any:
+94
View File
@@ -0,0 +1,94 @@
"""Helpers for provisioning and syncing Calibre-Web users into users.db."""
from __future__ import annotations
from typing import Any, Iterable
from shelfmark.core.auth_modes import AUTH_SOURCE_CWA, normalize_auth_source
from shelfmark.core.external_user_linking import upsert_external_user
from shelfmark.core.user_db import UserDB
_CWA_ALIAS_SUFFIX = "__cwa"
def _normalize_email(value: Any) -> str | None:
if value is None:
return None
email = str(value).strip()
return email or None
def upsert_cwa_user(
user_db: UserDB,
cwa_username: str,
cwa_email: str | None,
role: str,
context: str | None = None,
) -> tuple[dict[str, Any], str]:
"""Create/update a CWA-backed user with collision-safe matching."""
normalized_email = _normalize_email(cwa_email)
collision_strategy = "alias" if normalized_email else "takeover"
user, action = upsert_external_user(
user_db,
auth_source="cwa",
username=cwa_username,
email=normalized_email,
role=role,
allow_email_link=True,
collision_strategy=collision_strategy,
alias_suffix=_CWA_ALIAS_SUFFIX,
context=context,
)
if user is None:
raise RuntimeError("Unexpected CWA user sync result: no user returned")
return user, action
def sync_cwa_users_from_rows(
user_db: UserDB,
rows: Iterable[tuple[Any, Any, Any]],
) -> dict[str, int]:
"""Sync CWA users from raw `(name, role_flags, email)` rows."""
created = 0
updated = 0
active_cwa_user_ids: set[int] = set()
for username, role_flags, email in rows:
normalized_username = str(username or "").strip()
if not normalized_username:
continue
role = "admin" if (int(role_flags or 0) & 1) == 1 else "user"
user, action = upsert_cwa_user(
user_db,
cwa_username=normalized_username,
cwa_email=_normalize_email(email),
role=role,
context="cwa_manual_sync",
)
active_cwa_user_ids.add(int(user["id"]))
if action == "created":
created += 1
else:
updated += 1
deleted = 0
for existing_user in user_db.list_users():
if normalize_auth_source(
existing_user.get("auth_source"),
existing_user.get("oidc_subject"),
) != AUTH_SOURCE_CWA:
continue
existing_id = int(existing_user.get("id") or 0)
if existing_id in active_cwa_user_ids:
continue
user_db.delete_user(existing_id)
deleted += 1
return {
"created": created,
"updated": updated,
"deleted": deleted,
"total": created + updated,
}
+306
View File
@@ -0,0 +1,306 @@
"""Persistence helpers for canonical download activity rows."""
from __future__ import annotations
import os
import sqlite3
import threading
from datetime import datetime, timezone
from typing import Any
from shelfmark.core.logger import setup_logger
from shelfmark.core.models import TERMINAL_QUEUE_STATUSES
from shelfmark.core.request_helpers import normalize_optional_positive_int, normalize_optional_text, now_utc_iso
logger = setup_logger(__name__)
VALID_TERMINAL_STATUSES = frozenset(s.value for s in TERMINAL_QUEUE_STATUSES)
ACTIVE_DOWNLOAD_STATUS = "active"
VALID_ORIGINS = frozenset({"direct", "requested"})
def _normalize_task_id(task_id: Any) -> str:
normalized = normalize_optional_text(task_id)
if normalized is None:
raise ValueError("task_id must be a non-empty string")
return normalized
def _normalize_origin(origin: Any) -> str:
normalized = normalize_optional_text(origin)
if normalized is None:
return "direct"
lowered = normalized.lower()
if lowered not in VALID_ORIGINS:
raise ValueError("origin must be one of: direct, requested")
return lowered
def _normalize_final_status(final_status: Any) -> str:
normalized = normalize_optional_text(final_status)
if normalized is None:
raise ValueError("final_status must be a non-empty string")
lowered = normalized.lower()
if lowered not in VALID_TERMINAL_STATUSES:
raise ValueError("final_status must be one of: complete, error, cancelled")
return lowered
def _normalize_limit(value: Any, *, default: int, minimum: int, maximum: int) -> int:
if value is None:
return default
try:
parsed = int(value)
except (TypeError, ValueError) as exc:
raise ValueError("limit must be an integer") from exc
if parsed < minimum:
return minimum
if parsed > maximum:
return maximum
return parsed
class DownloadHistoryService:
"""Service for persisted canonical download activity rows."""
def __init__(self, db_path: str):
self._db_path = db_path
self._lock = threading.Lock()
def _connect(self) -> sqlite3.Connection:
conn = sqlite3.connect(self._db_path)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA foreign_keys = ON")
return conn
@staticmethod
def _row_to_dict(row: sqlite3.Row | None) -> dict[str, Any] | None:
return dict(row) if row is not None else None
@staticmethod
def _to_item_key(task_id: str) -> str:
return f"download:{task_id}"
@staticmethod
def _resolve_existing_download_path(value: Any) -> str | None:
normalized = normalize_optional_text(value)
if normalized is None:
return None
return normalized if os.path.exists(normalized) else None
@staticmethod
def to_download_payload(row: dict[str, Any]) -> dict[str, Any]:
return {
"id": row.get("task_id"),
"title": row.get("title"),
"author": row.get("author"),
"format": row.get("format"),
"size": row.get("size"),
"preview": row.get("preview"),
"content_type": row.get("content_type"),
"source": row.get("source"),
"source_display_name": row.get("source_display_name"),
"status_message": row.get("status_message"),
"download_path": DownloadHistoryService._resolve_existing_download_path(row.get("download_path")),
"added_time": DownloadHistoryService._iso_to_epoch(row.get("queued_at")),
"user_id": row.get("user_id"),
"username": row.get("username"),
"request_id": row.get("request_id"),
}
@staticmethod
def _iso_to_epoch(value: Any) -> float | None:
if not isinstance(value, str) or not value.strip():
return None
normalized = value.strip().replace("Z", "+00:00")
try:
parsed = datetime.fromisoformat(normalized)
except ValueError:
return None
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed.timestamp()
@classmethod
def to_history_row(cls, row: dict[str, Any], *, dismissed_at: str) -> dict[str, Any]:
task_id = str(row.get("task_id") or "").strip()
item_key = cls._to_item_key(task_id)
download_payload = cls.to_download_payload(row)
# Clear stale progress messages for non-error terminal states.
if row.get("final_status") in ("complete", "cancelled"):
download_payload["status_message"] = None
return {
"id": item_key,
"user_id": row.get("user_id"),
"item_type": "download",
"item_key": item_key,
"dismissed_at": dismissed_at,
"snapshot": {
"kind": "download",
"download": download_payload,
},
"origin": row.get("origin"),
"final_status": row.get("final_status"),
"terminal_at": row.get("terminal_at"),
"request_id": row.get("request_id"),
"source_id": task_id or None,
}
def record_download(
self,
*,
task_id: str,
user_id: int | None,
username: str | None,
request_id: int | None,
source: str,
source_display_name: str | None,
title: str,
author: str | None,
format: str | None,
size: str | None,
preview: str | None,
content_type: str | None,
origin: str,
) -> None:
"""Record a download at queue time with final_status='active'.
On first queue: inserts a new row.
On retry (row already exists): resets the row back to 'active'
so the normal finalize path works when the retry completes.
"""
normalized_task_id = _normalize_task_id(task_id)
normalized_user_id = normalize_optional_positive_int(user_id, "user_id")
normalized_request_id = normalize_optional_positive_int(request_id, "request_id")
normalized_source = normalize_optional_text(source)
if normalized_source is None:
raise ValueError("source must be a non-empty string")
normalized_title = normalize_optional_text(title)
if normalized_title is None:
raise ValueError("title must be a non-empty string")
normalized_origin = _normalize_origin(origin)
recorded_at = now_utc_iso()
with self._lock:
conn = self._connect()
try:
conn.execute(
"""
INSERT INTO download_history (
task_id, user_id, username, request_id,
source, source_display_name,
title, author, format, size, preview, content_type,
origin, final_status,
status_message, download_path,
queued_at, terminal_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', NULL, NULL, ?, ?)
ON CONFLICT(task_id) DO UPDATE SET
final_status = 'active',
status_message = NULL,
download_path = NULL,
terminal_at = ?
""",
(
normalized_task_id,
normalized_user_id,
normalize_optional_text(username),
normalized_request_id,
normalized_source,
normalize_optional_text(source_display_name),
normalized_title,
normalize_optional_text(author),
normalize_optional_text(format),
normalize_optional_text(size),
normalize_optional_text(preview),
normalize_optional_text(content_type),
normalized_origin,
recorded_at,
recorded_at,
recorded_at,
),
)
conn.commit()
finally:
conn.close()
def finalize_download(
self,
*,
task_id: str,
final_status: str,
status_message: str | None = None,
download_path: str | None = None,
) -> None:
"""Update an existing download row to its terminal state."""
normalized_task_id = _normalize_task_id(task_id)
normalized_final_status = _normalize_final_status(final_status)
normalized_status_message = normalize_optional_text(status_message)
normalized_download_path = normalize_optional_text(download_path)
effective_terminal_at = now_utc_iso()
with self._lock:
conn = self._connect()
try:
cursor = conn.execute(
"""
UPDATE download_history
SET final_status = ?,
status_message = ?,
download_path = ?,
terminal_at = ?
WHERE task_id = ? AND final_status = 'active'
""",
(
normalized_final_status,
normalized_status_message,
normalized_download_path,
effective_terminal_at,
normalized_task_id,
),
)
rowcount = int(cursor.rowcount) if cursor.rowcount is not None else 0
if rowcount < 1:
logger.warning(
"finalize_download: no active row found for task_id=%s (may have been missed at queue time)",
normalized_task_id,
)
conn.commit()
finally:
conn.close()
def get_by_task_id(self, task_id: str) -> dict[str, Any] | None:
normalized_task_id = _normalize_task_id(task_id)
conn = self._connect()
try:
row = conn.execute(
"SELECT * FROM download_history WHERE task_id = ?",
(normalized_task_id,),
).fetchone()
return self._row_to_dict(row)
finally:
conn.close()
def list_recent(
self,
*,
user_id: int | None,
limit: int = 200,
) -> list[dict[str, Any]]:
normalized_user_id = normalize_optional_positive_int(user_id, "user_id")
normalized_limit = _normalize_limit(limit, default=200, minimum=1, maximum=1000)
query = "SELECT * FROM download_history"
params: list[Any] = []
if normalized_user_id is not None:
query += " WHERE user_id = ?"
params.append(normalized_user_id)
query += " ORDER BY terminal_at DESC, id DESC LIMIT ?"
params.append(normalized_limit)
conn = self._connect()
try:
rows = conn.execute(query, params).fetchall()
return [dict(row) for row in rows]
finally:
conn.close()
+283
View File
@@ -0,0 +1,283 @@
"""Shared external identity matching and provisioning helpers."""
from __future__ import annotations
import re
from typing import Any, Literal
from shelfmark.core.auth_modes import normalize_auth_source
from shelfmark.core.logger import setup_logger
from shelfmark.core.user_db import UserDB
UNSET = object()
CollisionStrategy = Literal["takeover", "suffix", "alias"]
MatchReason = Literal[
"subject_match",
"existing_source_username_match",
"unique_email_match",
]
logger = setup_logger(__name__)
def _normalize_username(value: Any) -> str:
return str(value or "").strip()
def _normalize_email(value: Any) -> str | None:
if value is None:
return None
email = str(value).strip()
return email or None
def _normalize_display_name(value: Any) -> str | None:
if value is None:
return None
name = str(value).strip()
return name or None
def _email_key(value: str | None) -> str:
return (value or "").strip().lower()
def _normalize_role(value: Any) -> str:
return "admin" if str(value or "").strip().lower() == "admin" else "user"
def _get_by_subject(user_db: UserDB, subject_field: str | None, subject: str | None) -> dict[str, Any] | None:
if not subject_field or not subject:
return None
if subject_field == "oidc_subject":
return user_db.get_user(oidc_subject=subject)
return None
def find_unique_user_by_email(user_db: UserDB, email: str | None) -> dict[str, Any] | None:
key = _email_key(_normalize_email(email))
if not key:
return None
matches = [u for u in user_db.list_users() if _email_key(u.get("email")) == key]
return matches[0] if len(matches) == 1 else None
def find_external_user_match(
user_db: UserDB,
*,
auth_source: str,
username: str,
email: str | None,
subject_field: str | None = None,
subject: str | None = None,
allow_email_link: bool = False,
) -> tuple[dict[str, Any] | None, MatchReason | None]:
"""Find an existing local user that should be linked to an external identity."""
normalized_username = _normalize_username(username)
normalized_email = _normalize_email(email)
by_subject = _get_by_subject(user_db, subject_field, subject)
if by_subject is not None:
return by_subject, "subject_match"
by_username = user_db.get_user(username=normalized_username)
if by_username and normalize_auth_source(
by_username.get("auth_source"),
by_username.get("oidc_subject"),
) == auth_source:
return by_username, "existing_source_username_match"
if allow_email_link:
return find_unique_user_by_email(user_db, normalized_email), "unique_email_match"
return None, None
def _build_updates(
*,
auth_source: str,
role: str,
sync_role: bool,
email: str | None | object,
display_name: str | None | object,
subject_field: str | None,
subject: str | None,
) -> dict[str, Any]:
updates: dict[str, Any] = {"auth_source": auth_source}
if sync_role:
updates["role"] = _normalize_role(role)
if email is not UNSET:
updates["email"] = _normalize_email(email)
if display_name is not UNSET:
updates["display_name"] = _normalize_display_name(display_name)
if subject_field == "oidc_subject" and subject:
updates["oidc_subject"] = subject
return updates
def _next_suffix_username(user_db: UserDB, base_username: str) -> str:
candidate = base_username
suffix = 1
while user_db.get_user(username=candidate):
candidate = f"{base_username}_{suffix}"
suffix += 1
return candidate
def _find_existing_alias_user(
user_db: UserDB,
*,
auth_source: str,
alias_base: str,
) -> dict[str, Any] | None:
pattern = re.compile(rf"^{re.escape(alias_base)}(?:_\d+)?$")
candidates = [
user for user in user_db.list_users()
if pattern.match(str(user.get("username") or ""))
and normalize_auth_source(user.get("auth_source"), user.get("oidc_subject")) == auth_source
]
if not candidates:
return None
return sorted(candidates, key=lambda user: int(user.get("id") or 0))[0]
def _resolve_create_username(
user_db: UserDB,
*,
auth_source: str,
requested_username: str,
strategy: CollisionStrategy,
alias_suffix: str,
) -> tuple[str | None, dict[str, Any] | None, str]:
existing = user_db.get_user(username=requested_username)
if not existing:
return requested_username, None, "new_username_available"
if strategy == "takeover":
return None, existing, "username_collision_takeover"
if strategy == "suffix":
return _next_suffix_username(user_db, requested_username), None, "username_collision_suffix"
alias_base = f"{requested_username}{alias_suffix}"
alias_existing = _find_existing_alias_user(
user_db,
auth_source=auth_source,
alias_base=alias_base,
)
if alias_existing is not None:
return None, alias_existing, "reuse_existing_alias"
return _next_suffix_username(user_db, alias_base), None, "username_collision_alias"
def upsert_external_user(
user_db: UserDB,
*,
auth_source: str,
username: str,
role: str,
email: str | None | object = UNSET,
display_name: str | None | object = UNSET,
subject_field: str | None = None,
subject: str | None = None,
allow_email_link: bool = False,
sync_role: bool = True,
allow_create: bool = True,
collision_strategy: CollisionStrategy = "takeover",
alias_suffix: str | None = None,
context: str | None = None,
) -> tuple[dict[str, Any] | None, str]:
"""Create/update a user from an external auth identity.
Returns `(user, action)` where action is one of:
- `"updated"`
- `"created"`
- `"not_found"` (when `allow_create=False` and no link target exists)
"""
normalized_username = _normalize_username(username)
if not normalized_username:
raise ValueError("External username is required")
normalized_email = _normalize_email(email) if email is not UNSET else None
normalized_display_name = (
_normalize_display_name(display_name) if display_name is not UNSET else None
)
normalized_role = _normalize_role(role)
matched, match_reason = find_external_user_match(
user_db,
auth_source=auth_source,
username=normalized_username,
email=normalized_email,
subject_field=subject_field,
subject=subject,
allow_email_link=allow_email_link,
)
updates = _build_updates(
auth_source=auth_source,
role=normalized_role,
sync_role=sync_role,
email=normalized_email if email is not UNSET else UNSET,
display_name=normalized_display_name if display_name is not UNSET else UNSET,
subject_field=subject_field,
subject=subject,
)
if matched is not None:
user_db.update_user(matched["id"], **updates)
mapped = user_db.get_user(user_id=matched["id"]) or matched
logger.info(
"External user mapped to existing Shelfmark user "
f"(source={auth_source}, context={context or 'unspecified'}, reason={match_reason}, "
f"external_username={normalized_username}, shelfmark_user_id={mapped['id']}, "
f"shelfmark_username={mapped['username']})"
)
return mapped, "updated"
if not allow_create:
logger.info(
"External user could not be mapped and creation is disabled "
f"(source={auth_source}, context={context or 'unspecified'}, "
f"external_username={normalized_username})"
)
return None, "not_found"
resolved_alias_suffix = alias_suffix or f"__{auth_source}"
create_username, takeover_target, create_reason = _resolve_create_username(
user_db,
auth_source=auth_source,
requested_username=normalized_username,
strategy=collision_strategy,
alias_suffix=resolved_alias_suffix,
)
if takeover_target is not None:
user_db.update_user(takeover_target["id"], **updates)
mapped = user_db.get_user(user_id=takeover_target["id"]) or takeover_target
logger.info(
"External user mapped to existing Shelfmark user "
f"(source={auth_source}, context={context or 'unspecified'}, reason={create_reason}, "
f"external_username={normalized_username}, shelfmark_user_id={mapped['id']}, "
f"shelfmark_username={mapped['username']})"
)
return mapped, "updated"
create_kwargs: dict[str, Any] = {
"username": create_username,
"auth_source": auth_source,
"role": normalized_role,
}
if email is not UNSET:
create_kwargs["email"] = normalized_email
if display_name is not UNSET:
create_kwargs["display_name"] = normalized_display_name
if subject_field == "oidc_subject" and subject:
create_kwargs["oidc_subject"] = subject
created = user_db.create_user(**create_kwargs)
logger.info(
"External user created Shelfmark user "
f"(source={auth_source}, context={context or 'unspecified'}, reason={create_reason}, "
f"external_username={normalized_username}, shelfmark_user_id={created['id']}, "
f"shelfmark_username={created['username']})"
)
return created, "created"
+34
View File
@@ -1,16 +1,20 @@
"""Disk-based image cache with LRU eviction."""
import ipaddress
import json
import os
import socket
import threading
import time
from io import BytesIO
from pathlib import Path
from typing import Any, Dict, Optional, Tuple
from urllib.parse import urlparse
import requests
from shelfmark.core.logger import setup_logger
from shelfmark.download.network import get_ssl_verify
logger = setup_logger(__name__)
@@ -465,6 +469,32 @@ class ImageCacheService:
'hit_rate': round(hit_rate, 1),
}
@staticmethod
def _is_safe_url(url: str) -> bool:
"""Check that a URL is safe to fetch (no SSRF to internal resources)."""
try:
parsed = urlparse(url)
except Exception:
return False
if parsed.scheme not in ('http', 'https'):
return False
hostname = parsed.hostname
if not hostname:
return False
try:
resolved = socket.getaddrinfo(hostname, None)
for _, _, _, _, sockaddr in resolved:
ip = ipaddress.ip_address(sockaddr[0])
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved:
return False
except (socket.gaierror, ValueError):
return False
return True
def fetch_and_cache(self, cache_id: str, url: str) -> Optional[Tuple[bytes, str]]:
"""Fetch an image from URL and cache it.
@@ -476,12 +506,16 @@ class ImageCacheService:
Tuple of (image_data, content_type) or None on failure
"""
try:
if not self._is_safe_url(url):
logger.warning(f"Blocked request to disallowed URL: {url}")
return None
response = requests.get(
url,
timeout=(5, 10),
headers=FETCH_HEADERS,
stream=True,
verify=get_ssl_verify(url),
)
response.raise_for_status()
+29 -13
View File
@@ -39,22 +39,38 @@ class CustomLogger(logging.Logger):
self.debug(msg, *args, exc_info=has_exception, **kwargs)
def log_resource_usage(self):
import psutil
# Best-effort only; this should never raise during exception logging.
try:
import psutil
# Sum RSS of all processes for actual app memory
app_memory_mb = 0
for proc in psutil.process_iter(['memory_info']):
# Sum RSS of all processes for actual app memory (container-friendly),
# but fall back gracefully on platforms that restrict process enumeration.
app_memory_mb = 0.0
try:
if proc.info['memory_info']:
app_memory_mb += proc.info['memory_info'].rss / (1024 * 1024)
except (psutil.NoSuchProcess, psutil.AccessDenied):
continue
for proc in psutil.process_iter(['memory_info']):
try:
mem = proc.info.get('memory_info')
if mem:
app_memory_mb += mem.rss / (1024 * 1024)
except (psutil.NoSuchProcess, psutil.AccessDenied, KeyError, AttributeError):
continue
except (PermissionError, psutil.AccessDenied, OSError):
try:
app_memory_mb = psutil.Process().memory_info().rss / (1024 * 1024)
except Exception:
app_memory_mb = 0.0
memory = psutil.virtual_memory()
system_used_mb = memory.used / (1024 * 1024)
available_mb = memory.available / (1024 * 1024)
cpu_percent = psutil.cpu_percent()
self.debug(f"Container Memory: App={app_memory_mb:.2f} MB, System={system_used_mb:.2f} MB, Available={available_mb:.2f} MB, CPU: {cpu_percent:.2f}%")
memory = psutil.virtual_memory()
system_used_mb = memory.used / (1024 * 1024)
available_mb = memory.available / (1024 * 1024)
cpu_percent = psutil.cpu_percent()
self.debug(
f"Container Memory: App={app_memory_mb:.2f} MB, System={system_used_mb:.2f} MB, "
f"Available={available_mb:.2f} MB, CPU: {cpu_percent:.2f}%"
)
except Exception:
# Avoid breaking the original log call if psutil is missing or restricted.
return
def setup_logger(name: str, log_file: Path = LOG_FILE) -> CustomLogger:
+79 -36
View File
@@ -2,6 +2,8 @@
from typing import List
from shelfmark.core.utils import normalize_http_url
# Lazy import to avoid circular imports
_config_module = None
@@ -17,10 +19,10 @@ def _get_config():
# Default mirror lists (hardcoded fallbacks)
DEFAULT_AA_MIRRORS = [
"https://annas-archive.se",
"https://annas-archive.li",
"https://annas-archive.pm",
"https://annas-archive.in",
"https://annas-archive.gl",
"https://annas-archive.pk",
"https://annas-archive.vg",
"https://annas-archive.gd",
]
DEFAULT_LIBGEN_MIRRORS = [
@@ -44,22 +46,52 @@ DEFAULT_WELIB_MIRRORS = [
]
def _normalize_mirror_url(url: str) -> str:
return normalize_http_url(url, default_scheme="https")
def get_aa_mirrors() -> List[str]:
"""
Get Anna's Archive mirrors from config + defaults.
Get Anna's Archive mirrors.
Returns:
List of AA mirror URLs, starting with defaults then custom additions.
Ordered list of AA mirror URLs.
If AA_MIRROR_URLS is configured, it is treated as the full list.
Otherwise, defaults are used and AA_ADDITIONAL_URLS (legacy) is appended.
Notes:
- The list is used to populate the AA mirror dropdown in Settings.
- When AA_BASE_URL is set to 'auto', mirrors are tried in the order listed.
"""
mirrors = list(DEFAULT_AA_MIRRORS)
config = _get_config()
additional = config.get("AA_ADDITIONAL_URLS", "")
if additional:
for url in additional.split(","):
url = url.strip()
if url and url not in mirrors:
mirrors.append(url)
mirrors: list[str] = []
configured_list = config.get("AA_MIRROR_URLS", None)
if isinstance(configured_list, list):
for url in configured_list:
normalized = _normalize_mirror_url(str(url))
if normalized and normalized not in mirrors:
mirrors.append(normalized)
elif isinstance(configured_list, str) and configured_list.strip():
# Allow comma-separated env/manual configs.
for url in configured_list.split(","):
normalized = _normalize_mirror_url(url)
if normalized and normalized not in mirrors:
mirrors.append(normalized)
if not mirrors:
mirrors = [_normalize_mirror_url(url) for url in DEFAULT_AA_MIRRORS]
mirrors = [url for url in mirrors if url]
# Backwards-compatible append-only behavior for legacy configs/env.
additional = config.get("AA_ADDITIONAL_URLS", "")
if additional:
for url in additional.split(","):
normalized = _normalize_mirror_url(url)
if normalized and normalized not in mirrors:
mirrors.append(normalized)
return mirrors
@@ -71,15 +103,16 @@ def get_libgen_mirrors() -> List[str]:
Returns:
List of LibGen mirror URLs (defaults first, then custom additions).
"""
mirrors = list(DEFAULT_LIBGEN_MIRRORS)
mirrors = [_normalize_mirror_url(url) for url in DEFAULT_LIBGEN_MIRRORS]
mirrors = [url for url in mirrors if url]
config = _get_config()
additional = config.get("LIBGEN_ADDITIONAL_URLS", "")
if additional:
for url in additional.split(","):
url = url.strip()
if url and url not in mirrors:
mirrors.append(url)
normalized = _normalize_mirror_url(url)
if normalized and normalized not in mirrors:
mirrors.append(normalized)
return mirrors
@@ -93,21 +126,24 @@ def get_zlib_mirrors() -> List[str]:
"""
config = _get_config()
primary = config.get("ZLIB_PRIMARY_URL", DEFAULT_ZLIB_MIRRORS[0])
primary = _normalize_mirror_url(config.get("ZLIB_PRIMARY_URL", DEFAULT_ZLIB_MIRRORS[0]))
if not primary:
primary = _normalize_mirror_url(DEFAULT_ZLIB_MIRRORS[0])
mirrors = [primary]
# Add other defaults (excluding primary)
for url in DEFAULT_ZLIB_MIRRORS:
if url != primary:
mirrors.append(url)
normalized = _normalize_mirror_url(url)
if normalized and normalized != primary:
mirrors.append(normalized)
# Add custom mirrors
additional = config.get("ZLIB_ADDITIONAL_URLS", "")
if additional:
for url in additional.split(","):
url = url.strip()
if url and url not in mirrors:
mirrors.append(url)
normalized = _normalize_mirror_url(url)
if normalized and normalized not in mirrors:
mirrors.append(normalized)
return mirrors
@@ -120,7 +156,8 @@ def get_zlib_primary_url() -> str:
Primary Z-Library mirror URL.
"""
config = _get_config()
return config.get("ZLIB_PRIMARY_URL", DEFAULT_ZLIB_MIRRORS[0])
primary = _normalize_mirror_url(config.get("ZLIB_PRIMARY_URL", DEFAULT_ZLIB_MIRRORS[0]))
return primary or _normalize_mirror_url(DEFAULT_ZLIB_MIRRORS[0])
def get_zlib_url_template() -> str:
@@ -143,21 +180,24 @@ def get_welib_mirrors() -> List[str]:
"""
config = _get_config()
primary = config.get("WELIB_PRIMARY_URL", DEFAULT_WELIB_MIRRORS[0])
primary = _normalize_mirror_url(config.get("WELIB_PRIMARY_URL", DEFAULT_WELIB_MIRRORS[0]))
if not primary:
primary = _normalize_mirror_url(DEFAULT_WELIB_MIRRORS[0])
mirrors = [primary]
# Add other defaults (excluding primary)
for url in DEFAULT_WELIB_MIRRORS:
if url != primary:
mirrors.append(url)
normalized = _normalize_mirror_url(url)
if normalized and normalized != primary:
mirrors.append(normalized)
# Add custom mirrors
additional = config.get("WELIB_ADDITIONAL_URLS", "")
if additional:
for url in additional.split(","):
url = url.strip()
if url and url not in mirrors:
mirrors.append(url)
normalized = _normalize_mirror_url(url)
if normalized and normalized not in mirrors:
mirrors.append(normalized)
return mirrors
@@ -170,7 +210,8 @@ def get_welib_primary_url() -> str:
Primary Welib mirror URL.
"""
config = _get_config()
return config.get("WELIB_PRIMARY_URL", DEFAULT_WELIB_MIRRORS[0])
primary = _normalize_mirror_url(config.get("WELIB_PRIMARY_URL", DEFAULT_WELIB_MIRRORS[0]))
return primary or _normalize_mirror_url(DEFAULT_WELIB_MIRRORS[0])
def get_welib_url_template() -> str:
@@ -197,17 +238,19 @@ def get_zlib_cookie_domains() -> set:
# Add all default domains
for url in DEFAULT_ZLIB_MIRRORS:
domain = url.replace("https://", "").replace("http://", "").split("/")[0]
domains.add(domain)
normalized = _normalize_mirror_url(url)
if normalized:
domain = normalized.replace("https://", "").replace("http://", "").split("/")[0]
domains.add(domain)
# Add custom domains
config = _get_config()
additional = config.get("ZLIB_ADDITIONAL_URLS", "")
if additional:
for url in additional.split(","):
url = url.strip()
if url:
domain = url.replace("https://", "").replace("http://", "").split("/")[0]
normalized = _normalize_mirror_url(url)
if normalized:
domain = normalized.replace("https://", "").replace("http://", "").split("/")[0]
domains.add(domain)
return domains
+25 -52
View File
@@ -2,7 +2,7 @@
from dataclasses import dataclass, field
from pathlib import Path
from typing import Dict, List, Optional
from typing import Any, Dict, List, Optional
from enum import Enum
import re
import time
@@ -35,14 +35,22 @@ class QueueStatus(str, Enum):
"""Enum for possible book queue statuses."""
QUEUED = "queued"
RESOLVING = "resolving"
LOCATING = "locating"
DOWNLOADING = "downloading"
COMPLETE = "complete"
AVAILABLE = "available"
ERROR = "error"
DONE = "done"
CANCELLED = "cancelled"
TERMINAL_QUEUE_STATUSES: frozenset[QueueStatus] = frozenset({
QueueStatus.COMPLETE, QueueStatus.ERROR, QueueStatus.CANCELLED,
})
ACTIVE_QUEUE_STATUSES: frozenset[QueueStatus] = frozenset({
QueueStatus.QUEUED, QueueStatus.RESOLVING, QueueStatus.LOCATING, QueueStatus.DOWNLOADING,
})
class SearchMode(str, Enum):
DIRECT = "direct"
UNIVERSAL = "universal"
@@ -75,6 +83,7 @@ class DownloadTask:
size: Optional[str] = None
preview: Optional[str] = None
content_type: Optional[str] = None # "book (fiction)", "audiobook", "magazine", etc.
source_url: Optional[str] = None # Original release URL used by source-specific handlers
# Series info (for library naming templates)
series_name: Optional[str] = None
@@ -88,6 +97,16 @@ class DownloadTask:
# See SearchMode enum for behavioral differences
search_mode: Optional[SearchMode] = None
# Output selection for post-processing.
# This is captured at queue time so in-flight tasks are not affected if the user changes settings later.
output_mode: Optional[str] = None # e.g. "folder", "booklore", "email"
output_args: Dict[str, Any] = field(default_factory=dict) # Per-output parameters (e.g. email recipient)
# User association (multi-user support)
user_id: Optional[int] = None # DB user ID who queued this download
username: Optional[str] = None # Username for {User} template variable
request_id: Optional[int] = None # Origin request ID when queued from request fulfilment
# Runtime state
priority: int = 0
added_time: float = field(default_factory=time.time)
@@ -95,6 +114,9 @@ class DownloadTask:
status: QueueStatus = QueueStatus.QUEUED
status_message: Optional[str] = None
download_path: Optional[str] = None
last_error_message: Optional[str] = None
last_error_type: Optional[str] = None
staged_path: Optional[str] = None
def __lt__(self, other):
"""Compare tasks for priority queue (lower priority number = higher precedence)."""
@@ -109,55 +131,6 @@ class DownloadTask:
return build_filename(self.title, self.author, self.year, self.format)
@dataclass
class BookInfo:
"""Data class representing book information."""
id: str
title: str
preview: Optional[str] = None
author: Optional[str] = None
publisher: Optional[str] = None
year: Optional[str] = None
language: Optional[str] = None
content: Optional[str] = None
format: Optional[str] = None
size: Optional[str] = None
info: Optional[Dict[str, List[str]]] = None
description: Optional[str] = None
download_urls: List[str] = field(default_factory=list)
download_path: Optional[str] = None
priority: int = 0
progress: Optional[float] = None
status_message: Optional[str] = None # Detailed status message for UI display
added_time: Optional[float] = None # Timestamp when added to queue
source: str = "direct_download" # Release source handler to use for downloads
source_url: Optional[str] = None # Link to source page (e.g., Anna's Archive)
def get_filename(self, fallback_url: Optional[str] = None) -> str:
"""Build sanitized filename: 'Author - Title (Year).format'
Resolves format from self.format, download_urls, or fallback_url.
Args:
fallback_url: URL to extract format from if not already known
Returns:
Sanitized filename safe for filesystem use
"""
# Resolve format if needed
if not self.format:
urls = [self.download_urls[0]] if self.download_urls else []
if fallback_url:
urls.append(fallback_url)
for url in urls:
ext = url.split(".")[-1].lower()
if ext and len(ext) <= 5 and ext.isalnum():
self.format = ext
break
return build_filename(self.title, self.author, self.year, self.format)
@dataclass
class SearchFilters:
"""Filters for book search queries."""
+84 -32
View File
@@ -3,24 +3,35 @@
import os
import re
from pathlib import Path
from typing import Dict, Optional, Union
from typing import Dict, Optional, Union, Mapping
from shelfmark.core.logger import setup_logger
logger = setup_logger(__name__)
TOKEN_PATTERN = re.compile(
r'\{([- ._/\[(]*)' # prefix: space, dash, dot, underscore, slash, brackets
r'([A-Za-z]+)' # token name
r'([- ._/\])]*)\}' # suffix: space, dash, dot, underscore, slash, brackets
)
# Known variable tokens, sorted longest-first to avoid partial matches
# e.g., "SeriesPosition" must match before "Series"
KNOWN_TOKENS = [
'seriesposition',
'originalname',
'partnumber',
'subtitle',
'author',
'series',
'title',
'year',
'user',
]
# Match any {...} block for template parsing
BRACE_PATTERN = re.compile(r'\{([^}]+)\}')
# Characters that are invalid in filenames on various filesystems
INVALID_CHARS = re.compile(r'[\\:*?"<>|]')
INVALID_CHARS = re.compile(r'[\\/:*?"<>|]')
def _sanitize(name: str, max_length: int = 245) -> str:
def _sanitize(name: Optional[str], max_length: int = 245) -> str:
"""Sanitize a string for filesystem use."""
if not name:
return ""
@@ -31,7 +42,7 @@ def _sanitize(name: str, max_length: int = 245) -> str:
return sanitized[:max_length]
def sanitize_filename(name: str, max_length: int = 245) -> str:
def sanitize_filename(name: Optional[str], max_length: int = 245) -> str:
"""Sanitize a string for use as a filename or path component."""
return _sanitize(name, max_length)
@@ -40,7 +51,7 @@ def sanitize_filename(name: str, max_length: int = 245) -> str:
sanitize_path_component = sanitize_filename
def format_series_position(position: Optional[Union[int, float]]) -> str:
def format_series_position(position: Optional[Union[str, int, float]]) -> str:
if position is None:
return ""
@@ -78,7 +89,9 @@ def assign_part_numbers(
def parse_naming_template(
template: str,
metadata: Dict[str, Optional[Union[str, int, float]]],
metadata: Mapping[str, Optional[Union[str, int, float]]],
*,
allow_path_separators: bool = True,
) -> str:
if not template:
return ""
@@ -86,35 +99,74 @@ def parse_naming_template(
# Normalize metadata keys to lowercase for case-insensitive matching
normalized = {k.lower(): v for k, v in metadata.items()}
def replace_token(match: re.Match) -> str:
prefix = match.group(1)
token_name = match.group(2).lower()
suffix = match.group(3)
def find_token(content: str) -> tuple[Optional[str], int]:
content_lower = content.lower()
for token in KNOWN_TOKENS:
idx = content_lower.find(token)
if idx != -1:
return token, idx
return None, -1
# Get the value for this token
value = normalized.get(token_name)
# Special handling for series position
if token_name == 'seriesposition':
def token_value(token: str) -> str:
value = normalized.get(token)
if token == 'seriesposition':
value = format_series_position(value)
# Convert to string
if value is None:
value = ""
else:
value = str(value).strip()
return ""
return str(value).strip()
# If value is empty, return empty string (no prefix/suffix)
def render_block(content: str) -> Optional[str]:
token, idx = find_token(content)
if token is None:
return None
prefix = content[:idx]
suffix = content[idx + len(token):]
value = token_value(token)
if not value:
return ""
# Sanitize the value
if not allow_path_separators:
value = value.replace("/", "_")
value = sanitize_filename(value)
return f"{prefix}{value}{suffix}"
# Replace all tokens
result = TOKEN_PATTERN.sub(replace_token, template)
# Process brace blocks in order so we can support conditional literal blocks like:
# { - Part }{PartNumber}
matches = list(BRACE_PATTERN.finditer(template))
if not matches:
result = template
else:
parts: list[str] = []
cursor = 0
for idx, match in enumerate(matches):
parts.append(template[cursor:match.start()])
content = match.group(1)
rendered = render_block(content)
if rendered is not None:
parts.append(rendered)
else:
conditional_literal = False
include_literal = False
if idx + 1 < len(matches) and match.end() == matches[idx + 1].start():
next_content = matches[idx + 1].group(1)
next_token, _next_idx = find_token(next_content)
if next_token is not None:
conditional_literal = True
include_literal = bool(token_value(next_token))
if include_literal:
parts.append(content)
elif not conditional_literal:
# Preserve blocks that look like literal text, but treat bare unknown
# placeholders as missing variables.
if re.search(r"\s", content):
parts.append(match.group(0))
cursor = match.end()
parts.append(template[cursor:])
result = "".join(parts)
# Clean up any double slashes that might result from empty tokens
result = re.sub(r'/+', '/', result)
@@ -140,10 +192,10 @@ def parse_naming_template(
def build_library_path(
base_path: str,
template: str,
metadata: Dict[str, Optional[Union[str, int, float]]],
metadata: Mapping[str, Optional[Union[str, int, float]]],
extension: Optional[str] = None,
) -> Path:
relative = parse_naming_template(template, metadata)
relative = parse_naming_template(template, metadata, allow_path_separators=True)
if not relative:
# Fallback to title if template produces empty result
+613
View File
@@ -0,0 +1,613 @@
"""Apprise notification dispatch for global and per-user events."""
from __future__ import annotations
import logging
import threading
from concurrent.futures import ThreadPoolExecutor
from contextlib import contextmanager
from dataclasses import dataclass
from enum import Enum
from typing import Any, Iterable, Iterator
from urllib.parse import urlsplit
try:
import apprise
except Exception: # pragma: no cover - exercised in tests via monkeypatch
apprise = None # type: ignore[assignment]
from shelfmark.core.config import config as app_config
from shelfmark.core.logger import setup_logger
logger = setup_logger(__name__)
# Small pool for non-blocking dispatch. Notification sends are I/O bound and infrequent.
_executor = ThreadPoolExecutor(max_workers=2, thread_name_prefix="Notify")
_ROUTE_EVENT_ALL = "all"
_APPRISE_APP_ID = "Shelfmark"
_APPRISE_APP_DESC = "Shelfmark notifications"
_APPRISE_LOGO_URL = (
"https://raw.githubusercontent.com/calibrain/shelfmark/main/src/frontend/public/logo.png"
)
_APPRISE_LOGGER_NAME = "apprise"
class NotificationEvent(str, Enum):
"""Global notification event identifiers."""
REQUEST_CREATED = "request_created"
REQUEST_FULFILLED = "request_fulfilled"
REQUEST_REJECTED = "request_rejected"
DOWNLOAD_COMPLETE = "download_complete"
DOWNLOAD_FAILED = "download_failed"
@dataclass
class NotificationContext:
"""Context used to render notification templates."""
event: NotificationEvent
title: str
author: str
username: str | None = None
content_type: str | None = None
format: str | None = None
source: str | None = None
admin_note: str | None = None
error_message: str | None = None
def _normalize_urls(value: Any) -> list[str]:
if value is None:
return []
raw_values: list[Any]
if isinstance(value, list):
raw_values = value
elif isinstance(value, str):
# Support legacy/manual configs.
raw_values = [segment for part in value.splitlines() for segment in part.split(",")]
else:
raw_values = [value]
normalized: list[str] = []
seen: set[str] = set()
for raw_url in raw_values:
url = str(raw_url or "").strip()
if not url:
continue
# Strip invisible/non-ASCII characters that can sneak in via copy-paste
# (zero-width spaces, smart quotes, non-breaking spaces, etc.).
# These pass Apprise URL validation but cause UnicodeEncodeError when
# requests tries to latin-1 encode credentials for Basic Auth headers.
url = url.encode("ascii", errors="ignore").decode("ascii").strip()
if not url:
continue
if url in seen:
continue
seen.add(url)
normalized.append(url)
return normalized
def _extract_url_schemes(urls: Iterable[str]) -> list[str]:
schemes: list[str] = []
seen: set[str] = set()
for raw_url in urls:
scheme = urlsplit(str(raw_url or "")).scheme.lower()
if not scheme or scheme in seen:
continue
seen.add(scheme)
schemes.append(scheme)
return schemes
class _AppriseLogCapture(logging.Handler):
def __init__(self, *, thread_id: int):
super().__init__(level=logging.INFO)
self.records: list[tuple[int, str, str, str]] = []
self._thread_id = thread_id
def emit(self, record: logging.LogRecord) -> None:
if record.thread != self._thread_id:
return
message = record.getMessage()
if message:
exception_summary = ""
if record.exc_info and record.exc_info[0]:
exc_type = getattr(record.exc_info[0], "__name__", "Exception")
exc = record.exc_info[1]
exception_summary = f"{exc_type}: {exc}"
elif record.exc_text:
exception_summary = str(record.exc_text).strip()
self.records.append((record.levelno, record.name, str(message), exception_summary))
@contextmanager
def _capture_apprise_logs(*, min_level: int = logging.INFO) -> Iterator[list[tuple[int, str, str, str]]]:
apprise_logger = logging.getLogger(_APPRISE_LOGGER_NAME)
previous_level = apprise_logger.level
handler = _AppriseLogCapture(thread_id=threading.get_ident())
apprise_logger.addHandler(handler)
if previous_level == logging.NOTSET or previous_level > min_level:
apprise_logger.setLevel(min_level)
try:
yield handler.records
finally:
apprise_logger.removeHandler(handler)
apprise_logger.setLevel(previous_level)
def _log_apprise_records(records: Iterable[tuple[int, str, str, str]]) -> None:
seen: set[tuple[int, str, str, str]] = set()
for level, source, raw_message, raw_exception_summary in records:
message = str(raw_message or "").strip()
source_name = str(source or "").strip() or _APPRISE_LOGGER_NAME
exception_summary = str(raw_exception_summary or "").strip()
key = (int(level), source_name, message, exception_summary)
if not message or key in seen:
continue
seen.add(key)
full_message = message if not exception_summary else f"{message} ({exception_summary})"
if level >= logging.ERROR:
logger.error("Apprise source [%s]: %s", source_name, full_message)
elif level >= logging.WARNING:
logger.warning("Apprise source [%s]: %s", source_name, full_message)
else:
logger.info("Apprise source [%s]: %s", source_name, full_message)
def _log_apprise_exception_debug(*, action: str, scheme: str, exc: Exception) -> None:
logger.debug(
"Apprise %s raised %s for scheme '%s': %s",
action,
type(exc).__name__,
scheme,
exc,
exc_info=True,
)
def _build_apprise_warning_detail(
records: Iterable[tuple[int, str, str, str]],
*,
scheme: str,
) -> str | None:
for level, source, raw_message, raw_exception_summary in records:
if level < logging.WARNING:
continue
message = str(raw_message or "").strip()
if not message:
continue
source_name = str(source or "").strip()
exception_summary = str(raw_exception_summary or "").strip()
full_message = message if not exception_summary else f"{message} ({exception_summary})"
if source_name and source_name != _APPRISE_LOGGER_NAME:
return f"{scheme}: {source_name}: {full_message}"
return f"{scheme}: {full_message}"
return None
def _normalize_routes(value: Any) -> list[dict[str, str]]:
if not isinstance(value, list):
return []
allowed_events = {_ROUTE_EVENT_ALL, *(event.value for event in NotificationEvent)}
normalized: list[dict[str, str]] = []
seen: set[tuple[str, str]] = set()
for row in value:
if not isinstance(row, dict):
continue
raw_events = row.get("event")
if isinstance(raw_events, list):
event_values = raw_events
elif isinstance(raw_events, (tuple, set)):
event_values = list(raw_events)
else:
event_values = [raw_events]
url = str(row.get("url") or "").strip()
if not url:
continue
row_events: list[str] = []
for raw_event in event_values:
event = str(raw_event or "").strip().lower()
if event not in allowed_events:
continue
if event in row_events:
continue
row_events.append(event)
if _ROUTE_EVENT_ALL in row_events:
row_events = [_ROUTE_EVENT_ALL]
for event in row_events:
key = (event, url)
if key in seen:
continue
seen.add(key)
normalized.append({"event": event, "url": url})
return normalized
def _resolve_admin_routes() -> list[dict[str, str]]:
return _normalize_routes(app_config.get("ADMIN_NOTIFICATION_ROUTES", []))
def _normalize_user_id(value: Any) -> int | None:
try:
user_id = int(value)
except (TypeError, ValueError):
return None
if user_id < 1:
return None
return user_id
def _resolve_user_routes(user_id: int | None) -> list[dict[str, str]]:
normalized_user_id = _normalize_user_id(user_id)
if normalized_user_id is None:
return []
return _normalize_routes(
app_config.get("USER_NOTIFICATION_ROUTES", [], user_id=normalized_user_id)
)
def _resolve_route_urls_for_event(
routes: list[dict[str, str]],
event: NotificationEvent,
) -> list[str]:
selected: list[str] = []
seen: set[str] = set()
event_value = event.value
for row in routes:
row_event = row.get("event", "")
if row_event not in {_ROUTE_EVENT_ALL, event_value}:
continue
url = row.get("url", "")
if not url or url in seen:
continue
seen.add(url)
selected.append(url)
return selected
def _resolve_notify_type(event: NotificationEvent) -> Any:
if apprise is None:
fallback = {
NotificationEvent.REQUEST_CREATED: "info",
NotificationEvent.REQUEST_FULFILLED: "success",
NotificationEvent.REQUEST_REJECTED: "warning",
NotificationEvent.DOWNLOAD_COMPLETE: "success",
NotificationEvent.DOWNLOAD_FAILED: "failure",
}
return fallback[event]
mapping = {
NotificationEvent.REQUEST_CREATED: apprise.NotifyType.INFO,
NotificationEvent.REQUEST_FULFILLED: apprise.NotifyType.SUCCESS,
NotificationEvent.REQUEST_REJECTED: apprise.NotifyType.WARNING,
NotificationEvent.DOWNLOAD_COMPLETE: apprise.NotifyType.SUCCESS,
NotificationEvent.DOWNLOAD_FAILED: apprise.NotifyType.FAILURE,
}
return mapping[event]
def _clean_text(value: Any, fallback: str) -> str:
text = str(value or "").strip()
return text or fallback
def _render_message(context: NotificationContext) -> tuple[str, str]:
event = context.event
title = _clean_text(context.title, "Unknown title")
author = _clean_text(context.author, "Unknown author")
username = _clean_text(context.username, "A user")
if event == NotificationEvent.REQUEST_CREATED:
return "New Request", f'{username} requested "{title}" by {author}'
if event == NotificationEvent.REQUEST_FULFILLED:
return "Request Approved", f'Request for "{title}" by {author} was approved.'
if event == NotificationEvent.REQUEST_REJECTED:
note = _clean_text(context.admin_note, "")
note_line = f"\nNote: {note}" if note else ""
return "Request Rejected", f'Request for "{title}" by {author} was rejected.{note_line}'
if event == NotificationEvent.DOWNLOAD_COMPLETE:
return "Download Complete", f'"{title}" by {author} downloaded successfully.'
error_message = _clean_text(context.error_message, "")
error_line = f"\nError: {error_message}" if error_message else ""
return "Download Failed", f'Failed to download "{title}" by {author}.{error_line}'
def _plugin_label(plugin: Any, fallback_scheme: str) -> str:
"""Build a human-readable label from a validated Apprise plugin.
Combines the URL scheme with the plugin's service name (app_id) and
privacy-safe URL for richer diagnostics, e.g.
``"slack (Slack - slack://TokenA/To...n/To...n/)"``
"""
parts: list[str] = [fallback_scheme]
app_id = getattr(plugin, "app_id", None)
if app_id and str(app_id) != fallback_scheme:
privacy_url: str | None = None
try:
privacy_url = plugin.url(privacy=True)
except Exception:
pass
suffix = str(app_id)
if privacy_url:
suffix = f"{suffix} - {privacy_url}"
parts.append(f"({suffix})")
return " ".join(parts)
def _dispatch_to_apprise(
urls: Iterable[str],
*,
title: str,
body: str,
notify_type: Any,
) -> dict[str, Any]:
normalized_urls = _normalize_urls(list(urls))
url_schemes = _extract_url_schemes(normalized_urls)
if not normalized_urls:
return {"success": False, "message": "No notification URLs configured"}
if apprise is None:
return {"success": False, "message": "Apprise is not installed"}
valid_urls = 0
invalid_urls = 0
delivered_urls = 0
failed_delivery_urls = 0
failure_details: list[str] = []
for url in normalized_urls:
scheme = urlsplit(url).scheme or "unknown"
apobj = _create_apprise_client()
if apobj is None:
return {"success": False, "message": "Apprise is not installed"}
registration_failure_detail: str | None = None
with _capture_apprise_logs(min_level=logging.INFO) as apprise_records:
try:
plugin = apprise.Apprise.instantiate(url, asset=getattr(apobj, "asset", None))
except Exception as exc:
logger.warning(
"Failed to register notification route URL for scheme '%s': %s",
scheme,
exc,
)
_log_apprise_exception_debug(
action="route registration",
scheme=scheme,
exc=exc,
)
registration_failure_detail = (
f"{scheme}: route registration failed ({type(exc).__name__}: {exc})"
)
failure_details.append(registration_failure_detail)
plugin = None
if plugin is None:
invalid_urls += 1
logger.warning("Apprise rejected notification route URL for scheme '%s'", scheme)
_log_apprise_records(apprise_records)
warning_detail = _build_apprise_warning_detail(apprise_records, scheme=scheme)
if warning_detail:
failure_details.append(warning_detail)
elif registration_failure_detail is None:
failure_details.append(f"{scheme}: route URL rejected by Apprise")
continue
plugin_label = _plugin_label(plugin, scheme)
apobj.add(plugin)
valid_urls += 1
try:
delivered = bool(apobj.notify(title=title, body=body, notify_type=notify_type))
except Exception as exc:
_log_apprise_records(apprise_records)
failed_delivery_urls += 1
logger.warning(
"Apprise notify raised %s for %s: %s",
type(exc).__name__,
plugin_label,
exc,
)
_log_apprise_exception_debug(action="notify", scheme=scheme, exc=exc)
warning_detail = _build_apprise_warning_detail(apprise_records, scheme=scheme)
if warning_detail:
failure_details.append(warning_detail)
else:
failure_details.append(
f"{scheme}: notify raised {type(exc).__name__}: {exc}"
)
continue
_log_apprise_records(apprise_records)
if delivered:
delivered_urls += 1
logger.debug("Notification delivered via %s", plugin_label)
continue
failed_delivery_urls += 1
logger.warning("Apprise notify returned False for %s", plugin_label)
warning_detail = _build_apprise_warning_detail(apprise_records, scheme=scheme)
if warning_detail:
failure_details.append(warning_detail)
else:
failure_details.append(f"{scheme}: delivery failed")
scheme_summary = ", ".join(url_schemes) if url_schemes else "unknown"
if valid_urls == 0:
logger.warning(
"No valid Apprise notification routes after registration for scheme(s): %s",
scheme_summary,
)
result: dict[str, Any] = {
"success": False,
"message": "No valid notification URLs configured",
}
if failure_details:
result["details"] = failure_details
return result
if delivered_urls == 0:
logger.warning(
(
"Apprise notify returned False for scheme(s): %s "
"(valid_urls=%s invalid_urls=%s failed_deliveries=%s)"
),
scheme_summary,
valid_urls,
invalid_urls,
failed_delivery_urls,
)
result = {"success": False, "message": "Notification delivery failed"}
if failure_details:
result["details"] = failure_details
return result
message = f"Notification sent to {delivered_urls} URL(s)"
failed_urls = invalid_urls + failed_delivery_urls
if failed_urls:
message += f" ({failed_urls} URL(s) failed)"
result = {"success": True, "message": message}
if failure_details:
result["details"] = failure_details
return result
def _create_apprise_client() -> Any:
if apprise is None:
return None
apprise_cls = getattr(apprise, "Apprise", None)
if apprise_cls is None:
return None
apprise_asset_cls = getattr(apprise, "AppriseAsset", None)
if apprise_asset_cls is None:
return apprise_cls()
try:
asset = apprise_asset_cls(
app_id=_APPRISE_APP_ID,
app_desc=_APPRISE_APP_DESC,
image_url_logo=_APPRISE_LOGO_URL,
)
except TypeError:
# Support older Apprise versions that do not expose image_url_logo.
asset = apprise_asset_cls(
app_id=_APPRISE_APP_ID,
app_desc=_APPRISE_APP_DESC,
)
except Exception:
return apprise_cls()
try:
return apprise_cls(asset=asset)
except Exception:
return apprise_cls()
def _send_admin_event(event: NotificationEvent, context: NotificationContext, urls: list[str]) -> dict[str, Any]:
title, body = _render_message(context)
notify_type = _resolve_notify_type(event)
return _dispatch_to_apprise(urls, title=title, body=body, notify_type=notify_type)
def notify_admin(event: NotificationEvent, context: NotificationContext) -> None:
"""Send a global admin notification for an event if subscribed."""
routes = _resolve_admin_routes()
urls = _resolve_route_urls_for_event(routes, event)
if not urls:
return
try:
_executor.submit(_dispatch_admin_async, event, context, urls)
except Exception as exc:
logger.warning("Failed to queue admin notification '%s': %s", event.value, exc)
def notify_user(user_id: int | None, event: NotificationEvent, context: NotificationContext) -> None:
"""Send a per-user notification for an event if subscribed."""
normalized_user_id = _normalize_user_id(user_id)
if normalized_user_id is None:
return
routes = _resolve_user_routes(normalized_user_id)
urls = _resolve_route_urls_for_event(routes, event)
if not urls:
return
try:
_executor.submit(_dispatch_user_async, normalized_user_id, event, context, urls)
except Exception as exc:
logger.warning(
"Failed to queue user notification '%s' for user_id=%s: %s",
event.value,
normalized_user_id,
exc,
)
def _dispatch_admin_async(event: NotificationEvent, context: NotificationContext, urls: list[str]) -> None:
result = _send_admin_event(event, context, urls)
if not result.get("success", False):
logger.warning("Admin notification failed for event '%s': %s", event.value, result.get("message"))
def _dispatch_user_async(
user_id: int,
event: NotificationEvent,
context: NotificationContext,
urls: list[str],
) -> None:
result = _send_admin_event(event, context, urls)
if not result.get("success", False):
logger.warning(
"User notification failed for event '%s' (user_id=%s): %s",
event.value,
user_id,
result.get("message"),
)
def send_test_notification(urls: list[str]) -> dict[str, Any]:
"""Send a synchronous test notification to the provided URLs."""
normalized_urls = _normalize_urls(urls)
if not normalized_urls:
return {"success": False, "message": "No notification URLs configured"}
test_context = NotificationContext(
event=NotificationEvent.REQUEST_CREATED,
title="Shelfmark Test Notification",
author="Shelfmark",
username="Shelfmark",
)
return _send_admin_event(NotificationEvent.REQUEST_CREATED, test_context, normalized_urls)
+80
View File
@@ -0,0 +1,80 @@
"""OIDC authentication helpers.
Handles group claim parsing, user info extraction, and user provisioning.
Flask route handlers are registered separately in main.py.
"""
from typing import Any, Dict, List, Optional
from shelfmark.core.external_user_linking import upsert_external_user
from shelfmark.core.user_db import UserDB
def parse_group_claims(id_token: Dict[str, Any], group_claim: str) -> List[str]:
"""Extract group list from an ID token claim.
Supports list, comma-separated string, or pipe-separated string.
Returns empty list if claim is missing.
"""
raw = id_token.get(group_claim)
if raw is None:
return []
if isinstance(raw, list):
return [str(g).strip() for g in raw if str(g).strip()]
if isinstance(raw, str):
delimiter = "," if "," in raw else "|"
return [g.strip() for g in raw.split(delimiter) if g.strip()]
return []
def extract_user_info(id_token: Dict[str, Any]) -> Dict[str, Any]:
"""Extract user info from OIDC ID token claims.
Returns a dict with keys: oidc_subject, username, email, display_name.
Falls back through preferred_username -> email -> sub for username.
"""
sub = id_token.get("sub", "")
email = id_token.get("email")
display_name = id_token.get("name")
username = id_token.get("preferred_username") or email or sub
return {
"oidc_subject": sub,
"username": username,
"email": email,
"display_name": display_name,
}
def provision_oidc_user(
db: UserDB,
user_info: Dict[str, Any],
is_admin: Optional[bool] = None,
allow_email_link: bool = False,
allow_create: bool = True,
) -> Optional[Dict[str, Any]]:
"""Create or update a user from OIDC claims.
Matching and collision handling use the shared external user linker:
- OIDC subject first
- optionally unique email linking (when `allow_email_link=True`)
- username conflict resolution via numeric suffix.
Returns None when no existing user is matchable and `allow_create=False`.
"""
oidc_subject = user_info["oidc_subject"]
user, _ = upsert_external_user(
db,
auth_source="oidc",
username=user_info["username"] or oidc_subject,
role="admin" if is_admin else "user",
email=user_info.get("email"),
display_name=user_info.get("display_name"),
subject_field="oidc_subject",
subject=oidc_subject,
allow_email_link=allow_email_link,
sync_role=is_admin is not None,
allow_create=allow_create,
collision_strategy="suffix",
context="oidc_login",
)
return user
+289
View File
@@ -0,0 +1,289 @@
"""OIDC Flask route handlers using Authlib.
Registers /api/auth/oidc/login and /api/auth/oidc/callback endpoints.
Business logic remains in oidc_auth.py.
"""
from typing import Any
from urllib.parse import urlencode, urlsplit, urlunsplit
from authlib.jose.errors import InvalidClaimError
from authlib.integrations.flask_client import OAuth
from flask import Flask, jsonify, redirect, request, session
from shelfmark.core.logger import setup_logger
from shelfmark.core.oidc_auth import (
extract_user_info,
parse_group_claims,
provision_oidc_user,
)
from shelfmark.core.settings_registry import load_config_file
from shelfmark.core.user_db import UserDB
from shelfmark.download.network import get_ssl_verify
logger = setup_logger(__name__)
oauth = OAuth()
_RETURN_TO_SESSION_KEY = "oidc_return_to"
def _normalize_claims(raw_claims: Any) -> dict[str, Any]:
"""Return a plain dict for claims from Authlib token/userinfo payloads."""
if raw_claims is None:
return {}
if isinstance(raw_claims, dict):
return raw_claims
if hasattr(raw_claims, "to_dict"):
return raw_claims.to_dict() # type: ignore[no-any-return]
try:
return dict(raw_claims)
except Exception:
return {}
def _has_username_or_email(claims: dict[str, Any]) -> bool:
"""Return True when claims include a usable username or email."""
for key in ("preferred_username", "email"):
value = claims.get(key)
if isinstance(value, str) and value.strip():
return True
return False
def _login_error_url(message: str) -> str:
"""Build a login URL (with script_root) that includes an OIDC error message."""
script_root = request.script_root.rstrip("/")
login_url = f"{script_root}/login" if script_root else "/login"
params = {"oidc_error": message}
return_to = _get_pending_return_to()
if return_to and return_to != "/":
params["return_to"] = return_to
return f"{login_url}?{urlencode(params)}"
def _normalize_return_to(raw_return_to: Any) -> str | None:
"""Return a safe app-relative post-login target."""
if not isinstance(raw_return_to, str):
return None
value = raw_return_to.strip()
if not value or not value.startswith("/") or value.startswith("//"):
return None
parsed = urlsplit(value)
if parsed.scheme or parsed.netloc:
return None
script_root = request.script_root.rstrip("/")
path = parsed.path or "/"
if script_root:
if path == script_root:
path = "/"
elif path.startswith(f"{script_root}/"):
path = path[len(script_root):] or "/"
if (
path == "/login"
or path.startswith("/login/")
or path == "/api"
or path.startswith("/api/")
):
return None
return urlunsplit(("", "", path, parsed.query, parsed.fragment))
def _get_pending_return_to(*, clear: bool = False) -> str | None:
"""Read the pending post-login target from the session."""
raw_return_to = (
session.pop(_RETURN_TO_SESSION_KEY, None)
if clear
else session.get(_RETURN_TO_SESSION_KEY)
)
normalized = _normalize_return_to(raw_return_to)
if normalized is None and not clear:
session.pop(_RETURN_TO_SESSION_KEY, None)
return normalized
def _post_login_redirect_target(return_to: str | None) -> str:
"""Build the final redirect target, honoring script_root when present."""
normalized = _normalize_return_to(return_to) or "/"
script_root = request.script_root.rstrip("/")
if not script_root:
return normalized
if normalized == "/":
return f"{script_root}/"
return f"{script_root}{normalized}"
def _get_oidc_client() -> tuple[Any, dict[str, Any]]:
"""Register and return an OIDC client from the current security config."""
config = load_config_file("security")
discovery_url = config.get("OIDC_DISCOVERY_URL", "")
client_id = config.get("OIDC_CLIENT_ID", "")
if not discovery_url or not client_id:
raise ValueError("OIDC not configured")
configured_scopes = config.get("OIDC_SCOPES", ["openid", "email", "profile"])
if isinstance(configured_scopes, list):
scope_values = [str(scope).strip() for scope in configured_scopes if str(scope).strip()]
elif isinstance(configured_scopes, str):
delimiter = "," if "," in configured_scopes else " "
scope_values = [scope.strip() for scope in configured_scopes.split(delimiter) if scope.strip()]
else:
scope_values = []
scopes = list(dict.fromkeys(["openid"] + scope_values))
admin_group = config.get("OIDC_ADMIN_GROUP", "")
group_claim = config.get("OIDC_GROUP_CLAIM", "groups")
use_admin_group = config.get("OIDC_USE_ADMIN_GROUP", True)
if admin_group and use_admin_group and group_claim and group_claim not in scopes:
scopes.append(group_claim)
def _ssl_compliance_fix(session, **kwargs):
"""Set session.verify based on the Certificate Validation setting."""
session.verify = get_ssl_verify(discovery_url)
return session
oauth._clients.pop("shelfmark_idp", None)
oauth.register(
name="shelfmark_idp",
client_id=client_id,
client_secret=config.get("OIDC_CLIENT_SECRET", ""),
server_metadata_url=discovery_url,
client_kwargs={
"scope": " ".join(scopes),
"code_challenge_method": "S256",
},
compliance_fix=_ssl_compliance_fix,
overwrite=True,
)
client = oauth.create_client("shelfmark_idp")
if client is None:
raise RuntimeError("OIDC client initialization failed")
return client, config
def register_oidc_routes(app: Flask, user_db: UserDB) -> None:
"""Register OIDC authentication routes on the Flask app."""
oauth.init_app(app)
@app.route("/api/auth/oidc/login", methods=["GET"])
def oidc_login():
"""Initiate OIDC login flow and redirect to the provider."""
try:
client, _ = _get_oidc_client()
return_to = _normalize_return_to(request.args.get("return_to"))
if return_to and return_to != "/":
session[_RETURN_TO_SESSION_KEY] = return_to
else:
session.pop(_RETURN_TO_SESSION_KEY, None)
redirect_uri = request.url_root.rstrip("/") + "/api/auth/oidc/callback"
return client.authorize_redirect(redirect_uri)
except ValueError:
return jsonify({"error": "OIDC not configured"}), 500
except Exception as e:
logger.error(f"OIDC login error: {e}")
return jsonify({"error": "OIDC login failed"}), 500
@app.route("/api/auth/oidc/callback", methods=["GET"])
def oidc_callback():
"""Handle OIDC callback from identity provider."""
try:
error = request.args.get("error")
if error:
logger.warning(f"OIDC callback error from IdP: {error}")
return redirect(_login_error_url("Authentication failed"))
client, config = _get_oidc_client()
try:
token = client.authorize_access_token()
except InvalidClaimError as e:
claim_name = getattr(e, "claim_name", "unknown")
discovery_url = str(config.get("OIDC_DISCOVERY_URL", ""))
provider_issuer = ""
try:
metadata = client.load_server_metadata()
if isinstance(metadata, dict):
provider_issuer = str(metadata.get("issuer", ""))
except Exception as metadata_error:
logger.debug(f"OIDC metadata lookup failed during claim diagnostics: {metadata_error}")
logger.error(
"OIDC callback claim validation failed: claim=%s error=%s discovery_url=%s provider_issuer=%s",
claim_name,
e,
discovery_url or "<unset>",
provider_issuer or "<unknown>",
)
if claim_name == "iss":
msg = (
"OIDC issuer validation failed. Verify your discovery URL and IdP issuer/"
"external URL configuration."
)
return redirect(_login_error_url(msg))
return redirect(_login_error_url(f"OIDC token claim validation failed: {claim_name}"))
claims = _normalize_claims(token.get("userinfo"))
# If userinfo is missing or claims are too sparse, request it explicitly.
if not claims or not _has_username_or_email(claims):
fetched_claims: dict[str, Any] = {}
try:
fetched_claims = _normalize_claims(client.userinfo(token=token))
except TypeError:
fetched_claims = _normalize_claims(client.userinfo())
except Exception as e:
logger.error(f"Failed to fetch OIDC userinfo: {e}")
if fetched_claims:
claims = {**claims, **fetched_claims}
if not claims:
msg = "OIDC authentication failed: missing user claims"
logger.error(msg)
return redirect(_login_error_url(msg))
group_claim = config.get("OIDC_GROUP_CLAIM", "groups")
admin_group = config.get("OIDC_ADMIN_GROUP", "")
use_admin_group = config.get("OIDC_USE_ADMIN_GROUP", True)
auto_provision = config.get("OIDC_AUTO_PROVISION", True)
user_info = extract_user_info(claims)
groups = parse_group_claims(claims, group_claim)
is_admin = None
if admin_group and use_admin_group:
is_admin = admin_group in groups
allow_email_link = bool(user_info.get("email"))
user = provision_oidc_user(
user_db,
user_info,
is_admin=is_admin,
allow_email_link=allow_email_link,
allow_create=bool(auto_provision),
)
if user is None:
logger.warning(
f"OIDC login rejected: auto-provision disabled for {user_info['username']}"
)
return redirect(_login_error_url("Account not found. Contact your administrator."))
session["user_id"] = user["username"]
session["is_admin"] = user.get("role") == "admin"
session["db_user_id"] = user["id"]
session.permanent = True
logger.info(f"OIDC login successful: {user['username']} (admin={is_admin})")
return redirect(_post_login_redirect_target(_get_pending_return_to(clear=True)))
except ValueError as e:
logger.error(f"OIDC callback error: {e}")
return redirect(_login_error_url(str(e)))
except Exception as e:
logger.error(f"OIDC callback error: {e}")
return redirect(_login_error_url("Authentication failed"))
+6
View File
@@ -34,6 +34,12 @@ def _get_config_dir() -> Path:
def is_onboarding_complete() -> bool:
"""Check if onboarding has been completed."""
from shelfmark.config.env import ONBOARDING
# If onboarding is disabled via env var, treat as complete
if not ONBOARDING:
return True
config_file = _get_config_dir() / "settings.json"
if not config_file.exists():
return False
+136
View File
@@ -0,0 +1,136 @@
"""Remote path mapping utilities.
Used when an external download client reports a completed download path that does
not exist inside the Shelfmark runtime environment (commonly different Docker
volume mounts).
A mapping rewrites a remote path prefix into a local path prefix.
"""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Iterable, Optional
@dataclass(frozen=True)
class RemotePathMapping:
host: str
remote_path: str
local_path: str
def _normalize_prefix(path: str) -> str:
normalized = str(path or "").strip()
if not normalized:
return ""
normalized = normalized.replace("\\", "/")
if normalized != "/":
normalized = normalized.rstrip("/")
return normalized
def _is_windows_path(path: str) -> bool:
"""Check if a path looks like a Windows path (has a drive letter like C:/)."""
return len(path) >= 2 and path[1] == ":" and path[0].isalpha()
def _normalize_host(host: str) -> str:
return str(host or "").strip().lower()
def parse_remote_path_mappings(value: Any) -> list[RemotePathMapping]:
if not value or not isinstance(value, list):
return []
mappings: list[RemotePathMapping] = []
for row in value:
if not isinstance(row, dict):
continue
host = _normalize_host(row.get("host", ""))
remote_path = _normalize_prefix(row.get("remotePath", ""))
local_path = _normalize_prefix(row.get("localPath", ""))
if not host or not remote_path or not local_path:
continue
mappings.append(RemotePathMapping(host=host, remote_path=remote_path, local_path=local_path))
mappings.sort(key=lambda m: len(m.remote_path), reverse=True)
return mappings
def remap_remote_to_local_with_match(
*,
mappings: Iterable[RemotePathMapping],
host: str,
remote_path: str | Path,
) -> tuple[Path, bool]:
host_normalized = _normalize_host(host)
remote_normalized = _normalize_prefix(str(remote_path))
if not remote_normalized:
return Path(str(remote_path)), False
# Windows paths are case-insensitive, so we need case-insensitive matching
# for paths that look like Windows paths (e.g., D:/Torrents)
is_windows = _is_windows_path(remote_normalized)
for mapping in mappings:
if _normalize_host(mapping.host) != host_normalized:
continue
remote_prefix = _normalize_prefix(mapping.remote_path)
if not remote_prefix:
continue
# For Windows paths, do case-insensitive prefix matching
if is_windows:
remote_lower = remote_normalized.lower()
prefix_lower = remote_prefix.lower()
matches = remote_lower == prefix_lower or remote_lower.startswith(prefix_lower + "/")
else:
matches = remote_normalized == remote_prefix or remote_normalized.startswith(remote_prefix + "/")
if matches:
# Use the length of the original prefix to extract remainder
# This preserves the original case in folder names
remainder = remote_normalized[len(remote_prefix):]
local_prefix = _normalize_prefix(mapping.local_path)
if remainder.startswith("/"):
remainder = remainder[1:]
remapped = Path(local_prefix) / remainder if remainder else Path(local_prefix)
return remapped, True
return Path(remote_normalized), False
def remap_remote_to_local(*, mappings: Iterable[RemotePathMapping], host: str, remote_path: str | Path) -> Path:
remapped, _ = remap_remote_to_local_with_match(
mappings=mappings,
host=host,
remote_path=remote_path,
)
return remapped
def get_client_host_identifier(client: Any) -> Optional[str]:
"""Return a stable identifier used by the mapping UI.
Sonarr uses the download client's configured host. Shelfmark currently uses
the download client 'name' (e.g. qbittorrent, sabnzbd).
"""
name = getattr(client, "name", None)
if isinstance(name, str) and name.strip():
return name.strip().lower()
return None
+31
View File
@@ -0,0 +1,31 @@
"""WSGI middleware for hosting Shelfmark under a URL prefix."""
from __future__ import annotations
from typing import Iterable, Optional
class PrefixMiddleware:
"""Strip a configured URL prefix from PATH_INFO before routing."""
def __init__(self, app, prefix: str, bypass_paths: Optional[Iterable[str]] = None) -> None:
self.app = app
self.prefix = prefix.rstrip("/")
self.bypass_paths = set(bypass_paths or [])
def __call__(self, environ, start_response):
path = environ.get("PATH_INFO", "") or ""
if path in self.bypass_paths:
return self.app(environ, start_response)
if not self.prefix:
return self.app(environ, start_response)
if path == self.prefix or path.startswith(self.prefix + "/"):
environ["SCRIPT_NAME"] = self.prefix
environ["PATH_INFO"] = path[len(self.prefix):] or "/"
return self.app(environ, start_response)
start_response("404 Not Found", [("Content-Type", "text/plain")])
return [b"Not Found"]
+122 -47
View File
@@ -5,10 +5,13 @@ import time
from datetime import datetime, timedelta
from pathlib import Path
from threading import Lock, Event
from typing import Dict, List, Optional, Tuple, Any
from typing import Dict, List, Optional, Tuple, Any, Callable
from shelfmark.core.config import config as app_config
from shelfmark.core.models import QueueStatus, QueueItem, DownloadTask
from shelfmark.core.logger import setup_logger
from shelfmark.core.models import QueueStatus, QueueItem, DownloadTask, TERMINAL_QUEUE_STATUSES
logger = setup_logger(__name__)
class BookQueue:
@@ -22,6 +25,10 @@ class BookQueue:
self._status_timestamps: dict[str, datetime] = {} # Track when each status was last updated
self._cancel_flags: dict[str, Event] = {} # Cancellation flags for active downloads
self._active_downloads: dict[str, bool] = {} # Track currently downloading tasks
self._terminal_status_hook: Optional[
Callable[[str, QueueStatus, DownloadTask], None]
] = None
self._queue_hook: Optional[Callable[[str, DownloadTask], None]] = None
@property
def _status_timeout(self) -> timedelta:
@@ -30,11 +37,12 @@ class BookQueue:
def add(self, task: DownloadTask) -> bool:
"""Add a download task to the queue. Returns False if already exists."""
hook: Optional[Callable[[str, DownloadTask], None]] = None
with self._lock:
task_id = task.task_id
# Don't add if already exists and not in error/done state
if task_id in self._status and self._status[task_id] not in [QueueStatus.ERROR, QueueStatus.DONE, QueueStatus.CANCELLED]:
# Don't add if already exists and not in error/cancelled state
if task_id in self._status and self._status[task_id] not in [QueueStatus.ERROR, QueueStatus.CANCELLED]:
return False
# Ensure added_time is set
@@ -45,7 +53,14 @@ class BookQueue:
self._queue.put(queue_item)
self._task_data[task_id] = task
self._update_status(task_id, QueueStatus.QUEUED)
return True
hook = self._queue_hook
if hook is not None:
try:
hook(task_id, task)
except Exception as exc:
logger.warning("Queue hook failed while adding task %s: %s", task_id, exc)
return True
def get_next(self) -> Optional[Tuple[str, Event]]:
"""Get next task ID from queue with cancellation flag."""
@@ -74,21 +89,58 @@ class BookQueue:
with self._lock:
return self._task_data.get(task_id)
def get_task_status(self, task_id: str) -> Optional[QueueStatus]:
"""Get queue status for a task id."""
with self._lock:
return self._status.get(task_id)
def _update_status(self, book_id: str, status: QueueStatus) -> None:
"""Internal method to update status and timestamp."""
self._status[book_id] = status
self._status_timestamps[book_id] = datetime.now()
def set_terminal_status_hook(
self,
hook: Optional[Callable[[str, QueueStatus, DownloadTask], None]],
) -> None:
"""Register a callback invoked when a task first enters a terminal status."""
with self._lock:
self._terminal_status_hook = hook
def set_queue_hook(
self,
hook: Optional[Callable[[str, DownloadTask], None]],
) -> None:
"""Register a callback invoked when a task is added to the queue."""
with self._lock:
self._queue_hook = hook
def update_status(self, book_id: str, status: QueueStatus) -> None:
"""Update status of a book in the queue."""
hook: Optional[Callable[[str, QueueStatus, DownloadTask], None]] = None
hook_task: Optional[DownloadTask] = None
with self._lock:
previous_status = self._status.get(book_id)
self._update_status(book_id, status)
if (
status in TERMINAL_QUEUE_STATUSES
and previous_status != status
and self._terminal_status_hook is not None
):
current_task = self._task_data.get(book_id)
if current_task is not None:
hook = self._terminal_status_hook
hook_task = current_task
# Clean up active download tracking when finished
if status in [QueueStatus.COMPLETE, QueueStatus.AVAILABLE, QueueStatus.ERROR, QueueStatus.DONE, QueueStatus.CANCELLED]:
if status in TERMINAL_QUEUE_STATUSES:
self._active_downloads.pop(book_id, None)
self._cancel_flags.pop(book_id, None)
if hook is not None and hook_task is not None:
hook(book_id, status, hook_task)
def update_download_path(self, task_id: str, download_path: str) -> None:
"""Update the download path of a task in the queue."""
with self._lock:
@@ -107,14 +159,22 @@ class BookQueue:
if task_id in self._task_data:
self._task_data[task_id].status_message = message
def get_status(self) -> Dict[QueueStatus, Dict[str, DownloadTask]]:
"""Get current queue status grouped by status."""
def get_status(self, user_id: Optional[int] = None) -> Dict[QueueStatus, Dict[str, DownloadTask]]:
"""Get current queue status grouped by status.
Args:
user_id: If provided, only return tasks belonging to this user.
If None, return all.
"""
self.refresh()
with self._lock:
result: Dict[QueueStatus, Dict[str, DownloadTask]] = {status: {} for status in QueueStatus}
for task_id, status in self._status.items():
if task_id in self._task_data:
result[status][task_id] = self._task_data[task_id]
task = self._task_data[task_id]
if user_id is not None and task.user_id != user_id:
continue
result[status][task_id] = task
return result
def get_queue_order(self) -> List[Dict[str, Any]]:
@@ -149,31 +209,20 @@ class BookQueue:
return sorted(queue_items, key=lambda x: (x['priority'], x['added_time']))
def cancel_download(self, task_id: str) -> bool:
"""Cancel a download or clear a completed/errored item."""
"""Cancel an active or queued download."""
with self._lock:
current_status = self._status.get(task_id)
# Allow cancellation during any active state
if current_status in [QueueStatus.RESOLVING, QueueStatus.DOWNLOADING]:
if current_status in [QueueStatus.RESOLVING, QueueStatus.LOCATING, QueueStatus.DOWNLOADING]:
# Signal active download to stop
if task_id in self._cancel_flags:
self._cancel_flags[task_id].set()
self._update_status(task_id, QueueStatus.CANCELLED)
return True
elif current_status == QueueStatus.QUEUED:
# Remove from queue and mark as cancelled
self._update_status(task_id, QueueStatus.CANCELLED)
return True
elif current_status in [QueueStatus.COMPLETE, QueueStatus.DONE, QueueStatus.AVAILABLE, QueueStatus.ERROR, QueueStatus.CANCELLED]:
# Clear completed/errored/cancelled items from tracking
self._status.pop(task_id, None)
self._status_timestamps.pop(task_id, None)
self._task_data.pop(task_id, None)
self._cancel_flags.pop(task_id, None)
self._active_downloads.pop(task_id, None)
return True
elif current_status not in [QueueStatus.QUEUED]:
# Not in a cancellable state
return False
return False
self.update_status(task_id, QueueStatus.CANCELLED)
return True
def set_priority(self, task_id: str, new_priority: int) -> bool:
"""Change the priority of a queued task (lower = higher priority)."""
@@ -207,6 +256,51 @@ class BookQueue:
return found
def enqueue_existing(self, task_id: str, *, priority: Optional[int] = None) -> bool:
"""Requeue an existing task regardless of current status.
This is used for retries where task metadata should be preserved.
"""
hook: Optional[Callable[[str, DownloadTask], None]] = None
hook_task: Optional[DownloadTask] = None
with self._lock:
task = self._task_data.get(task_id)
if task is None:
return False
if priority is not None:
task.priority = priority
# Ensure task doesn't appear active while waiting for retry.
self._active_downloads.pop(task_id, None)
self._cancel_flags.pop(task_id, None)
# De-duplicate queue entries for this task id.
temp_items: list[QueueItem] = []
while not self._queue.empty():
try:
item = self._queue.get_nowait()
except queue.Empty:
break
if item.book_id != task_id:
temp_items.append(item)
for item in temp_items:
self._queue.put(item)
queue_item = QueueItem(task_id, task.priority, time.time())
self._queue.put(queue_item)
self._update_status(task_id, QueueStatus.QUEUED)
hook = self._queue_hook
hook_task = task
if hook is not None and hook_task is not None:
try:
hook(task_id, hook_task)
except Exception as exc:
logger.warning("Queue hook failed while requeueing task %s: %s", task_id, exc)
return True
def reorder_queue(self, task_priorities: Dict[str, int]) -> bool:
"""Bulk reorder queue by mapping task_id to new priority."""
with self._lock:
@@ -245,24 +339,9 @@ class BookQueue:
return True
return any(status == QueueStatus.QUEUED for status in self._status.values())
def clear_completed(self) -> int:
"""Remove all completed, errored, or cancelled tasks from tracking."""
terminal_statuses = {QueueStatus.COMPLETE, QueueStatus.DONE, QueueStatus.AVAILABLE, QueueStatus.ERROR, QueueStatus.CANCELLED}
with self._lock:
to_remove = [task_id for task_id, status in self._status.items() if status in terminal_statuses]
for task_id in to_remove:
self._status.pop(task_id, None)
self._status_timestamps.pop(task_id, None)
self._task_data.pop(task_id, None)
self._cancel_flags.pop(task_id, None)
self._active_downloads.pop(task_id, None)
return len(to_remove)
def refresh(self) -> None:
"""Remove any tasks that are done downloading or have stale status."""
terminal_statuses = {QueueStatus.COMPLETE, QueueStatus.DONE, QueueStatus.ERROR, QueueStatus.AVAILABLE, QueueStatus.CANCELLED}
terminal_statuses = TERMINAL_QUEUE_STATUSES
with self._lock:
current_time = datetime.now()
to_remove = []
@@ -276,10 +355,6 @@ class BookQueue:
if task.download_path and not Path(task.download_path).exists():
task.download_path = None
# Mark available downloads as done if file is gone
if status == QueueStatus.AVAILABLE and not task.download_path:
self._update_status(task_id, QueueStatus.DONE)
# Check for stale status entries
last_update = self._status_timestamps.get(task_id)
if last_update and (current_time - last_update) > self._status_timeout:
+129
View File
@@ -0,0 +1,129 @@
"""Shared request-related helper functions used by routes and services."""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any
from shelfmark.core.logger import setup_logger
from shelfmark.core.settings_registry import load_config_file
_logger = setup_logger(__name__)
def now_utc_iso() -> str:
"""Return the current UTC time as a seconds-precision ISO 8601 string."""
return datetime.now(timezone.utc).isoformat(timespec="seconds")
def emit_ws_event(
ws_manager: Any,
*,
event_name: str,
payload: dict[str, Any],
room: str,
) -> None:
"""Emit a WebSocket event via the shared manager, swallowing failures."""
if ws_manager is None:
return
try:
socketio = getattr(ws_manager, "socketio", None)
is_enabled = getattr(ws_manager, "is_enabled", None)
if socketio is None or not callable(is_enabled) or not is_enabled():
return
socketio.emit(event_name, payload, to=room)
except Exception as exc:
_logger.warning("Failed to emit WebSocket event '%s' to room '%s': %s", event_name, room, exc)
def load_users_request_policy_settings() -> dict[str, Any]:
"""Load global request-policy settings from the users config file."""
return load_config_file("users")
def coerce_bool(value: Any, default: bool = False) -> bool:
"""Coerce arbitrary values into booleans with string-friendly semantics."""
if isinstance(value, bool):
return value
if value is None:
return default
if isinstance(value, str):
normalized = value.strip().lower()
if normalized in {"1", "true", "yes", "on"}:
return True
if normalized in {"0", "false", "no", "off", ""}:
return False
return bool(value)
def get_session_db_user_id(session_obj: Any) -> int | None:
"""Extract and coerce `db_user_id` from a Flask session to ``int | None``."""
raw = session_obj.get("db_user_id") if session_obj is not None else None
try:
return int(raw) if raw is not None else None
except (TypeError, ValueError):
return None
def coerce_int(value: Any, default: int) -> int:
"""Best-effort integer coercion with fallback to default."""
try:
return int(value)
except (TypeError, ValueError):
return default
def normalize_optional_text(value: Any) -> str | None:
"""Return a trimmed string or None for empty/non-string input."""
if not isinstance(value, str):
return None
normalized = value.strip()
return normalized or None
def normalize_positive_int(value: Any) -> int | None:
"""Parse *value* as a positive integer, returning ``None`` on failure."""
try:
parsed = int(value)
except (TypeError, ValueError):
return None
return parsed if parsed > 0 else None
def normalize_optional_positive_int(value: Any, field_name: str = "value") -> int | None:
"""Parse *value* as a positive integer or ``None``.
Raises ``ValueError`` when *value* is present but not a valid
positive integer.
"""
if value is None:
return None
try:
parsed = int(value)
except (TypeError, ValueError) as exc:
raise ValueError(f"{field_name} must be a positive integer when provided") from exc
if parsed < 1:
raise ValueError(f"{field_name} must be a positive integer when provided")
return parsed
def populate_request_usernames(rows: list[dict[str, Any]], user_db: Any) -> None:
"""Add 'username' to each request row by looking up user_id."""
cache: dict[int, str] = {}
for row in rows:
requester_id = row["user_id"]
if requester_id not in cache:
requester = user_db.get_user(user_id=requester_id)
cache[requester_id] = requester.get("username", "") if requester else ""
row["username"] = cache[requester_id]
def extract_release_source_id(release_data: Any) -> str | None:
"""Extract and normalize release_data.source_id."""
if not isinstance(release_data, dict):
return None
source_id = release_data.get("source_id")
if not isinstance(source_id, str):
return None
normalized = source_id.strip()
return normalized or None
+373
View File
@@ -0,0 +1,373 @@
"""Request-policy resolution helpers.
This module is intentionally pure and side-effect free so it can be reused by
routes/services and tested independently.
"""
from __future__ import annotations
from enum import Enum
from typing import Any, Iterable, Mapping, Sequence
class PolicyMode(str, Enum):
"""Allowed request-policy modes.
Ordered from most to least permissive. The content-type default acts as a
ceiling — matrix rules can only match or restrict further, never upgrade
beyond the default.
"""
DOWNLOAD = "download"
REQUEST_RELEASE = "request_release"
REQUEST_BOOK = "request_book"
BLOCKED = "blocked"
# Permissiveness ordering: lower index = more permissive.
_MODE_PERMISSIVENESS: dict[PolicyMode, int] = {
PolicyMode.DOWNLOAD: 0,
PolicyMode.REQUEST_RELEASE: 1,
PolicyMode.REQUEST_BOOK: 2,
PolicyMode.BLOCKED: 3,
}
# Modes allowed in REQUEST_POLICY_RULES matrix rows.
MATRIX_ALLOWED_MODES = frozenset({PolicyMode.DOWNLOAD, PolicyMode.REQUEST_RELEASE, PolicyMode.BLOCKED})
def cap_mode(mode: PolicyMode, ceiling: PolicyMode) -> PolicyMode:
"""Cap a resolved mode so it cannot be more permissive than the ceiling."""
if _MODE_PERMISSIVENESS[mode] < _MODE_PERMISSIVENESS[ceiling]:
return ceiling
return mode
def _source_results_are_releases(source: Any) -> bool:
normalized_source = normalize_source(source)
if normalized_source in {"", "*"}:
return False
from shelfmark.release_sources import source_results_are_releases
return source_results_are_releases(normalized_source)
def _normalize_release_result_mode(source: Any, mode: PolicyMode) -> PolicyMode:
"""Concrete release browse results cannot fall back to request_book semantics."""
if mode == PolicyMode.REQUEST_BOOK and _source_results_are_releases(source):
return PolicyMode.REQUEST_RELEASE
return mode
REQUEST_POLICY_KEYS = frozenset(
{
"REQUESTS_ENABLED",
"REQUEST_POLICY_DEFAULT_EBOOK",
"REQUEST_POLICY_DEFAULT_AUDIOBOOK",
"REQUEST_POLICY_RULES",
"MAX_PENDING_REQUESTS_PER_USER",
"REQUESTS_ALLOW_NOTES",
}
)
REQUEST_POLICY_DEFAULT_FALLBACK_MODE = PolicyMode.REQUEST_BOOK
DEFAULT_SUPPORTED_CONTENT_TYPES = ("ebook", "audiobook")
def filter_request_policy_settings(settings: Mapping[str, Any] | None) -> dict[str, Any]:
"""Return only uppercase request-policy keys from a settings JSON object."""
if not isinstance(settings, Mapping):
return {}
return {key: settings[key] for key in REQUEST_POLICY_KEYS if key in settings}
def merge_request_policy_settings(
global_settings: Mapping[str, Any] | None,
user_settings: Mapping[str, Any] | None = None,
) -> dict[str, Any]:
"""Merge global settings with per-user request-policy overrides."""
merged = filter_request_policy_settings(global_settings)
user_filtered = filter_request_policy_settings(user_settings)
# Preserve global rules by default and treat user rules as per-cell overlays.
# This allows per-user REQUEST_POLICY_RULES payloads to store only explicit
# differences instead of replacing the full global matrix.
global_rules = list(_iter_rules(merged.get("REQUEST_POLICY_RULES", [])))
user_has_rules = "REQUEST_POLICY_RULES" in user_filtered
for key, value in user_filtered.items():
if key == "REQUEST_POLICY_RULES":
continue
merged[key] = value
if user_has_rules:
merged_rules: dict[tuple[str, str], tuple[str, str, PolicyMode]] = {
(source, content_type): (source, content_type, mode)
for source, content_type, mode in global_rules
}
for source, content_type, mode in _iter_rules(user_filtered.get("REQUEST_POLICY_RULES", [])):
merged_rules[(source, content_type)] = (source, content_type, mode)
merged["REQUEST_POLICY_RULES"] = [
{"source": source, "content_type": content_type, "mode": mode.value}
for source, content_type, mode in merged_rules.values()
]
return merged
def normalize_content_type(content_type: Any) -> str:
"""Normalize arbitrary content type values to `ebook` or `audiobook`."""
if not isinstance(content_type, str):
return "ebook"
value = content_type.strip().lower()
if not value:
return "ebook"
if value in {"audiobook", "audiobooks", "audio", "book (audiobook)"}:
return "audiobook"
return "ebook"
def normalize_source(source: Any) -> str:
"""Normalize source values for policy matching."""
if not isinstance(source, str):
return "*"
value = source.strip().lower()
return value or "*"
def parse_policy_mode(mode: Any) -> PolicyMode | None:
"""Parse an arbitrary mode value into a PolicyMode enum member."""
if isinstance(mode, PolicyMode):
return mode
if not isinstance(mode, str):
return None
try:
return PolicyMode(mode.strip().lower())
except ValueError:
return None
def _normalize_rule_content_type(content_type: Any) -> str | None:
if not isinstance(content_type, str):
return None
value = content_type.strip().lower()
if not value:
return None
if value in {"*", "any"}:
return "*"
if value in {"ebook", "book", "books", "book (fiction)"}:
return "ebook"
if value in {"audiobook", "audiobooks", "audio", "book (audiobook)"}:
return "audiobook"
return None
def _normalize_rule_source(source: Any) -> str | None:
if not isinstance(source, str):
return None
value = source.strip().lower()
if not value:
return None
if value in {"*", "any"}:
return "*"
return value
def get_source_content_type_capabilities() -> dict[str, set[str]]:
"""Return source -> supported content type map from registered sources."""
try:
from shelfmark.release_sources import list_available_sources
except Exception:
return {}
capabilities: dict[str, set[str]] = {}
for source in list_available_sources():
raw_name = source.get("name")
name = normalize_source(raw_name)
if not name or name == "*":
continue
raw_types = source.get("supported_content_types", DEFAULT_SUPPORTED_CONTENT_TYPES)
if isinstance(raw_types, str) or not isinstance(raw_types, Sequence):
raw_types = DEFAULT_SUPPORTED_CONTENT_TYPES
normalized_types: set[str] = set()
for content_type in raw_types:
normalized_type = _normalize_rule_content_type(content_type)
if normalized_type and normalized_type != "*":
normalized_types.add(normalized_type)
if not normalized_types:
normalized_types = set(DEFAULT_SUPPORTED_CONTENT_TYPES)
capabilities[name] = normalized_types
return capabilities
def validate_policy_rules(
rules: Any,
source_capabilities: Mapping[str, set[str]] | None = None,
) -> tuple[list[dict[str, str]], list[str]]:
"""Validate and normalize policy rule rows.
Validation covers:
- row shape and required keys
- valid mode/content_type values
- known source names
- source/content-type compatibility from source declarations
"""
capabilities = source_capabilities if source_capabilities is not None else get_source_content_type_capabilities()
normalized_capabilities = {
normalize_source(source): {normalize_content_type(content_type) for content_type in content_types}
for source, content_types in capabilities.items()
}
normalized_rules: list[dict[str, str]] = []
errors: list[str] = []
if rules is None:
return normalized_rules, errors
if not isinstance(rules, list):
return normalized_rules, ["REQUEST_POLICY_RULES must be a list"]
for index, rule in enumerate(rules):
row_label = f"Rule {index + 1}"
if not isinstance(rule, Mapping):
errors.append(f"{row_label}: must be an object")
continue
source = _normalize_rule_source(rule.get("source"))
raw_content_type = rule.get("content_type")
content_type = _normalize_rule_content_type(rule.get("content_type"))
raw_mode = rule.get("mode")
mode = parse_policy_mode(rule.get("mode"))
if source is None:
errors.append(f"{row_label}: source is required")
continue
if (
raw_content_type is None
or (isinstance(raw_content_type, str) and not raw_content_type.strip())
):
errors.append(f"{row_label}: content_type is required")
continue
if content_type is None:
errors.append(f"{row_label}: invalid content_type '{rule.get('content_type')}'")
continue
if (
raw_mode is None
or (isinstance(raw_mode, str) and not raw_mode.strip())
):
errors.append(f"{row_label}: mode is required")
continue
if mode is None:
errors.append(f"{row_label}: invalid mode '{rule.get('mode')}'")
continue
if mode not in MATRIX_ALLOWED_MODES:
errors.append(f"{row_label}: mode '{mode.value}' is not allowed in matrix rules (use content-type defaults instead)")
continue
if source != "*" and source not in normalized_capabilities:
errors.append(f"{row_label}: unknown source '{source}'")
continue
if (
source != "*"
and content_type != "*"
and source in normalized_capabilities
and content_type not in normalized_capabilities[source]
):
errors.append(
f"{row_label}: source '{source}' does not support content_type '{content_type}'"
)
continue
normalized_rules.append(
{
"source": source,
"content_type": content_type,
"mode": mode.value,
}
)
return normalized_rules, errors
def _iter_rules(rules: Any) -> Iterable[tuple[str, str, PolicyMode]]:
if not isinstance(rules, list):
return []
normalized: list[tuple[str, str, PolicyMode]] = []
for rule in rules:
if not isinstance(rule, Mapping):
continue
source = _normalize_rule_source(rule.get("source"))
content_type = _normalize_rule_content_type(rule.get("content_type"))
mode = parse_policy_mode(rule.get("mode"))
if (
source is None
or content_type is None
or mode is None
or mode not in MATRIX_ALLOWED_MODES
):
continue
normalized.append((source, content_type, mode))
return normalized
def resolve_policy_mode(
*,
source: Any,
content_type: Any,
global_settings: Mapping[str, Any] | None,
user_settings: Mapping[str, Any] | None = None,
) -> PolicyMode:
"""Resolve an effective policy mode for a request context.
Resolution:
1. Resolve the content-type default (ceiling).
2. Match rules in specificity order.
3. Cap the matched rule at the ceiling.
4. If no rule matches, return the ceiling.
The content-type default acts as a ceiling — matrix rules can only
match or restrict further, never upgrade beyond the default.
Concrete-release browse exception:
- sources whose browse results are already concrete releases normalize
request_book to request_release.
"""
effective = merge_request_policy_settings(global_settings, user_settings)
normalized_source = normalize_source(source)
normalized_content_type = normalize_content_type(content_type)
# Resolve the content-type default (ceiling)
default_key = (
"REQUEST_POLICY_DEFAULT_AUDIOBOOK"
if normalized_content_type == "audiobook"
else "REQUEST_POLICY_DEFAULT_EBOOK"
)
default_mode = parse_policy_mode(effective.get(default_key))
ceiling = default_mode if default_mode is not None else REQUEST_POLICY_DEFAULT_FALLBACK_MODE
# Match rules in specificity order
rules = tuple(_iter_rules(effective.get("REQUEST_POLICY_RULES", [])))
candidates = (
(normalized_source, normalized_content_type),
(normalized_source, "*"),
("*", normalized_content_type),
("*", "*"),
)
for candidate_source, candidate_content_type in candidates:
for rule_source, rule_content_type, rule_mode in rules:
if rule_source == candidate_source and rule_content_type == candidate_content_type:
return _normalize_release_result_mode(
normalized_source,
cap_mode(rule_mode, ceiling),
)
return _normalize_release_result_mode(normalized_source, ceiling)
+882
View File
@@ -0,0 +1,882 @@
"""Request API routes and policy snapshot endpoint."""
from __future__ import annotations
from typing import Any, Callable
from flask import Flask, jsonify, request, session
from shelfmark.core.logger import setup_logger
from shelfmark.core.request_policy import (
PolicyMode,
REQUEST_POLICY_DEFAULT_FALLBACK_MODE,
get_source_content_type_capabilities,
merge_request_policy_settings,
normalize_content_type,
normalize_source,
parse_policy_mode,
resolve_policy_mode,
)
from shelfmark.core.request_validation import RequestStatus
from shelfmark.core.requests_service import (
RequestServiceError,
cancel_request,
create_request,
create_requests,
fulfil_request,
reject_request,
)
from shelfmark.core.notifications import (
NotificationContext,
NotificationEvent,
notify_admin,
notify_user,
)
from shelfmark.core.request_helpers import (
coerce_bool,
coerce_int,
emit_ws_event,
load_users_request_policy_settings,
normalize_optional_text,
normalize_positive_int,
populate_request_usernames,
)
from shelfmark.core.user_db import UserDB
logger = setup_logger(__name__)
def _error_response(
message: str,
status_code: int,
*,
code: str | None = None,
required_mode: str | None = None,
):
payload: dict[str, Any] = {"error": message}
if code is not None:
payload["code"] = code
if required_mode is not None:
payload["required_mode"] = required_mode
return jsonify(payload), status_code
def _require_request_endpoints_available(resolve_auth_mode: Callable[[], str]):
auth_mode = resolve_auth_mode()
if auth_mode == "none":
return _error_response(
"Request workflow is unavailable in no-auth mode",
403,
code="requests_unavailable",
)
if "user_id" not in session:
return jsonify({"error": "Unauthorized"}), 401
return None
def _require_db_user_id() -> tuple[int | None, Any | None]:
raw_user_id = session.get("db_user_id")
if raw_user_id is None:
return None, _error_response(
"User identity is unavailable for request workflow",
403,
code="user_identity_unavailable",
)
try:
return int(raw_user_id), None
except (TypeError, ValueError):
return None, _error_response(
"User identity is unavailable for request workflow",
403,
code="user_identity_unavailable",
)
def _require_admin_user_id() -> tuple[int | None, Any | None]:
if not session.get("is_admin", False):
return None, (jsonify({"error": "Admin access required"}), 403)
raw_admin_id = session.get("db_user_id")
if raw_admin_id is None:
return None, (jsonify({"error": "Admin user identity unavailable"}), 403)
try:
return int(raw_admin_id), None
except (TypeError, ValueError):
return None, (jsonify({"error": "Admin user identity unavailable"}), 403)
def _resolve_effective_policy(
user_db: UserDB,
*,
db_user_id: int | None,
) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any], bool]:
global_settings = load_users_request_policy_settings()
user_settings = user_db.get_user_settings(db_user_id) if db_user_id is not None else {}
effective = merge_request_policy_settings(global_settings, user_settings)
requests_enabled = coerce_bool(effective.get("REQUESTS_ENABLED"), False)
return global_settings, user_settings, effective, requests_enabled
def _resolve_title_from_book_data(book_data: Any) -> str:
if isinstance(book_data, dict):
title = normalize_optional_text(book_data.get("title"))
if title is not None:
return title
return "Unknown title"
def _normalize_optional_source_id(value: Any) -> str | None:
"""Normalize source identifiers while allowing integer provider ids."""
if isinstance(value, bool) or value is None:
return None
if isinstance(value, int):
value = str(value)
return normalize_optional_text(value)
def _build_release_result_data_from_book_data(
*,
source: str,
book_data: dict[str, Any],
content_type: str,
) -> dict[str, Any]:
"""Build release-level payload fields for sources whose browse results are releases."""
source_id = _normalize_optional_source_id(book_data.get("provider_id")) or _normalize_optional_source_id(
book_data.get("id")
)
payload: dict[str, Any] = {
"source": source,
"source_id": source_id,
"title": book_data.get("title"),
"author": book_data.get("author"),
"year": book_data.get("year"),
"format": book_data.get("format"),
"size": book_data.get("size"),
"preview": book_data.get("preview"),
"content_type": content_type,
"source_url": book_data.get("source_url"),
"search_mode": "direct",
}
return {key: value for key, value in payload.items() if value is not None}
def _source_results_are_releases(source: str) -> bool:
normalized_source = normalize_source(source)
if normalized_source in {"", "*"}:
return False
from shelfmark.release_sources import source_results_are_releases
return source_results_are_releases(normalized_source)
def _normalize_release_result_request_payload(
*,
source: str,
request_level: Any,
book_data: Any,
release_data: Any,
content_type: str,
) -> tuple[Any, Any]:
"""Concrete-release browse results are always handled as release-level requests."""
if not _source_results_are_releases(source):
return request_level, release_data
normalized_release_data = release_data
if normalized_release_data is None and isinstance(book_data, dict):
normalized_release_data = _build_release_result_data_from_book_data(
source=source,
book_data=book_data,
content_type=content_type,
)
elif isinstance(normalized_release_data, dict):
normalized_release_data = dict(normalized_release_data)
if isinstance(normalized_release_data, dict):
normalized_release_data["source"] = source
if normalized_release_data.get("content_type") is None:
normalized_release_data["content_type"] = content_type
normalized_source_id = _normalize_optional_source_id(normalized_release_data.get("source_id"))
if normalized_source_id is not None:
normalized_release_data["source_id"] = normalized_source_id
elif isinstance(book_data, dict):
fallback_source_id = _normalize_optional_source_id(book_data.get("provider_id")) or _normalize_optional_source_id(
book_data.get("id")
)
if fallback_source_id is not None:
normalized_release_data["source_id"] = fallback_source_id
return "release", normalized_release_data
def _resolve_request_title(request_row: dict[str, Any]) -> str:
return _resolve_title_from_book_data(request_row.get("book_data"))
def _format_user_label(username: str | None, user_id: int | None = None) -> str:
normalized_username = normalize_optional_text(username)
if normalized_username is not None:
return normalized_username
if user_id is not None and user_id > 0:
return f"user#{user_id}"
return "unknown user"
def _format_requester_label(user_db: UserDB, request_row: dict[str, Any]) -> str:
"""Resolve a display label for the user who created a request."""
user_id = normalize_positive_int(request_row.get("user_id"))
if user_id is not None:
requester = user_db.get_user(user_id=user_id)
if isinstance(requester, dict):
username = normalize_optional_text(requester.get("username"))
if username is not None:
return username
return _format_user_label(None, user_id)
def _resolve_request_user_context(
user_db: UserDB,
*,
actor_user_id: int,
actor_username: str | None,
on_behalf_of_user_id: Any,
) -> tuple[int, str | None, str]:
if on_behalf_of_user_id in (None, ""):
actor_label = _format_user_label(actor_username, actor_user_id)
return actor_user_id, actor_username, actor_label
if not session.get("is_admin", False):
raise RequestServiceError("Admin required", status_code=403)
try:
target_user_id = int(on_behalf_of_user_id)
except (TypeError, ValueError) as exc:
raise RequestServiceError("Invalid on_behalf_of_user_id", status_code=400) from exc
if target_user_id <= 0:
raise RequestServiceError("Invalid on_behalf_of_user_id", status_code=400)
target_user = user_db.get_user(user_id=target_user_id)
if not target_user:
raise RequestServiceError("User not found", status_code=404)
target_username = normalize_optional_text(target_user.get("username"))
actor_label = _format_user_label(actor_username, actor_user_id)
target_label = _format_user_label(target_username, target_user_id)
return target_user_id, target_username, f"{actor_label} on behalf of {target_label}"
def _prepare_request_create_arguments(
user_db: UserDB,
data: dict[str, Any],
) -> dict[str, Any]:
db_user_id, db_gate = _require_db_user_id()
if db_gate is not None or db_user_id is None:
raise RequestServiceError(
"User identity is unavailable for request workflow",
status_code=403,
code="user_identity_unavailable",
)
actor_username = normalize_optional_text(session.get("user_id"))
target_user_id, _, actor_label = _resolve_request_user_context(
user_db,
actor_user_id=db_user_id,
actor_username=actor_username,
on_behalf_of_user_id=data.get("on_behalf_of_user_id"),
)
context = data.get("context") or {}
if not isinstance(context, dict):
raise RequestServiceError("context must be an object", status_code=400)
source = normalize_source(context.get("source"))
release_data = data.get("release_data")
request_level = context.get("request_level")
if request_level is None:
request_level = "book" if release_data is None else "release"
book_data = data.get("book_data")
if not isinstance(book_data, dict):
raise RequestServiceError("book_data must be an object", status_code=400)
request_title = _resolve_title_from_book_data(book_data)
content_type = normalize_content_type(
context.get("content_type")
or data.get("content_type")
or book_data.get("content_type")
)
request_level, release_data = _normalize_release_result_request_payload(
source=source,
request_level=request_level,
book_data=book_data,
release_data=release_data,
content_type=content_type,
)
global_settings, user_settings, effective, requests_enabled = _resolve_effective_policy(
user_db,
db_user_id=target_user_id,
)
if not requests_enabled:
raise RequestServiceError(
"Request workflow is disabled by policy",
status_code=403,
code="requests_unavailable",
)
max_pending = coerce_int(
effective.get("MAX_PENDING_REQUESTS_PER_USER"),
default=20,
)
if max_pending < 1:
max_pending = 1
if max_pending > 1000:
max_pending = 1000
allow_notes = coerce_bool(effective.get("REQUESTS_ALLOW_NOTES"), default=True)
note_value = data.get("note") if allow_notes else None
resolved_mode = resolve_policy_mode(
source=source,
content_type=content_type,
global_settings=global_settings,
user_settings=user_settings,
)
logger.debug(
"request create policy actor=%s target_user_id=%s source=%s content_type=%s request_level=%s resolved_mode=%s",
session.get("user_id"),
target_user_id,
source,
content_type,
request_level,
resolved_mode.value,
)
if resolved_mode == PolicyMode.BLOCKED:
raise RequestServiceError(
"Requesting is blocked by policy",
status_code=403,
code="policy_blocked",
required_mode=PolicyMode.BLOCKED.value,
)
requested_level = str(request_level).strip().lower() if isinstance(request_level, str) else ""
if resolved_mode == PolicyMode.REQUEST_BOOK and requested_level != "book":
raise RequestServiceError(
"Policy requires book-level requests",
status_code=403,
code="policy_requires_request",
required_mode=PolicyMode.REQUEST_BOOK.value,
)
return {
"create_args": {
"user_id": target_user_id,
"source_hint": source,
"content_type": content_type,
"request_level": request_level,
"policy_mode": resolved_mode.value,
"book_data": book_data,
"release_data": release_data,
"note": note_value,
"max_pending_per_user": max_pending,
},
"actor_label": actor_label,
"request_title": request_title,
}
def _resolve_request_source_and_format(request_row: dict[str, Any]) -> tuple[str, str | None]:
release_data = request_row.get("release_data")
if isinstance(release_data, dict):
source = normalize_source(release_data.get("source") or request_row.get("source_hint"))
release_format = normalize_optional_text(
release_data.get("format")
or release_data.get("filetype")
or release_data.get("extension")
)
return source, release_format
return normalize_source(request_row.get("source_hint")), None
def _notify_admin_for_request_event(
user_db: UserDB,
*,
event: NotificationEvent,
request_row: dict[str, Any],
) -> None:
book_data = request_row.get("book_data")
if not isinstance(book_data, dict):
book_data = {}
source, release_format = _resolve_request_source_and_format(request_row)
context = NotificationContext(
event=event,
title=str(book_data.get("title") or "Unknown title"),
author=str(book_data.get("author") or "Unknown author"),
username=_format_requester_label(user_db, request_row),
content_type=normalize_content_type(
request_row.get("content_type") or book_data.get("content_type")
),
format=release_format,
source=source,
admin_note=normalize_optional_text(request_row.get("admin_note")),
error_message=None,
)
owner_user_id = normalize_positive_int(request_row.get("user_id"))
try:
notify_admin(event, context)
except Exception as exc:
logger.warning(
"Failed to trigger admin notification for request event '%s': %s",
event.value,
exc,
)
if owner_user_id is None:
return
try:
notify_user(owner_user_id, event, context)
except Exception as exc:
logger.warning(
"Failed to trigger user notification for request event '%s' (user_id=%s): %s",
event.value,
owner_user_id,
exc,
)
def register_request_routes(
app: Flask,
user_db: UserDB,
*,
resolve_auth_mode: Callable[[], str],
queue_release: Callable[..., tuple[bool, str | None]],
ws_manager: Any | None = None,
) -> None:
"""Register request policy and request lifecycle routes."""
@app.route("/api/request-policy", methods=["GET"])
def api_request_policy():
auth_gate = _require_request_endpoints_available(resolve_auth_mode)
if auth_gate is not None:
return auth_gate
is_admin = bool(session.get("is_admin", False))
db_user_id: int | None = None
if not is_admin:
db_user_id, db_gate = _require_db_user_id()
if db_gate is not None:
return db_gate
else:
raw_id = session.get("db_user_id")
if raw_id is not None:
try:
db_user_id = int(raw_id)
except (TypeError, ValueError):
db_user_id = None
global_settings, user_settings, effective, requests_enabled = _resolve_effective_policy(
user_db,
db_user_id=db_user_id,
)
default_ebook_mode = parse_policy_mode(effective.get("REQUEST_POLICY_DEFAULT_EBOOK"))
default_audio_mode = parse_policy_mode(effective.get("REQUEST_POLICY_DEFAULT_AUDIOBOOK"))
source_capabilities = get_source_content_type_capabilities()
from shelfmark.release_sources import source_results_are_releases
source_modes = []
for source_name in sorted(source_capabilities):
supported_types = sorted(
source_capabilities[source_name],
key=lambda ct: (ct != "ebook", ct),
)
modes = {
content_type: resolve_policy_mode(
source=source_name,
content_type=content_type,
global_settings=global_settings,
user_settings=user_settings,
).value
for content_type in supported_types
}
source_modes.append(
{
"source": source_name,
"supported_content_types": supported_types,
"browse_results_are_releases": source_results_are_releases(source_name),
"modes": modes,
}
)
return jsonify(
{
"requests_enabled": requests_enabled,
"is_admin": is_admin,
"allow_notes": coerce_bool(effective.get("REQUESTS_ALLOW_NOTES"), default=True),
"defaults": {
"ebook": (
default_ebook_mode.value
if default_ebook_mode is not None
else REQUEST_POLICY_DEFAULT_FALLBACK_MODE.value
),
"audiobook": (
default_audio_mode.value
if default_audio_mode is not None
else REQUEST_POLICY_DEFAULT_FALLBACK_MODE.value
),
},
"rules": effective.get("REQUEST_POLICY_RULES", []),
"source_modes": source_modes,
}
)
@app.route("/api/requests", methods=["POST"])
def api_create_request():
auth_gate = _require_request_endpoints_available(resolve_auth_mode)
if auth_gate is not None:
return auth_gate
data = request.get_json(silent=True)
if not isinstance(data, dict):
return jsonify({"error": "No data provided"}), 400
try:
prepared = _prepare_request_create_arguments(user_db, data)
created = create_request(user_db, **prepared["create_args"])
except RequestServiceError as exc:
return _error_response(
str(exc),
exc.status_code,
code=exc.code,
required_mode=exc.required_mode,
)
event_payload = {
"request_id": created["id"],
"status": created["status"],
"title": _resolve_request_title(created),
}
logger.info(
"Request created #%s for '%s' by %s",
created["id"],
event_payload["title"],
prepared["actor_label"],
)
emit_ws_event(
ws_manager,
event_name="new_request",
payload=event_payload,
room="admins",
)
emit_ws_event(
ws_manager,
event_name="request_update",
payload=event_payload,
room=f"user_{created['user_id']}",
)
_notify_admin_for_request_event(
user_db,
event=NotificationEvent.REQUEST_CREATED,
request_row=created,
)
return jsonify(created), 201
@app.route("/api/requests/batch", methods=["POST"])
def api_create_requests_batch():
auth_gate = _require_request_endpoints_available(resolve_auth_mode)
if auth_gate is not None:
return auth_gate
data = request.get_json(silent=True)
if not isinstance(data, dict):
return jsonify({"error": "No data provided"}), 400
raw_requests = data.get("requests")
if not isinstance(raw_requests, list) or len(raw_requests) == 0:
return jsonify({"error": "requests must contain at least one request"}), 400
try:
prepared_requests = [
_prepare_request_create_arguments(user_db, raw_request)
for raw_request in raw_requests
]
created_rows = create_requests(
user_db,
requests=[prepared["create_args"] for prepared in prepared_requests],
)
except RequestServiceError as exc:
return _error_response(
str(exc),
exc.status_code,
code=exc.code,
required_mode=exc.required_mode,
)
for created, prepared in zip(created_rows, prepared_requests):
event_payload = {
"request_id": created["id"],
"status": created["status"],
"title": _resolve_request_title(created),
}
logger.info(
"Request created #%s for '%s' by %s",
created["id"],
event_payload["title"],
prepared["actor_label"],
)
emit_ws_event(
ws_manager,
event_name="new_request",
payload=event_payload,
room="admins",
)
emit_ws_event(
ws_manager,
event_name="request_update",
payload=event_payload,
room=f"user_{created['user_id']}",
)
_notify_admin_for_request_event(
user_db,
event=NotificationEvent.REQUEST_CREATED,
request_row=created,
)
return jsonify(created_rows), 201
@app.route("/api/requests", methods=["GET"])
def api_list_requests():
auth_gate = _require_request_endpoints_available(resolve_auth_mode)
if auth_gate is not None:
return auth_gate
db_user_id, db_gate = _require_db_user_id()
if db_gate is not None or db_user_id is None:
return db_gate
status = request.args.get("status")
limit = request.args.get("limit", type=int)
offset = request.args.get("offset", type=int, default=0) or 0
try:
rows = user_db.list_requests(
user_id=db_user_id,
status=status,
limit=limit,
offset=offset,
)
except ValueError as exc:
return jsonify({"error": str(exc)}), 400
return jsonify(rows)
@app.route("/api/requests/<int:request_id>", methods=["DELETE"])
def api_cancel_request(request_id: int):
auth_gate = _require_request_endpoints_available(resolve_auth_mode)
if auth_gate is not None:
return auth_gate
db_user_id, db_gate = _require_db_user_id()
if db_gate is not None or db_user_id is None:
return db_gate
try:
updated = cancel_request(
user_db,
request_id=request_id,
actor_user_id=db_user_id,
)
except RequestServiceError as exc:
return _error_response(str(exc), exc.status_code, code=exc.code)
event_payload = {
"request_id": updated["id"],
"status": updated["status"],
"title": _resolve_request_title(updated),
}
actor_label = _format_user_label(normalize_optional_text(session.get("user_id")), db_user_id)
logger.info(
"Request cancelled #%s for '%s' by %s",
updated["id"],
event_payload["title"],
actor_label,
)
emit_ws_event(
ws_manager,
event_name="request_update",
payload=event_payload,
room=f"user_{db_user_id}",
)
emit_ws_event(
ws_manager,
event_name="request_update",
payload=event_payload,
room="admins",
)
return jsonify(updated)
@app.route("/api/admin/requests", methods=["GET"])
def api_admin_list_requests():
auth_gate = _require_request_endpoints_available(resolve_auth_mode)
if auth_gate is not None:
return auth_gate
if not session.get("is_admin", False):
return jsonify({"error": "Admin access required"}), 403
status = request.args.get("status")
limit = request.args.get("limit", type=int)
offset = request.args.get("offset", type=int, default=0) or 0
try:
rows = user_db.list_requests(status=status, limit=limit, offset=offset)
except ValueError as exc:
return jsonify({"error": str(exc)}), 400
populate_request_usernames(rows, user_db)
return jsonify(rows)
@app.route("/api/admin/requests/count", methods=["GET"])
def api_admin_request_counts():
auth_gate = _require_request_endpoints_available(resolve_auth_mode)
if auth_gate is not None:
return auth_gate
if not session.get("is_admin", False):
return jsonify({"error": "Admin access required"}), 403
by_status = {
status: len(user_db.list_requests(status=status))
for status in RequestStatus
}
return jsonify(
{
"pending": by_status[RequestStatus.PENDING],
"total": sum(by_status.values()),
"by_status": by_status,
}
)
@app.route("/api/admin/requests/<int:request_id>/fulfil", methods=["POST"])
def api_admin_fulfil_request(request_id: int):
auth_gate = _require_request_endpoints_available(resolve_auth_mode)
if auth_gate is not None:
return auth_gate
admin_user_id, admin_gate = _require_admin_user_id()
if admin_gate is not None:
return admin_gate
data = request.get_json(silent=True) or {}
if not isinstance(data, dict):
return jsonify({"error": "Invalid payload"}), 400
try:
updated = fulfil_request(
user_db,
request_id=request_id,
admin_user_id=admin_user_id,
queue_release=queue_release,
release_data=data.get("release_data"),
admin_note=data.get("admin_note"),
manual_approval=data.get("manual_approval", False),
)
except RequestServiceError as exc:
return _error_response(str(exc), exc.status_code, code=exc.code)
event_payload = {
"request_id": updated["id"],
"status": updated["status"],
"title": _resolve_request_title(updated),
}
admin_label = _format_user_label(normalize_optional_text(session.get("user_id")), admin_user_id)
requester_label = _format_requester_label(user_db, updated)
logger.info(
"Request fulfilled #%s for '%s' by %s (requested by %s)",
updated["id"],
event_payload["title"],
admin_label,
requester_label,
)
emit_ws_event(
ws_manager,
event_name="request_update",
payload=event_payload,
room=f"user_{updated['user_id']}",
)
emit_ws_event(
ws_manager,
event_name="request_update",
payload=event_payload,
room="admins",
)
_notify_admin_for_request_event(
user_db,
event=NotificationEvent.REQUEST_FULFILLED,
request_row=updated,
)
return jsonify(updated)
@app.route("/api/admin/requests/<int:request_id>/reject", methods=["POST"])
def api_admin_reject_request(request_id: int):
auth_gate = _require_request_endpoints_available(resolve_auth_mode)
if auth_gate is not None:
return auth_gate
admin_user_id, admin_gate = _require_admin_user_id()
if admin_gate is not None:
return admin_gate
data = request.get_json(silent=True) or {}
if not isinstance(data, dict):
return jsonify({"error": "Invalid payload"}), 400
try:
updated = reject_request(
user_db,
request_id=request_id,
admin_user_id=admin_user_id,
admin_note=data.get("admin_note"),
)
except RequestServiceError as exc:
return _error_response(str(exc), exc.status_code, code=exc.code)
event_payload = {
"request_id": updated["id"],
"status": updated["status"],
"title": _resolve_request_title(updated),
}
admin_label = _format_user_label(normalize_optional_text(session.get("user_id")), admin_user_id)
requester_label = _format_requester_label(user_db, updated)
logger.info(
"Request rejected #%s for '%s' by %s (requested by %s)",
updated["id"],
event_payload["title"],
admin_label,
requester_label,
)
emit_ws_event(
ws_manager,
event_name="request_update",
payload=event_payload,
room=f"user_{updated['user_id']}",
)
emit_ws_event(
ws_manager,
event_name="request_update",
payload=event_payload,
room="admins",
)
_notify_admin_for_request_event(
user_db,
event=NotificationEvent.REQUEST_REJECTED,
request_row=updated,
)
return jsonify(updated)
+84
View File
@@ -0,0 +1,84 @@
"""Shared request validation and normalization helpers."""
from __future__ import annotations
from enum import Enum
from typing import Any
from shelfmark.core.models import QueueStatus
from shelfmark.core.request_policy import parse_policy_mode
class RequestStatus(str, Enum):
"""Enum for request lifecycle statuses."""
PENDING = "pending"
FULFILLED = "fulfilled"
REJECTED = "rejected"
CANCELLED = "cancelled"
DELIVERY_STATE_NONE = "none"
VALID_REQUEST_STATUSES = frozenset(RequestStatus)
TERMINAL_REQUEST_STATUSES = frozenset({
RequestStatus.FULFILLED, RequestStatus.REJECTED, RequestStatus.CANCELLED,
})
VALID_REQUEST_LEVELS = frozenset({"book", "release"})
VALID_DELIVERY_STATES = frozenset({DELIVERY_STATE_NONE} | set(QueueStatus))
def normalize_request_status(status: Any) -> str:
"""Validate and normalize request status values."""
if not isinstance(status, str):
raise ValueError(f"Invalid request status: {status}")
normalized = status.strip().lower()
if normalized not in VALID_REQUEST_STATUSES:
raise ValueError(f"Invalid request status: {status}")
return normalized
def normalize_policy_mode(mode: Any) -> str:
"""Validate and normalize policy mode values."""
parsed = parse_policy_mode(mode)
if parsed is None:
raise ValueError(f"Invalid policy_mode: {mode}")
return parsed.value
def normalize_request_level(request_level: Any) -> str:
"""Validate and normalize request level values."""
if not isinstance(request_level, str):
raise ValueError(f"Invalid request_level: {request_level}")
normalized = request_level.strip().lower()
if normalized not in VALID_REQUEST_LEVELS:
raise ValueError(f"Invalid request_level: {request_level}")
return normalized
def normalize_delivery_state(state: Any) -> str:
"""Validate and normalize delivery-state values."""
if not isinstance(state, str):
raise ValueError(f"Invalid delivery_state: {state}")
normalized = state.strip().lower()
if normalized not in VALID_DELIVERY_STATES:
raise ValueError(f"Invalid delivery_state: {state}")
return normalized
def validate_request_level_payload(request_level: Any, release_data: Any) -> str:
"""Validate request_level and release_data shape coupling."""
normalized_level = normalize_request_level(request_level)
if normalized_level == "release" and release_data is None:
raise ValueError("request_level=release requires non-null release_data")
if normalized_level == "book" and release_data is not None:
raise ValueError("request_level=book requires null release_data")
return normalized_level
def validate_status_transition(current_status: Any, new_status: Any) -> tuple[str, str]:
"""Validate request status transitions and terminal immutability."""
current = normalize_request_status(current_status)
new = normalize_request_status(new_status)
if current in TERMINAL_REQUEST_STATUSES and new != current:
raise ValueError("Terminal request statuses are immutable")
return current, new
+570
View File
@@ -0,0 +1,570 @@
"""Request lifecycle helpers and service-level validation."""
from __future__ import annotations
from datetime import datetime, timezone
import json
from typing import Any, Callable, TYPE_CHECKING
from shelfmark.core.request_policy import normalize_content_type
from shelfmark.core.models import QueueStatus
from shelfmark.core.request_validation import (
DELIVERY_STATE_NONE,
RequestStatus,
normalize_policy_mode,
normalize_request_level,
normalize_request_status,
validate_request_level_payload,
validate_status_transition,
)
from shelfmark.core.request_helpers import extract_release_source_id, normalize_positive_int
MAX_REQUEST_NOTE_LENGTH = 1000
MAX_REQUEST_JSON_BLOB_BYTES = 10 * 1024
if TYPE_CHECKING:
from shelfmark.core.user_db import UserDB
class RequestServiceError(ValueError):
"""Structured error raised by request lifecycle service methods."""
def __init__(
self,
message: str,
*,
status_code: int = 400,
code: str | None = None,
required_mode: str | None = None,
):
super().__init__(message)
self.status_code = status_code
self.code = code
self.required_mode = required_mode
def _normalize_match_text(value: Any) -> str:
if not isinstance(value, str):
return ""
return value.strip().lower()
def normalize_note(note: Any) -> str | None:
"""Validate request notes and normalize empty strings to None."""
if note is None:
return None
if not isinstance(note, str):
raise RequestServiceError("note must be a string", status_code=400)
normalized = note.strip()
if len(normalized) > MAX_REQUEST_NOTE_LENGTH:
raise RequestServiceError(
f"note must be <= {MAX_REQUEST_NOTE_LENGTH} characters",
status_code=400,
)
return normalized or None
def _validate_book_data(book_data: Any) -> dict[str, Any]:
if not isinstance(book_data, dict):
raise RequestServiceError("book_data must be an object", status_code=400)
required_fields = ("title", "author", "provider", "provider_id")
missing = [field for field in required_fields if not _normalize_match_text(book_data.get(field))]
if missing:
raise RequestServiceError(
f"book_data missing required field(s): {', '.join(missing)}",
status_code=400,
)
return dict(book_data)
def _validate_json_blob_size(field: str, payload: Any) -> None:
if payload is None:
return
try:
serialized = json.dumps(payload, separators=(",", ":"), ensure_ascii=False)
except (TypeError, ValueError) as exc:
raise RequestServiceError(f"{field} must be JSON-serializable", status_code=400) from exc
payload_size = len(serialized.encode("utf-8"))
if payload_size > MAX_REQUEST_JSON_BLOB_BYTES:
raise RequestServiceError(
f"{field} must be <= {MAX_REQUEST_JSON_BLOB_BYTES} bytes",
status_code=400,
code="request_payload_too_large",
)
def _find_duplicate_pending_request(
user_db: "UserDB",
*,
user_id: int,
title: str,
author: str,
content_type: str,
) -> dict[str, Any] | None:
pending_rows = user_db.list_requests(user_id=user_id, status=RequestStatus.PENDING)
for row in pending_rows:
row_book_data = row.get("book_data") or {}
if not isinstance(row_book_data, dict):
continue
row_title = _normalize_match_text(row_book_data.get("title"))
row_author = _normalize_match_text(row_book_data.get("author"))
row_content_type = normalize_content_type(
row.get("content_type") or row_book_data.get("content_type")
)
if row_title == title and row_author == author and row_content_type == content_type:
return row
return None
def _now_timestamp() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds")
def _normalize_admin_note(admin_note: Any) -> str | None:
if admin_note is None:
return None
if not isinstance(admin_note, str):
raise RequestServiceError("admin_note must be a string", status_code=400)
return admin_note.strip() or None
def _prepare_request_create(
*,
user_id: int,
source_hint: str | None,
content_type: Any,
request_level: Any,
policy_mode: Any,
book_data: Any,
release_data: Any = None,
note: Any = None,
) -> dict[str, Any]:
validated_book_data = _validate_book_data(book_data)
normalized_note = normalize_note(note)
normalized_content_type = normalize_content_type(
content_type or validated_book_data.get("content_type")
)
validated_book_data["content_type"] = normalized_content_type
try:
normalized_request_level = validate_request_level_payload(request_level, release_data)
normalized_policy_mode = normalize_policy_mode(policy_mode)
except ValueError as exc:
raise RequestServiceError(str(exc), status_code=400) from exc
_validate_json_blob_size("book_data", validated_book_data)
_validate_json_blob_size("release_data", release_data)
return {
"user_id": user_id,
"source_hint": source_hint,
"content_type": normalized_content_type,
"request_level": normalized_request_level,
"policy_mode": normalized_policy_mode,
"book_data": validated_book_data,
"release_data": release_data,
"note": normalized_note,
}
def sync_delivery_states_from_queue_status(
user_db: "UserDB",
*,
queue_status: dict[str, dict[str, Any]],
user_id: int | None = None,
) -> list[dict[str, Any]]:
"""Persist delivery-state transitions for fulfilled requests based on queue status."""
fulfilled_rows = user_db.list_requests(user_id=user_id, status=RequestStatus.FULFILLED)
if not fulfilled_rows:
return []
unique_request_ids_by_source: dict[str, int] = {}
ambiguous_source_ids: set[str] = set()
for row in fulfilled_rows:
source_id = extract_release_source_id(row.get("release_data"))
if source_id is None:
continue
if source_id in unique_request_ids_by_source:
ambiguous_source_ids.add(source_id)
continue
unique_request_ids_by_source[source_id] = int(row["id"])
for source_id in ambiguous_source_ids:
unique_request_ids_by_source.pop(source_id, None)
request_delivery_states: dict[int, str] = {}
for status_key in QueueStatus:
status_bucket = queue_status.get(status_key)
if not isinstance(status_bucket, dict):
continue
for source_id, task_payload in status_bucket.items():
request_id = None
if isinstance(task_payload, dict):
request_id = normalize_positive_int(task_payload.get("request_id"))
if request_id is None:
request_id = unique_request_ids_by_source.get(str(source_id).strip())
if request_id is None:
continue
request_delivery_states[request_id] = status_key
if not request_delivery_states:
return []
updated: list[dict[str, Any]] = []
for row in fulfilled_rows:
delivery_state = request_delivery_states.get(int(row["id"]))
if delivery_state is None:
continue
if row.get("delivery_state", DELIVERY_STATE_NONE) == delivery_state:
continue
updated.append(
user_db.update_request(
row["id"],
delivery_state=delivery_state,
delivery_updated_at=_now_timestamp(),
)
)
return updated
def create_request(
user_db: "UserDB",
*,
user_id: int,
source_hint: str | None,
content_type: Any,
request_level: Any,
policy_mode: Any,
book_data: Any,
release_data: Any = None,
note: Any = None,
max_pending_per_user: int | None = None,
) -> dict[str, Any]:
"""Create a pending request after service-level validation."""
prepared_request = _prepare_request_create(
user_id=user_id,
source_hint=source_hint,
content_type=content_type,
request_level=request_level,
policy_mode=policy_mode,
book_data=book_data,
release_data=release_data,
note=note,
)
if max_pending_per_user is not None:
pending_count = user_db.count_user_pending_requests(user_id)
if pending_count >= max_pending_per_user:
raise RequestServiceError(
"Maximum pending requests reached for this user",
status_code=409,
code="max_pending_reached",
)
duplicate = _find_duplicate_pending_request(
user_db,
user_id=user_id,
title=_normalize_match_text(prepared_request["book_data"].get("title")),
author=_normalize_match_text(prepared_request["book_data"].get("author")),
content_type=prepared_request["content_type"],
)
if duplicate is not None:
raise RequestServiceError(
"Duplicate pending request exists for this title/author/content_type",
status_code=409,
code="duplicate_pending_request",
)
try:
return user_db.create_request(**prepared_request)
except ValueError as exc:
raise RequestServiceError(str(exc), status_code=400) from exc
def create_requests(
user_db: "UserDB",
*,
requests: list[dict[str, Any]],
) -> list[dict[str, Any]]:
"""Create multiple pending requests atomically after validation."""
if not isinstance(requests, list) or len(requests) == 0:
raise RequestServiceError("requests must contain at least one request", status_code=400)
prepared_requests: list[dict[str, Any]] = []
pending_counts_by_user: dict[int, int] = {}
seen_request_keys: set[tuple[int, str, str, str]] = set()
for request in requests:
if not isinstance(request, dict):
raise RequestServiceError("requests must contain objects", status_code=400)
user_id = int(request["user_id"])
prepared_request = _prepare_request_create(
user_id=user_id,
source_hint=request.get("source_hint"),
content_type=request.get("content_type"),
request_level=request.get("request_level"),
policy_mode=request.get("policy_mode"),
book_data=request.get("book_data"),
release_data=request.get("release_data"),
note=request.get("note"),
)
request_key = (
user_id,
_normalize_match_text(prepared_request["book_data"].get("title")),
_normalize_match_text(prepared_request["book_data"].get("author")),
prepared_request["content_type"],
)
if request_key in seen_request_keys:
raise RequestServiceError(
"Duplicate pending request exists for this title/author/content_type",
status_code=409,
code="duplicate_pending_request",
)
seen_request_keys.add(request_key)
max_pending_per_user = request.get("max_pending_per_user")
if max_pending_per_user is not None:
existing_pending = pending_counts_by_user.get(user_id)
if existing_pending is None:
existing_pending = user_db.count_user_pending_requests(user_id)
if existing_pending >= max_pending_per_user:
raise RequestServiceError(
"Maximum pending requests reached for this user",
status_code=409,
code="max_pending_reached",
)
pending_counts_by_user[user_id] = existing_pending + 1
duplicate = _find_duplicate_pending_request(
user_db,
user_id=user_id,
title=request_key[1],
author=request_key[2],
content_type=request_key[3],
)
if duplicate is not None:
raise RequestServiceError(
"Duplicate pending request exists for this title/author/content_type",
status_code=409,
code="duplicate_pending_request",
)
prepared_requests.append(prepared_request)
try:
return user_db.create_requests(prepared_requests)
except ValueError as exc:
raise RequestServiceError(str(exc), status_code=400) from exc
def ensure_request_access(
user_db: "UserDB",
*,
request_id: int,
actor_user_id: int | None,
is_admin: bool,
) -> dict[str, Any]:
"""Get request by ID and enforce ownership for non-admin actors."""
request_row = user_db.get_request(request_id)
if request_row is None:
raise RequestServiceError("Request not found", status_code=404)
if not is_admin:
if actor_user_id is None or request_row["user_id"] != actor_user_id:
raise RequestServiceError("Forbidden", status_code=403)
return request_row
def _require_pending(request_row: dict[str, Any]) -> None:
if request_row["status"] != RequestStatus.PENDING:
raise RequestServiceError(
"Request is already in a terminal state",
status_code=409,
code="stale_transition",
)
def cancel_request(
user_db: "UserDB",
*,
request_id: int,
actor_user_id: int,
) -> dict[str, Any]:
"""Cancel a pending request owned by the actor."""
request_row = ensure_request_access(
user_db,
request_id=request_id,
actor_user_id=actor_user_id,
is_admin=False,
)
_require_pending(request_row)
try:
return user_db.update_request(
request_id,
expected_current_status=RequestStatus.PENDING,
status=RequestStatus.CANCELLED,
)
except ValueError as exc:
raise RequestServiceError(str(exc), status_code=409, code="stale_transition") from exc
def reject_request(
user_db: "UserDB",
*,
request_id: int,
admin_user_id: int,
admin_note: Any = None,
) -> dict[str, Any]:
"""Reject a pending request as admin."""
request_row = ensure_request_access(
user_db,
request_id=request_id,
actor_user_id=admin_user_id,
is_admin=True,
)
_require_pending(request_row)
normalized_admin_note = _normalize_admin_note(admin_note)
try:
return user_db.update_request(
request_id,
expected_current_status=RequestStatus.PENDING,
status=RequestStatus.REJECTED,
admin_note=normalized_admin_note,
reviewed_by=admin_user_id,
reviewed_at=_now_timestamp(),
)
except ValueError as exc:
raise RequestServiceError(str(exc), status_code=409, code="stale_transition") from exc
def fulfil_request(
user_db: "UserDB",
*,
request_id: int,
admin_user_id: int,
queue_release: Callable[..., tuple[bool, str | None]],
release_data: Any = None,
admin_note: Any = None,
manual_approval: Any = False,
) -> dict[str, Any]:
"""Fulfil a pending request and queue the release under requesting-user identity."""
request_row = ensure_request_access(
user_db,
request_id=request_id,
actor_user_id=admin_user_id,
is_admin=True,
)
_require_pending(request_row)
normalized_admin_note = _normalize_admin_note(admin_note)
if not isinstance(manual_approval, bool):
raise RequestServiceError("manual_approval must be a boolean", status_code=400)
selected_release_data = release_data if release_data is not None else request_row.get("release_data")
if selected_release_data is not None and not isinstance(selected_release_data, dict):
raise RequestServiceError("release_data must be an object", status_code=400)
if selected_release_data is None and manual_approval:
try:
return user_db.update_request(
request_id,
expected_current_status=RequestStatus.PENDING,
status=RequestStatus.FULFILLED,
release_data=None,
delivery_state=QueueStatus.COMPLETE,
delivery_updated_at=_now_timestamp(),
last_failure_reason=None,
admin_note=normalized_admin_note,
reviewed_by=admin_user_id,
reviewed_at=_now_timestamp(),
)
except ValueError as exc:
raise RequestServiceError(str(exc), status_code=409, code="stale_transition") from exc
if selected_release_data is None:
raise RequestServiceError(
"release_data is required to fulfil requests",
status_code=400,
)
_validate_json_blob_size("release_data", selected_release_data)
requester = user_db.get_user(user_id=request_row["user_id"])
if requester is None:
raise RequestServiceError("Requesting user not found", status_code=404)
original_release_data = request_row.get("release_data")
try:
claimed_request = user_db.update_request(
request_id,
expected_current_status=RequestStatus.PENDING,
status=RequestStatus.FULFILLED,
release_data=selected_release_data,
delivery_state=QueueStatus.QUEUED,
delivery_updated_at=_now_timestamp(),
last_failure_reason=None,
admin_note=normalized_admin_note,
reviewed_by=admin_user_id,
reviewed_at=_now_timestamp(),
)
except ValueError as exc:
raise RequestServiceError(str(exc), status_code=409, code="stale_transition") from exc
queued_release_data = dict(selected_release_data)
queued_release_data["_request_id"] = request_id
try:
success, error = queue_release(
queued_release_data,
0,
user_id=request_row["user_id"],
username=requester.get("username"),
)
except Exception:
user_db.rollback_request_fulfilment(
request_id,
release_data=original_release_data,
last_failure_reason="Queue dispatch raised an exception",
)
raise
if not success:
user_db.rollback_request_fulfilment(
request_id,
release_data=original_release_data,
last_failure_reason=error,
)
raise RequestServiceError(
error or "Failed to queue release",
status_code=409,
code="queue_failed",
)
return claimed_request
def reopen_failed_request(
user_db: "UserDB",
*,
request_id: int,
failure_reason: str | None = None,
) -> dict[str, Any] | None:
"""Reopen a failed fulfilled request so admins can re-approve with a new release."""
return user_db.reopen_failed_request(
request_id,
failure_reason=failure_reason,
)
+173
View File
@@ -0,0 +1,173 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import List, Optional
MANUAL_QUERY_MAX_LEN = 256
from shelfmark.core.config import config
from shelfmark.core.models import SearchFilters
from shelfmark.metadata_providers import (
BookMetadata,
group_languages_by_localized_title,
build_localized_search_titles,
)
@dataclass(frozen=True)
class ReleaseSearchVariant:
"""A single search variant (title + author) associated with languages."""
title: str
author: str
languages: Optional[List[str]] = None
@property
def query(self) -> str:
return " ".join(part for part in [self.title, self.author] if part).strip()
@dataclass(frozen=True)
class ReleaseSearchPlan:
"""Pre-computed search inputs shared across release sources."""
languages: Optional[List[str]]
isbn_candidates: List[str]
author: str
title_variants: List[ReleaseSearchVariant]
grouped_title_variants: List[ReleaseSearchVariant]
manual_query: Optional[str] = None
indexers: Optional[List[str]] = None # Indexer names for Prowlarr (overrides settings)
source_filters: Optional[SearchFilters] = None
@property
def primary_query(self) -> str:
return self.title_variants[0].query if self.title_variants else ""
def _normalize_languages(languages: Optional[List[str]]) -> Optional[List[str]]:
if not languages:
default = config.BOOK_LANGUAGE
if not default:
return None
return [str(lang).strip() for lang in default if str(lang).strip()]
normalized: List[str] = []
for lang in languages:
if not lang:
continue
s = str(lang).strip()
if not s:
continue
normalized.append(s)
if any(lang.lower() == "all" for lang in normalized):
return None
return normalized or None
def _pick_search_author(book: BookMetadata) -> str:
if book.search_author:
return book.search_author
if not book.authors:
return ""
first = book.authors[0]
if "," in first:
first = first.split(",")[0].strip()
return first
def _pick_search_title(book: BookMetadata) -> str:
return book.search_title or book.title
def build_release_search_plan(
book: BookMetadata,
languages: Optional[List[str]] = None,
manual_query: Optional[str] = None,
indexers: Optional[List[str]] = None,
source_filters: Optional[SearchFilters] = None,
) -> ReleaseSearchPlan:
resolved_languages = _normalize_languages(languages)
resolved_manual_query = None
if manual_query:
resolved_manual_query = manual_query.strip()[:MANUAL_QUERY_MAX_LEN] or None
author = _pick_search_author(book)
base_title = _pick_search_title(book)
if resolved_manual_query:
# Manual override: use the raw query as-is (no language/title expansion).
variant = ReleaseSearchVariant(title=resolved_manual_query, author="", languages=None)
return ReleaseSearchPlan(
languages=resolved_languages,
isbn_candidates=[],
author="",
title_variants=[variant],
grouped_title_variants=[variant],
manual_query=resolved_manual_query,
indexers=indexers,
source_filters=source_filters,
)
isbn_candidates: List[str] = []
if book.isbn_13:
isbn_candidates.append(book.isbn_13)
if book.isbn_10 and book.isbn_10 not in isbn_candidates:
isbn_candidates.append(book.isbn_10)
titles_by_language = book.titles_by_language or None
if book.search_title and titles_by_language:
titles_by_language = {
k: v
for k, v in titles_by_language.items()
if str(k).strip().lower() not in {"en", "eng", "english"}
}
grouped = group_languages_by_localized_title(
base_title=base_title,
languages=resolved_languages,
titles_by_language=titles_by_language,
)
grouped_variants: List[ReleaseSearchVariant] = [
ReleaseSearchVariant(title=title, author=author, languages=langs)
for title, langs in grouped
if title
]
expanded_titles = build_localized_search_titles(
base_title=base_title,
languages=resolved_languages,
titles_by_language=titles_by_language,
excluded_languages={"en", "eng", "english"},
)
title_variants: List[ReleaseSearchVariant] = [
ReleaseSearchVariant(title=title, author=author, languages=None)
for title in expanded_titles
if title
]
# If no titles could be built, fall back to ISBN queries.
if not title_variants and isbn_candidates:
title_variants = [
ReleaseSearchVariant(title=isbn, author="", languages=None)
for isbn in isbn_candidates
]
return ReleaseSearchPlan(
languages=resolved_languages,
isbn_candidates=isbn_candidates,
author=author,
title_variants=title_variants,
grouped_title_variants=grouped_variants,
manual_query=None,
indexers=indexers,
source_filters=source_filters,
)
+353
View File
@@ -0,0 +1,353 @@
"""Self-service user account routes."""
from functools import wraps
from typing import Any, Callable, Mapping
from flask import Flask, g, jsonify, request, session
from werkzeug.security import generate_password_hash
from shelfmark.config.env import CWA_DB_PATH
from shelfmark.core.admin_settings_routes import (
build_user_notification_test_response,
validate_user_settings,
)
from shelfmark.core.auth_modes import (
AUTH_SOURCE_BUILTIN,
AUTH_SOURCE_CWA,
AUTH_SOURCE_OIDC,
AUTH_SOURCE_PROXY,
is_user_active_for_auth_mode,
load_active_auth_mode,
normalize_auth_source,
)
from shelfmark.core.logger import setup_logger
from shelfmark.core.settings_registry import load_config_file
from shelfmark.core.user_settings_overrides import (
build_user_preferences_payload as _build_user_preferences_payload,
get_ordered_user_overridable_fields as _get_ordered_user_overridable_fields,
)
from shelfmark.core.user_db import UserDB
logger = setup_logger(__name__)
MIN_PASSWORD_LENGTH = 4
_VISIBLE_SELF_SETTINGS_SECTIONS_KEY = "VISIBLE_SELF_SETTINGS_SECTIONS"
_SELF_SETTINGS_SECTION_DELIVERY = "delivery"
_SELF_SETTINGS_SECTION_SEARCH = "search"
_SELF_SETTINGS_SECTION_NOTIFICATIONS = "notifications"
_VALID_SELF_SETTINGS_SECTIONS = (
_SELF_SETTINGS_SECTION_DELIVERY,
_SELF_SETTINGS_SECTION_SEARCH,
_SELF_SETTINGS_SECTION_NOTIFICATIONS,
)
_DEFAULT_VISIBLE_SELF_SETTINGS_SECTIONS = list(_VALID_SELF_SETTINGS_SECTIONS)
def _get_current_user(user_db: UserDB) -> tuple[int | None, dict[str, Any] | None, tuple[Any, int] | None]:
raw_user_id = session.get("db_user_id")
try:
user_id = int(raw_user_id)
except (TypeError, ValueError):
return None, None, (jsonify({"error": "Invalid user context"}), 400)
user = user_db.get_user(user_id=user_id)
if not user:
return None, None, (jsonify({"error": "User not found"}), 404)
return user_id, user, None
def _get_self_edit_capabilities(user: Mapping[str, Any]) -> dict[str, Any]:
auth_source = normalize_auth_source(
user.get("auth_source"),
user.get("oidc_subject"),
)
return {
"authSource": auth_source,
"canSetPassword": auth_source == AUTH_SOURCE_BUILTIN,
"canEditRole": False,
"canEditEmail": auth_source in {AUTH_SOURCE_BUILTIN, AUTH_SOURCE_PROXY},
"canEditDisplayName": auth_source != AUTH_SOURCE_OIDC,
}
def _serialize_self_user(user: Mapping[str, Any], auth_mode: str) -> dict[str, Any]:
payload = dict(user)
payload.pop("password_hash", None)
payload["auth_source"] = normalize_auth_source(
payload.get("auth_source"),
payload.get("oidc_subject"),
)
payload["is_active"] = is_user_active_for_auth_mode(payload, auth_mode)
payload["edit_capabilities"] = _get_self_edit_capabilities(payload)
return payload
def _normalize_visible_self_settings_sections(raw_sections: Any) -> list[str]:
"""Normalize users.VISIBLE_SELF_SETTINGS_SECTIONS to a safe ordered list."""
if raw_sections is None:
return list(_DEFAULT_VISIBLE_SELF_SETTINGS_SECTIONS)
if isinstance(raw_sections, str):
candidate_sections = [s.strip() for s in raw_sections.split(",") if s.strip()]
elif isinstance(raw_sections, (list, tuple, set)):
candidate_sections = [str(section).strip() for section in raw_sections if str(section).strip()]
else:
return list(_DEFAULT_VISIBLE_SELF_SETTINGS_SECTIONS)
normalized_sections: list[str] = []
for section in candidate_sections:
if section in _VALID_SELF_SETTINGS_SECTIONS and section not in normalized_sections:
normalized_sections.append(section)
if not normalized_sections and candidate_sections:
# Invalid non-empty config should fail-safe to showing defaults.
return list(_DEFAULT_VISIBLE_SELF_SETTINGS_SECTIONS)
return normalized_sections
def _get_visible_self_settings_sections() -> list[str]:
users_config = load_config_file("users")
raw_sections = users_config.get(_VISIBLE_SELF_SETTINGS_SECTIONS_KEY)
return _normalize_visible_self_settings_sections(raw_sections)
def _get_allowed_self_settings_keys(visible_sections: list[str]) -> set[str]:
allowed_keys: set[str] = set()
visible_sections_set = set(visible_sections)
if _SELF_SETTINGS_SECTION_DELIVERY in visible_sections_set:
allowed_keys |= {
key for key, _field in _get_ordered_user_overridable_fields("downloads")
}
if _SELF_SETTINGS_SECTION_SEARCH in visible_sections_set:
allowed_keys |= {
key for key, _field in _get_ordered_user_overridable_fields("search_mode")
}
if _SELF_SETTINGS_SECTION_NOTIFICATIONS in visible_sections_set:
allowed_keys |= {
key for key, _field in _get_ordered_user_overridable_fields("notifications")
}
return allowed_keys
def register_self_user_routes(app: Flask, user_db: UserDB) -> None:
"""Register self-service user endpoints."""
def _require_authenticated_user(f: Callable[..., Any]) -> Callable[..., Any]:
"""Decorator requiring an authenticated session linked to a local user row.
Caches the resolved auth_mode in ``g.auth_mode`` for the request.
"""
@wraps(f)
def decorated(*args, **kwargs):
auth_mode = load_active_auth_mode(CWA_DB_PATH, user_db=user_db)
g.auth_mode = auth_mode
if auth_mode != "none" and "user_id" not in session:
return jsonify({"error": "Authentication required"}), 401
if "db_user_id" not in session:
return jsonify({"error": "Authenticated session is missing local user context"}), 403
return f(*args, **kwargs)
return decorated
@app.route("/api/users/me/edit-context", methods=["GET"])
@_require_authenticated_user
def users_me_edit_context():
user_id, user, user_error = _get_current_user(user_db)
if user_error:
return user_error
serialized_user = _serialize_self_user(user, g.auth_mode)
serialized_user["settings"] = user_db.get_user_settings(user_id)
visible_self_settings_sections = _get_visible_self_settings_sections()
delivery_preferences = None
if _SELF_SETTINGS_SECTION_DELIVERY in visible_self_settings_sections:
try:
delivery_preferences = _build_user_preferences_payload(user_db, user_id, "downloads")
except ValueError:
return jsonify({"error": "Downloads settings tab not found"}), 500
except Exception as exc:
logger.warning(f"Failed to build user delivery preferences for user_id={user_id}: {exc}")
delivery_preferences = None
search_preferences = None
if _SELF_SETTINGS_SECTION_SEARCH in visible_self_settings_sections:
try:
search_preferences = _build_user_preferences_payload(user_db, user_id, "search_mode")
except ValueError:
return jsonify({"error": "Search mode settings tab not found"}), 500
except Exception as exc:
logger.warning(f"Failed to build user search preferences for user_id={user_id}: {exc}")
search_preferences = None
notification_preferences = None
if _SELF_SETTINGS_SECTION_NOTIFICATIONS in visible_self_settings_sections:
try:
notification_preferences = _build_user_preferences_payload(user_db, user_id, "notifications")
except ValueError:
return jsonify({"error": "Notifications settings tab not found"}), 500
except Exception as exc:
logger.warning(f"Failed to build user notification preferences for user_id={user_id}: {exc}")
notification_preferences = None
user_overridable_keys = sorted(
set(delivery_preferences.get("keys", []) if delivery_preferences else [])
| set(search_preferences.get("keys", []) if search_preferences else [])
| set(notification_preferences.get("keys", []) if notification_preferences else [])
)
return jsonify(
{
"user": serialized_user,
"deliveryPreferences": delivery_preferences,
"searchPreferences": search_preferences,
"notificationPreferences": notification_preferences,
"userOverridableKeys": user_overridable_keys,
"visibleUserSettingsSections": visible_self_settings_sections,
}
)
@app.route("/api/users/me/notification-preferences/test", methods=["POST"])
@_require_authenticated_user
def users_me_test_notification_preferences():
user_id, _user, user_error = _get_current_user(user_db)
if user_error:
return user_error
if user_id is None:
return jsonify({"error": "User not found"}), 404
payload = request.get_json(silent=True)
result, status_code = build_user_notification_test_response(
user_id=user_id,
payload=payload,
)
return jsonify(result), status_code
@app.route("/api/users/me", methods=["PUT"])
@_require_authenticated_user
def users_me_update():
user_id, user, user_error = _get_current_user(user_db)
if user_error:
return user_error
data = request.get_json() or {}
if not isinstance(data, dict):
return jsonify({"error": "Request body must be a JSON object"}), 400
capabilities = _get_self_edit_capabilities(user)
auth_source = capabilities["authSource"]
password = data.get("password", "")
if password:
if not capabilities["canSetPassword"]:
return jsonify(
{
"error": f"Cannot set password for {auth_source.upper()} users",
"message": "Password authentication is only available for local users.",
}
), 400
if len(password) < MIN_PASSWORD_LENGTH:
return jsonify({"error": f"Password must be at least {MIN_PASSWORD_LENGTH} characters"}), 400
user_db.update_user(user_id, password_hash=generate_password_hash(password))
user_fields: dict[str, Any] = {}
if "email" in data:
incoming_email = data.get("email")
if incoming_email is None:
user_fields["email"] = None
else:
user_fields["email"] = str(incoming_email).strip() or None
if "display_name" in data:
incoming_display_name = data.get("display_name")
user_fields["display_name"] = (
str(incoming_display_name).strip() or None
if incoming_display_name is not None
else None
)
email_changed = "email" in user_fields and user_fields["email"] != user.get("email")
display_name_changed = (
"display_name" in user_fields
and user_fields["display_name"] != user.get("display_name")
)
if email_changed and not capabilities["canEditEmail"]:
if auth_source == AUTH_SOURCE_CWA:
return jsonify(
{
"error": "Cannot change email for CWA users",
"message": "Email is synced from Calibre-Web.",
}
), 400
return jsonify(
{
"error": "Cannot change email for OIDC users",
"message": "Email is managed by your identity provider.",
}
), 400
if display_name_changed and not capabilities["canEditDisplayName"]:
return jsonify(
{
"error": "Cannot change display name for OIDC users",
"message": "Display name is managed by your identity provider.",
}
), 400
for field in ("email", "display_name"):
if field in user_fields and user_fields[field] == user.get(field):
user_fields.pop(field)
if user_fields:
user_db.update_user(user_id, **user_fields)
if "settings" in data:
settings_payload = data["settings"]
if not isinstance(settings_payload, dict):
return jsonify({"error": "Settings must be an object"}), 400
visible_self_settings_sections = _get_visible_self_settings_sections()
allowed_user_settings_keys = _get_allowed_self_settings_keys(visible_self_settings_sections)
disallowed_keys = sorted(
key for key in settings_payload if key not in allowed_user_settings_keys
)
if disallowed_keys:
return jsonify(
{
"error": "Some settings are admin-only",
"details": [
f"Setting not user-overridable: {key}" for key in disallowed_keys
],
}
), 400
validated_settings, validation_errors = validate_user_settings(settings_payload)
if validation_errors:
return jsonify(
{
"error": "Invalid settings payload",
"details": validation_errors,
}
), 400
user_db.set_user_settings(user_id, validated_settings)
try:
from shelfmark.core.config import config as app_config
app_config.refresh(force=True)
except Exception:
pass
updated = user_db.get_user(user_id=user_id)
if not updated:
return jsonify({"error": "User not found"}), 404
result = _serialize_self_user(updated, g.auth_mode)
result["settings"] = user_db.get_user_settings(user_id)
logger.info(f"User {user_id} updated their own account")
return jsonify(result)
+414 -28
View File
@@ -22,12 +22,14 @@ class FieldBase:
required: bool = False # Whether field must have a value
env_var: Optional[str] = None # Override env var name (defaults to key)
env_supported: bool = True # Whether this setting can be set via ENV var (False = UI-only)
user_overridable: bool = False # Whether admins can set per-user overrides for this field
disabled: bool = False # Whether field is disabled/greyed out
disabled_reason: str = "" # Explanation shown when disabled
show_when: Optional[Dict[str, Any]] = None # Conditional visibility: {"field": "key", "value": "expected"} or {"field": "key", "notEmpty": True}
show_when: Optional[Dict[str, Any] | List[Dict[str, Any]]] = None # Conditional visibility: {"field": "key", "value": "expected"} or list of conditions
disabled_when: Optional[Dict[str, Any]] = None # Conditional disable: {"field": "key", "value": "expected", "reason": "..."}
requires_restart: bool = False # Whether changing this setting requires a container restart
universal_only: bool = False # Only show in Universal search mode (hide in Direct mode)
hidden_in_ui: bool = False # Keep field in schema/save path but hide default renderer
def get_env_var_name(self) -> str:
"""Get the environment variable name for this field."""
@@ -71,6 +73,7 @@ class SelectField(FieldBase):
"""Single-choice dropdown."""
# Options can be a list or a callable that returns a list (for lazy evaluation)
options: Any = field(default_factory=list) # [{value: "", label: ""}] or callable
filter_by_field: Optional[str] = None # Field key whose value filters options via childOf property
@dataclass
@@ -82,6 +85,14 @@ class MultiSelectField(FieldBase):
variant: str = "pills" # "pills" (default) or "dropdown" for checkbox dropdown style
@dataclass
class TagListField(FieldBase):
"""Editable list of free-form string values (tag/chip input)."""
placeholder: str = ""
default: List[str] = field(default_factory=list)
normalize_urls: bool = True
@dataclass
class OrderableListField(FieldBase):
# Options can be a list or a callable that returns a list (for lazy evaluation)
@@ -93,16 +104,55 @@ class OrderableListField(FieldBase):
default: List[Dict[str, Any]] = field(default_factory=list)
@dataclass
class TableField(FieldBase):
"""Editable table of structured rows."""
# Column definitions: [{key, label, type, placeholder?, options?, defaultValue?}, ...]
columns: Any = field(default_factory=list) # list or callable
# Value format: list of objects
default: List[Dict[str, Any]] = field(default_factory=list)
add_label: str = "Add"
empty_message: str = ""
@dataclass
class CustomComponentField:
"""Render a custom frontend component inside settings content."""
key: str
component: str # Frontend component registry key
label: str = ""
description: str = ""
bind_keys: List[str] = field(default_factory=list) # Related value keys this component edits
value_fields: List[Any] = field(default_factory=list) # Backing value schema for this component
wrap_in_field_wrapper: bool = False # Whether to render with standard FieldWrapper layout
disabled: bool = False
disabled_reason: str = ""
show_when: Optional[Dict[str, Any] | List[Dict[str, Any]]] = None
universal_only: bool = False
def get_field_type(self) -> str:
return "CustomComponentField"
def get_bind_keys(self) -> List[str]:
if self.bind_keys:
return self.bind_keys
return [getattr(f, "key") for f in self.value_fields if getattr(f, "key", None)]
@dataclass
class ActionButton:
key: str # Action identifier
label: str # Button text
description: str = "" # Help text
style: str = "default" # "default", "primary", "danger"
callback: Optional[Callable[[], Dict[str, Any]]] = None # Returns {"success": bool, "message": str}
callback: Optional[Callable[..., Dict[str, Any]]] = None # Returns {"success": bool, "message": str}
disabled: bool = False # Whether button is disabled/greyed out
disabled_reason: str = "" # Explanation shown when disabled
show_when: Optional[Dict[str, Any]] = None # Conditional visibility: {"field": "key", "value": "expected"} or {"field": "key", "notEmpty": True}
show_when: Optional[Dict[str, Any] | List[Dict[str, Any]]] = None # Conditional visibility: {"field": "key", "value": "expected"} or list of conditions
disabled_when: Optional[Dict[str, Any]] = None # Conditional disable: {"field": "key", "value": "expected", "reason": "..."}
def get_field_type(self) -> str:
@@ -120,9 +170,10 @@ class HeadingField:
key: str # Unique identifier
title: str # Heading title
description: str = "" # Description text (supports markdown-style links)
description_by_auth_mode: Optional[Dict[str, str]] = None # Optional auth-mode specific description map
link_url: str = "" # Optional URL for a link
link_text: str = "" # Text for the link (defaults to URL if not provided)
show_when: Optional[Dict[str, Any]] = None # Conditional visibility: {"field": "key", "value": "expected"} or {"field": "key", "notEmpty": True}
show_when: Optional[Dict[str, Any] | List[Dict[str, Any]]] = None # Conditional visibility: {"field": "key", "value": "expected"} or list of conditions
universal_only: bool = False # Only show in Universal search mode (hide in Direct mode)
def get_field_type(self) -> str:
@@ -130,7 +181,20 @@ class HeadingField:
# Type alias for all field types
SettingsField = Union[TextField, PasswordField, NumberField, CheckboxField, SelectField, MultiSelectField, OrderableListField, ActionButton, HeadingField]
SettingsField = Union[
TextField,
PasswordField,
NumberField,
CheckboxField,
SelectField,
MultiSelectField,
TagListField,
OrderableListField,
TableField,
CustomComponentField,
ActionButton,
HeadingField,
]
@dataclass
@@ -225,6 +289,48 @@ def get_all_settings_tabs() -> List[SettingsTab]:
return sorted(_SETTINGS_REGISTRY.values(), key=lambda t: (t.order, t.name))
def _iter_value_fields(tab: SettingsTab):
"""Yield value-bearing fields for a tab."""
for field in tab.fields:
if isinstance(field, CustomComponentField):
for value_field in field.value_fields:
if isinstance(value_field, (ActionButton, HeadingField, CustomComponentField)):
continue
yield value_field
continue
if isinstance(field, (ActionButton, HeadingField)):
continue
yield field
def get_settings_field_map(tab_name: Optional[str] = None) -> Dict[str, tuple[SettingsField, str]]:
"""Return key -> (field, tab_name) map for value-bearing settings fields."""
tabs: List[SettingsTab]
if tab_name:
tab = get_settings_tab(tab_name)
if not tab:
return {}
tabs = [tab]
else:
tabs = get_all_settings_tabs()
field_map: Dict[str, tuple[SettingsField, str]] = {}
for tab in tabs:
for field in _iter_value_fields(tab):
field_map[field.key] = (field, tab.name)
return field_map
def get_user_overridable_fields(tab_name: Optional[str] = None) -> Dict[str, tuple[SettingsField, str]]:
"""Return key -> (field, tab_name) map for fields marked user_overridable."""
field_map = get_settings_field_map(tab_name=tab_name)
return {
key: (field, tab)
for key, (field, tab) in field_map.items()
if getattr(field, "user_overridable", False)
}
def list_registered_settings() -> List[str]:
"""List all registered settings tab names."""
return list(_SETTINGS_REGISTRY.keys())
@@ -242,7 +348,11 @@ def _get_config_file_path(tab_name: str) -> Path:
# Core settings tabs share the main settings.json file
if tab_name in ("general", "search_mode"):
return config_dir / "settings.json"
return config_dir / "plugins" / f"{tab_name}.json"
# Sanitize tab_name to prevent path traversal
safe_name = Path(tab_name).name
if not safe_name or safe_name != tab_name:
raise ValueError(f"Invalid tab name: {tab_name}")
return config_dir / "plugins" / f"{safe_name}.json"
def _ensure_config_dir(tab_name: str) -> None:
@@ -323,11 +433,7 @@ def initialize_default_configs() -> bool:
# Collect default values for all fields
defaults = {}
for field in tab.fields:
# Skip non-value fields
if isinstance(field, (ActionButton, HeadingField)):
continue
for field in _iter_value_fields(tab):
# Only include fields that have a non-None default
if field.default is not None:
defaults[field.key] = field.default
@@ -359,11 +465,7 @@ def sync_env_to_config() -> None:
for tab in get_all_settings_tabs():
values_to_sync = {}
for field in tab.fields:
# Skip non-value fields
if isinstance(field, (ActionButton, HeadingField)):
continue
for field in _iter_value_fields(tab):
# Skip fields that don't support ENV vars
if not getattr(field, 'env_supported', True):
continue
@@ -383,6 +485,106 @@ def sync_env_to_config() -> None:
logger.debug(f"Synced {len(values_to_sync)} ENV values to {tab.name} config: {list(values_to_sync.keys())}")
migrate_legacy_settings()
migrate_download_to_browser_settings()
migrate_mirror_settings()
def migrate_mirror_settings() -> None:
"""
Sync AA mirror list when code defaults change between versions.
On startup, compares a hash of DEFAULT_AA_MIRRORS against the hash stored
in the config file. If they differ (i.e., an update shipped new defaults),
the config is overwritten with the new defaults. If they match, the user's
customizations are left untouched.
Also handles legacy migration from AA_ADDITIONAL_URLS.
"""
import hashlib
from shelfmark.core.mirrors import DEFAULT_AA_MIRRORS
from shelfmark.core.utils import normalize_http_url
def _normalize_list(values: list[str]) -> list[str]:
out: list[str] = []
for item in values:
if str(item).strip().lower() == "auto":
continue
norm = normalize_http_url(str(item), default_scheme="https")
if norm and norm not in out:
out.append(norm)
return out
def _hash_mirrors(mirrors: list[str]) -> str:
return hashlib.sha256(",".join(mirrors).encode()).hexdigest()
normalized_defaults = _normalize_list(DEFAULT_AA_MIRRORS)
current_defaults_hash = _hash_mirrors(normalized_defaults)
mirrors_config = load_config_file("mirrors")
stored_hash = mirrors_config.get("_AA_MIRRORS_DEFAULTS_HASH")
raw_list = mirrors_config.get("AA_MIRROR_URLS")
raw_additional = mirrors_config.get("AA_ADDITIONAL_URLS", "")
def _save_mirrors(values: dict[str, Any]) -> None:
merged = dict(mirrors_config)
merged.update(values)
save_config_file("mirrors", merged)
mirrors_config.update(values)
# Defaults changed since last startup — push new mirrors to config
if stored_hash != current_defaults_hash:
_save_mirrors({
"AA_MIRROR_URLS": normalized_defaults,
"_AA_MIRRORS_DEFAULTS_HASH": current_defaults_hash,
})
return
# --- Legacy migration (only runs if hash already matches / first time) ---
# If already a proper list, just ensure it's non-empty.
if isinstance(raw_list, list):
normalized = _normalize_list([str(v) for v in raw_list])
if normalized:
return
_save_mirrors({
"AA_MIRROR_URLS": normalized_defaults,
"_AA_MIRRORS_DEFAULTS_HASH": current_defaults_hash,
})
return
# If saved as a string, convert to list.
if isinstance(raw_list, str) and raw_list.strip():
parts = [p.strip() for p in raw_list.split(",") if p.strip()]
normalized = _normalize_list(parts)
if normalized:
_save_mirrors({
"AA_MIRROR_URLS": normalized,
"_AA_MIRRORS_DEFAULTS_HASH": current_defaults_hash,
})
return
_save_mirrors({
"AA_MIRROR_URLS": normalized_defaults,
"_AA_MIRRORS_DEFAULTS_HASH": current_defaults_hash,
})
return
# If there's legacy additional mirrors, seed the full list.
if isinstance(raw_additional, str) and raw_additional.strip():
additional_parts = [p.strip() for p in raw_additional.split(",") if p.strip()]
combined = _normalize_list(DEFAULT_AA_MIRRORS + additional_parts)
if combined:
_save_mirrors({
"AA_MIRROR_URLS": combined,
"_AA_MIRRORS_DEFAULTS_HASH": current_defaults_hash,
})
return
# No config at all yet — write defaults
_save_mirrors({
"AA_MIRROR_URLS": normalized_defaults,
"_AA_MIRRORS_DEFAULTS_HASH": current_defaults_hash,
})
def migrate_legacy_settings() -> None:
@@ -494,8 +696,59 @@ def migrate_legacy_settings() -> None:
logger.info(f"Migrated content-type routing settings: {list(migrated_sources.keys())}")
def migrate_download_to_browser_settings() -> None:
"""Migrate the legacy download-to-browser toggle to content-type selection."""
downloads_config = load_config_file("downloads")
legacy_key = "DOWNLOAD_TO_BROWSER"
new_key = "DOWNLOAD_TO_BROWSER_CONTENT_TYPES"
config_path = _get_config_file_path("downloads")
legacy_value: Any = None
legacy_present = False
if legacy_key in downloads_config:
legacy_value = downloads_config.get(legacy_key)
legacy_present = True
elif new_key not in downloads_config and os.environ.get(new_key) is None and legacy_key in os.environ:
legacy_value = os.environ.get(legacy_key)
legacy_present = True
if not legacy_present and legacy_key not in downloads_config:
return
updated_downloads = dict(downloads_config)
changed = False
if new_key not in updated_downloads and legacy_present:
enabled = False
if isinstance(legacy_value, bool):
enabled = legacy_value
elif isinstance(legacy_value, str):
enabled = legacy_value.strip().lower() in {"true", "1", "yes", "on"}
else:
enabled = bool(legacy_value)
updated_downloads[new_key] = ["book", "audiobook"] if enabled else []
changed = True
if legacy_key in updated_downloads:
updated_downloads.pop(legacy_key, None)
changed = True
if not changed:
return
try:
_ensure_config_dir("downloads")
with open(config_path, "w") as f:
json.dump(updated_downloads, f, indent=2)
logger.info("Migrated download-to-browser setting to content-type selection")
except Exception as exc:
logger.error(f"Failed to migrate download-to-browser settings: {exc}")
def get_setting_value(field: SettingsField, tab_name: str) -> Any:
if isinstance(field, (ActionButton, HeadingField)):
if isinstance(field, (ActionButton, HeadingField, CustomComponentField)):
return None # Actions and headings don't have values
# 1. Check environment variable (if supported for this field)
@@ -527,6 +780,8 @@ def _parse_env_value(value: str, field: SettingsField) -> Any:
return field.default
elif isinstance(field, MultiSelectField):
return [v.strip() for v in value.split(',') if v.strip()]
elif isinstance(field, TagListField):
return [v.strip() for v in value.split(',') if v.strip()]
elif isinstance(field, OrderableListField):
# Parse JSON array: [{"id": "...", "enabled": true}, ...]
try:
@@ -534,13 +789,21 @@ def _parse_env_value(value: str, field: SettingsField) -> Any:
except json.JSONDecodeError:
logger.warning(f"Invalid JSON for {field.key}, using default")
return field.default
elif isinstance(field, TableField):
# Parse JSON array: [{"col": "value"}, ...]
try:
parsed = json.loads(value)
return parsed if isinstance(parsed, list) else field.default
except json.JSONDecodeError:
logger.warning(f"Invalid JSON for {field.key}, using default")
return field.default
else:
return value
def is_value_from_env(field: SettingsField) -> bool:
"""Check if a field's value comes from an environment variable."""
if isinstance(field, (ActionButton, HeadingField)):
if isinstance(field, (ActionButton, HeadingField, CustomComponentField)):
return False
# UI-only settings never come from ENV (env_supported=False)
if not getattr(field, 'env_supported', True):
@@ -560,14 +823,46 @@ def serialize_field(field: SettingsField, tab_name: str, include_value: bool = T
Returns:
Dict representation of the field.
"""
# CustomComponentField has a custom structure - handle separately
if isinstance(field, CustomComponentField):
result: Dict[str, Any] = {
"key": field.key,
"label": field.label,
"type": field.get_field_type(),
"description": field.description,
"component": field.component,
"bindKeys": field.get_bind_keys(),
"wrapInFieldWrapper": field.wrap_in_field_wrapper,
"disabled": field.disabled,
"disabledReason": field.disabled_reason,
}
if field.value_fields:
bound_fields = []
for value_field in field.value_fields:
serialized_bound_field = serialize_field(
value_field,
tab_name,
include_value=include_value,
)
serialized_bound_field["hiddenInUi"] = True
bound_fields.append(serialized_bound_field)
result["boundFields"] = bound_fields
if field.show_when:
result["showWhen"] = field.show_when
if field.universal_only:
result["universalOnly"] = True
return result
# HeadingField has a different structure - handle separately
if isinstance(field, HeadingField):
result = {
result: Dict[str, Any] = {
"key": field.key,
"type": field.get_field_type(),
"title": field.title,
"description": field.description,
}
if field.description_by_auth_mode:
result["descriptionByAuthMode"] = field.description_by_auth_mode
if field.link_url:
result["linkUrl"] = field.link_url
result["linkText"] = field.link_text or field.link_url
@@ -577,7 +872,7 @@ def serialize_field(field: SettingsField, tab_name: str, include_value: bool = T
result["universalOnly"] = True
return result
result = {
result: Dict[str, Any] = {
"key": field.key,
"label": field.label,
"type": field.get_field_type(),
@@ -586,6 +881,8 @@ def serialize_field(field: SettingsField, tab_name: str, include_value: bool = T
"disabled": getattr(field, 'disabled', False),
"disabledReason": getattr(field, 'disabled_reason', ''),
"requiresRestart": getattr(field, 'requires_restart', False),
"userOverridable": getattr(field, 'user_overridable', False),
"hiddenInUi": getattr(field, 'hidden_in_ui', False),
}
# Add optional properties if set
@@ -613,21 +910,63 @@ def serialize_field(field: SettingsField, tab_name: str, include_value: bool = T
result["options"] = options
if field.default is not None:
result["default"] = field.default
if field.filter_by_field:
result["filterByField"] = field.filter_by_field
elif isinstance(field, MultiSelectField):
# Support callable options for lazy evaluation (avoids circular imports)
options = field.options() if callable(field.options) else field.options
result["options"] = options
result["variant"] = field.variant
elif isinstance(field, TagListField):
result["placeholder"] = field.placeholder
result["normalizeUrls"] = field.normalize_urls
elif isinstance(field, OrderableListField):
# Support callable options for lazy evaluation (avoids circular imports)
options = field.options() if callable(field.options) else field.options
result["options"] = options
elif isinstance(field, TableField):
columns = field.columns() if callable(field.columns) else field.columns
result["columns"] = columns
result["addLabel"] = field.add_label
result["emptyMessage"] = field.empty_message
elif isinstance(field, ActionButton):
result["style"] = field.style
result["description"] = field.description
if include_value and not isinstance(field, (ActionButton, HeadingField)):
if include_value and not isinstance(field, (ActionButton, HeadingField, CustomComponentField)):
value = get_setting_value(field, tab_name)
# Ensure select values are serialized as strings so the frontend can
# reliably match against string option values.
if isinstance(field, SelectField) and value is not None:
value = str(value)
elif isinstance(field, MultiSelectField):
if value is None:
value = []
elif isinstance(value, list):
value = [str(v) for v in value]
elif isinstance(value, str):
# Support legacy/manual configs where MultiSelect values were saved
# as comma-separated strings.
value = [v.strip() for v in value.split(",") if v.strip()]
else:
value = []
elif isinstance(field, TagListField):
if value is None:
value = []
elif isinstance(value, list):
value = [str(v) for v in value]
elif isinstance(value, str):
# Support legacy/manual configs where lists were saved as comma-separated strings.
value = [v.strip() for v in value.split(",") if v.strip()]
else:
value = []
elif isinstance(field, TableField):
if value is None:
value = []
elif not isinstance(value, list):
value = []
result["value"] = value if value is not None else ""
result["fromEnv"] = is_value_from_env(field)
@@ -696,7 +1035,7 @@ def execute_action(tab_name: str, action_key: str, current_values: Optional[Dict
try:
# Check if callback accepts current_values parameter
sig = inspect.signature(field.callback)
if 'current_values' in sig.parameters:
if "current_values" in sig.parameters:
return field.callback(current_values=current_values or {})
else:
return field.callback()
@@ -749,6 +1088,23 @@ def _apply_dns_settings(config) -> None:
except Exception as e:
logger.warning(f"Failed to apply DNS settings: {e}")
def _apply_aa_mirror_settings(config) -> None:
"""
Apply AA mirror settings changes to the network module.
This ensures AA_BASE_URL / AA_ADDITIONAL_URLS changes take effect immediately
without requiring a container restart.
"""
try:
from shelfmark.download import network
# Reload AA mirror list and configured base URL from refreshed config.
network.init_aa(force=True)
except ImportError:
pass # Network module not available
except Exception as e:
logger.warning(f"Failed to apply AA mirror settings: {e}")
def update_settings(tab_name: str, values: Dict[str, Any]) -> Dict[str, Any]:
tab = get_settings_tab(tab_name)
@@ -756,7 +1112,10 @@ def update_settings(tab_name: str, values: Dict[str, Any]) -> Dict[str, Any]:
return {"success": False, "message": f"Unknown settings tab: {tab_name}", "updated": [], "requiresRestart": False}
# Build a map of field keys to fields (exclude non-value fields)
field_map = {f.key: f for f in tab.fields if not isinstance(f, (ActionButton, HeadingField))}
field_map = {
key: field
for key, (field, _) in get_settings_field_map(tab_name=tab_name).items()
}
# Filter out values that are set via env vars or unknown
values_to_save = {}
@@ -816,16 +1175,43 @@ def update_settings(tab_name: str, values: Dict[str, Any]) -> Dict[str, Any]:
# Save to config file
if save_config_file(tab_name, values_to_save):
# Refresh the config singleton so live settings take effect immediately
config_obj = None
try:
from shelfmark.core.config import config
config.refresh()
from shelfmark.core.config import config as config_obj
config_obj.refresh()
except ImportError:
pass # Config module not yet available during initial setup
config_obj = None # Config module not yet available during initial setup
# Apply DNS settings changes live (network tab)
dns_keys = {"CUSTOM_DNS", "CUSTOM_DNS_MANUAL", "USE_DOH"}
if tab_name == "network" and dns_keys.intersection(values_to_save.keys()):
_apply_dns_settings(config)
if (
config_obj is not None
and tab_name == "network"
and dns_keys.intersection(values_to_save.keys())
):
_apply_dns_settings(config_obj)
# Apply certificate validation changes live (network tab)
if (
config_obj is not None
and tab_name == "network"
and "CERTIFICATE_VALIDATION" in values_to_save
):
try:
from shelfmark.download.network import _apply_ssl_warning_suppression
_apply_ssl_warning_suppression()
except Exception as e:
logger.warning(f"Failed to apply certificate validation setting: {e}")
# Apply AA mirror settings changes live (mirrors tab)
aa_keys = {"AA_BASE_URL", "AA_MIRROR_URLS", "AA_ADDITIONAL_URLS"}
if (
config_obj is not None
and tab_name == "mirrors"
and aa_keys.intersection(values_to_save.keys())
):
_apply_aa_mirror_settings(config_obj)
# Sync metadata provider selection when a provider's enabled state changes
tab = get_settings_tab(tab_name)
+910
View File
@@ -0,0 +1,910 @@
"""SQLite user database for multi-user support."""
import json
import os
import sqlite3
import threading
from typing import Any, Dict, List, Optional
from shelfmark.core.auth_modes import AUTH_SOURCE_BUILTIN, AUTH_SOURCE_SET
from shelfmark.core.activity_view_state_service import user_viewer_scope
from shelfmark.core.logger import setup_logger
from shelfmark.core.request_helpers import normalize_optional_positive_int
from shelfmark.core.models import QueueStatus
from shelfmark.core.request_validation import (
DELIVERY_STATE_NONE,
RequestStatus,
normalize_delivery_state,
normalize_policy_mode,
normalize_request_level,
normalize_request_status,
validate_request_level_payload,
validate_status_transition,
)
logger = setup_logger(__name__)
_CREATE_TABLES_SQL = """
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
email TEXT,
display_name TEXT,
password_hash TEXT,
oidc_subject TEXT UNIQUE,
auth_source TEXT NOT NULL DEFAULT 'builtin',
role TEXT NOT NULL DEFAULT 'user',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS user_settings (
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
settings_json TEXT NOT NULL DEFAULT '{}'
);
CREATE TABLE IF NOT EXISTS download_requests (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
status TEXT NOT NULL DEFAULT 'pending',
delivery_state TEXT NOT NULL DEFAULT 'none',
source_hint TEXT,
content_type TEXT NOT NULL,
request_level TEXT NOT NULL,
policy_mode TEXT NOT NULL,
book_data TEXT NOT NULL,
release_data TEXT,
note TEXT,
admin_note TEXT,
reviewed_by INTEGER REFERENCES users(id),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
reviewed_at TIMESTAMP,
delivery_updated_at TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_download_requests_user_status_created_at
ON download_requests (user_id, status, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_download_requests_status_created_at
ON download_requests (status, created_at DESC);
CREATE TABLE IF NOT EXISTS download_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id TEXT UNIQUE NOT NULL,
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
username TEXT,
request_id INTEGER,
source TEXT NOT NULL,
source_display_name TEXT,
title TEXT NOT NULL,
author TEXT,
format TEXT,
size TEXT,
preview TEXT,
content_type TEXT,
origin TEXT NOT NULL DEFAULT 'direct',
final_status TEXT NOT NULL,
status_message TEXT,
download_path TEXT,
queued_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
terminal_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_download_history_user_status
ON download_history (user_id, final_status, terminal_at DESC);
CREATE INDEX IF NOT EXISTS idx_download_history_recent
ON download_history (user_id, terminal_at DESC, id DESC);
CREATE TABLE IF NOT EXISTS activity_view_state (
id INTEGER PRIMARY KEY AUTOINCREMENT,
viewer_scope TEXT NOT NULL,
item_type TEXT NOT NULL,
item_key TEXT NOT NULL,
dismissed_at TIMESTAMP,
cleared_at TIMESTAMP,
UNIQUE(viewer_scope, item_type, item_key)
);
CREATE INDEX IF NOT EXISTS idx_activity_view_state_history
ON activity_view_state (viewer_scope, dismissed_at DESC, id DESC)
WHERE dismissed_at IS NOT NULL AND cleared_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_activity_view_state_hidden
ON activity_view_state (viewer_scope, item_type, item_key)
WHERE dismissed_at IS NOT NULL;
"""
def get_users_db_path(config_dir: Optional[str] = None) -> str:
"""Return the configured users database path."""
root = config_dir or os.environ.get("CONFIG_DIR", "/config")
return os.path.join(root, "users.db")
def sync_builtin_admin_user(
username: str,
password_hash: str,
db_path: Optional[str] = None,
) -> None:
"""Ensure a local admin user exists for configured builtin credentials."""
normalized_username = (username or "").strip()
normalized_hash = password_hash or ""
if not normalized_username or not normalized_hash:
return
user_db = UserDB(db_path or get_users_db_path())
user_db.initialize()
existing = user_db.get_user(username=normalized_username)
if existing:
existing_auth_source = str(existing.get("auth_source") or AUTH_SOURCE_BUILTIN).strip().lower()
if existing_auth_source != AUTH_SOURCE_BUILTIN:
logger.warning(
"Skipped builtin admin sync for username '%s' because it belongs to auth_source='%s'",
normalized_username,
existing_auth_source,
)
return
updates: dict[str, Any] = {}
if existing.get("password_hash") != normalized_hash:
updates["password_hash"] = normalized_hash
if existing.get("role") != "admin":
updates["role"] = "admin"
if existing.get("auth_source") != AUTH_SOURCE_BUILTIN:
updates["auth_source"] = AUTH_SOURCE_BUILTIN
if updates:
user_db.update_user(existing["id"], **updates)
logger.info(f"Updated local admin user '{normalized_username}' from builtin settings")
return
user_db.create_user(
username=normalized_username,
password_hash=normalized_hash,
auth_source=AUTH_SOURCE_BUILTIN,
role="admin",
)
logger.info(f"Created local admin user '{normalized_username}' from builtin settings")
class UserDB:
"""Thread-safe SQLite user database."""
_VALID_AUTH_SOURCES = set(AUTH_SOURCE_SET)
def __init__(self, db_path: str):
self._db_path = db_path
self._lock = threading.Lock()
def _connect(self) -> sqlite3.Connection:
conn = sqlite3.connect(self._db_path)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA foreign_keys = ON")
return conn
def initialize(self) -> None:
"""Create database and tables if they don't exist."""
with self._lock:
conn = self._connect()
try:
conn.executescript(_CREATE_TABLES_SQL)
self._migrate_auth_source_column(conn)
self._migrate_request_delivery_columns(conn)
self._migrate_download_history_queued_at(conn)
conn.commit()
# WAL mode must be changed outside an open transaction.
conn.execute("PRAGMA journal_mode=WAL")
finally:
conn.close()
def _migrate_auth_source_column(self, conn: sqlite3.Connection) -> None:
"""Ensure users.auth_source exists and backfill historical rows."""
columns = conn.execute("PRAGMA table_info(users)").fetchall()
column_names = {str(col["name"]) for col in columns}
if "auth_source" not in column_names:
conn.execute(
"ALTER TABLE users ADD COLUMN auth_source TEXT NOT NULL DEFAULT 'builtin'"
)
# Backfill OIDC-origin users created before auth_source existed.
conn.execute(
"UPDATE users SET auth_source = 'oidc' WHERE oidc_subject IS NOT NULL"
)
# Defensive cleanup for any legacy null/blank values.
conn.execute(
"UPDATE users SET auth_source = 'builtin' WHERE auth_source IS NULL OR auth_source = ''"
)
def _migrate_request_delivery_columns(self, conn: sqlite3.Connection) -> None:
"""Ensure request delivery-state columns exist and backfill historical rows."""
columns = conn.execute("PRAGMA table_info(download_requests)").fetchall()
column_names = {str(col["name"]) for col in columns}
if "delivery_state" not in column_names:
conn.execute(
"ALTER TABLE download_requests ADD COLUMN delivery_state TEXT NOT NULL DEFAULT 'none'"
)
if "delivery_updated_at" not in column_names:
conn.execute("ALTER TABLE download_requests ADD COLUMN delivery_updated_at TIMESTAMP")
if "last_failure_reason" not in column_names:
conn.execute("ALTER TABLE download_requests ADD COLUMN last_failure_reason TEXT")
conn.execute(
"""
UPDATE download_requests
SET delivery_state = 'none'
WHERE delivery_state IS NULL OR TRIM(delivery_state) = '' OR delivery_state IN ('unknown', 'available', 'done')
"""
)
conn.execute(
"""
UPDATE download_requests
SET delivery_updated_at = COALESCE(delivery_updated_at, reviewed_at, created_at)
WHERE delivery_state != 'none' AND delivery_updated_at IS NULL
"""
)
def _migrate_download_history_queued_at(self, conn: sqlite3.Connection) -> None:
"""Ensure download_history.queued_at exists for queue-time recording."""
columns = conn.execute("PRAGMA table_info(download_history)").fetchall()
column_names = {str(col["name"]) for col in columns}
if "queued_at" not in column_names:
conn.execute("ALTER TABLE download_history ADD COLUMN queued_at TIMESTAMP")
conn.execute(
"UPDATE download_history SET queued_at = CURRENT_TIMESTAMP WHERE queued_at IS NULL"
)
def create_user(
self,
username: str,
email: Optional[str] = None,
display_name: Optional[str] = None,
password_hash: Optional[str] = None,
oidc_subject: Optional[str] = None,
auth_source: str = "builtin",
role: str = "user",
) -> Dict[str, Any]:
"""Create a new user. Raises ValueError if username or oidc_subject already exists."""
if auth_source not in self._VALID_AUTH_SOURCES:
raise ValueError(f"Invalid auth_source: {auth_source}")
with self._lock:
conn = self._connect()
try:
cursor = conn.execute(
"""INSERT INTO users (
username, email, display_name, password_hash, oidc_subject, auth_source, role
)
VALUES (?, ?, ?, ?, ?, ?, ?)""",
(
username,
email,
display_name,
password_hash,
oidc_subject,
auth_source,
role,
),
)
conn.commit()
user_id = cursor.lastrowid
return self._get_user_by_id(conn, user_id)
except sqlite3.IntegrityError as e:
raise ValueError(f"User already exists: {e}")
finally:
conn.close()
def get_user(
self,
user_id: Optional[int] = None,
username: Optional[str] = None,
oidc_subject: Optional[str] = None,
) -> Optional[Dict[str, Any]]:
"""Get a user by id, username, or oidc_subject. Returns None if not found."""
conn = self._connect()
try:
if user_id is not None:
return self._get_user_by_id(conn, user_id)
elif username is not None:
row = conn.execute(
"SELECT * FROM users WHERE username = ?", (username,)
).fetchone()
elif oidc_subject is not None:
row = conn.execute(
"SELECT * FROM users WHERE oidc_subject = ?", (oidc_subject,)
).fetchone()
else:
return None
return dict(row) if row else None
finally:
conn.close()
def _get_user_by_id(self, conn: sqlite3.Connection, user_id: int) -> Optional[Dict[str, Any]]:
row = conn.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone()
return dict(row) if row else None
_ALLOWED_UPDATE_COLUMNS = {
"email",
"display_name",
"password_hash",
"oidc_subject",
"auth_source",
"role",
}
def update_user(self, user_id: int, **kwargs) -> None:
"""Update user fields. Raises ValueError if user not found or invalid column."""
if not kwargs:
return
for k in kwargs:
if k not in self._ALLOWED_UPDATE_COLUMNS:
raise ValueError(f"Invalid column: {k}")
if "auth_source" in kwargs and kwargs["auth_source"] not in self._VALID_AUTH_SOURCES:
raise ValueError(f"Invalid auth_source: {kwargs['auth_source']}")
with self._lock:
conn = self._connect()
try:
# Verify user exists
if not self._get_user_by_id(conn, user_id):
raise ValueError(f"User {user_id} not found")
sets = ", ".join(f"{k} = ?" for k in kwargs)
values = list(kwargs.values()) + [user_id]
conn.execute(f"UPDATE users SET {sets} WHERE id = ?", values)
conn.commit()
finally:
conn.close()
def delete_user(self, user_id: int) -> None:
"""Delete a user and their settings."""
with self._lock:
conn = self._connect()
try:
request_rows = conn.execute(
"SELECT id FROM download_requests WHERE user_id = ?",
(user_id,),
).fetchall()
request_item_keys = [f"request:{row['id']}" for row in request_rows]
if request_item_keys:
placeholders = ",".join("?" for _ in request_item_keys)
conn.execute(
f"""
DELETE FROM activity_view_state
WHERE item_type = 'request'
AND item_key IN ({placeholders})
""",
request_item_keys,
)
conn.execute(
"DELETE FROM activity_view_state WHERE viewer_scope = ?",
(user_viewer_scope(user_id),),
)
conn.execute("UPDATE download_requests SET reviewed_by = NULL WHERE reviewed_by = ?", (user_id,))
conn.execute("DELETE FROM users WHERE id = ?", (user_id,))
conn.commit()
finally:
conn.close()
def list_users(self) -> List[Dict[str, Any]]:
"""List all users."""
conn = self._connect()
try:
rows = conn.execute("SELECT * FROM users ORDER BY id").fetchall()
return [dict(r) for r in rows]
finally:
conn.close()
def has_admin_with_password(self) -> bool:
"""Return True when at least one admin user with a password hash exists."""
conn = self._connect()
try:
row = conn.execute(
"SELECT 1 FROM users WHERE role = 'admin'"
" AND password_hash IS NOT NULL AND password_hash != ''"
" LIMIT 1",
).fetchone()
return row is not None
finally:
conn.close()
def get_user_settings(self, user_id: int) -> Dict[str, Any]:
"""Get per-user settings. Returns empty dict if none set."""
conn = self._connect()
try:
row = conn.execute(
"SELECT settings_json FROM user_settings WHERE user_id = ?", (user_id,)
).fetchone()
if row:
return json.loads(row["settings_json"])
return {}
finally:
conn.close()
def set_user_settings(self, user_id: int, settings: Dict[str, Any]) -> None:
"""Merge settings into user's existing settings."""
with self._lock:
conn = self._connect()
try:
existing = {}
row = conn.execute(
"SELECT settings_json FROM user_settings WHERE user_id = ?", (user_id,)
).fetchone()
if row:
existing = json.loads(row["settings_json"])
existing.update(settings)
# Remove keys set to None (meaning "clear this override")
existing = {k: v for k, v in existing.items() if v is not None}
settings_json = json.dumps(existing)
conn.execute(
"""INSERT INTO user_settings (user_id, settings_json) VALUES (?, ?)
ON CONFLICT(user_id) DO UPDATE SET settings_json = ?""",
(user_id, settings_json, settings_json),
)
conn.commit()
finally:
conn.close()
@staticmethod
def _serialize_json(value: Any, field: str) -> Optional[str]:
if value is None:
return None
try:
return json.dumps(value)
except TypeError as exc:
raise ValueError(f"{field} must be JSON-serializable") from exc
@staticmethod
def _parse_request_row(row: Optional[sqlite3.Row]) -> Optional[Dict[str, Any]]:
if row is None:
return None
payload = dict(row)
for key in ("book_data", "release_data"):
raw_value = payload.get(key)
if raw_value is None:
payload[key] = None
continue
try:
payload[key] = json.loads(raw_value)
except (ValueError, TypeError):
payload[key] = None
return payload
def _insert_request(
self,
conn: sqlite3.Connection,
*,
user_id: int,
content_type: str,
request_level: str,
policy_mode: str,
book_data: Dict[str, Any],
release_data: Optional[Dict[str, Any]] = None,
status: str = RequestStatus.PENDING,
source_hint: Optional[str] = None,
note: Optional[str] = None,
admin_note: Optional[str] = None,
reviewed_by: Optional[int] = None,
reviewed_at: Optional[str] = None,
delivery_state: str = DELIVERY_STATE_NONE,
delivery_updated_at: Optional[str] = None,
) -> Dict[str, Any]:
cursor = conn.execute(
"""
INSERT INTO download_requests (
user_id,
status,
delivery_state,
source_hint,
content_type,
request_level,
policy_mode,
book_data,
release_data,
note,
admin_note,
reviewed_by,
reviewed_at,
delivery_updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
user_id,
status,
delivery_state,
source_hint,
content_type,
request_level,
policy_mode,
self._serialize_json(book_data, "book_data"),
self._serialize_json(release_data, "release_data"),
note,
admin_note,
reviewed_by,
reviewed_at,
delivery_updated_at,
),
)
request_id = cursor.lastrowid
row = conn.execute(
"SELECT * FROM download_requests WHERE id = ?",
(request_id,),
).fetchone()
parsed = self._parse_request_row(row)
if parsed is None:
raise ValueError(f"Request {request_id} not found after creation")
return parsed
def create_request(
self,
*,
user_id: int,
content_type: str,
request_level: str,
policy_mode: str,
book_data: Dict[str, Any],
release_data: Optional[Dict[str, Any]] = None,
status: str = RequestStatus.PENDING,
source_hint: Optional[str] = None,
note: Optional[str] = None,
admin_note: Optional[str] = None,
reviewed_by: Optional[int] = None,
reviewed_at: Optional[str] = None,
delivery_state: str = DELIVERY_STATE_NONE,
delivery_updated_at: Optional[str] = None,
) -> Dict[str, Any]:
"""Create a download request row and return the created record."""
if not isinstance(book_data, dict):
raise ValueError("book_data must be an object")
if release_data is not None and not isinstance(release_data, dict):
raise ValueError("release_data must be an object when provided")
if not content_type:
raise ValueError("content_type is required")
normalized_status = normalize_request_status(status)
normalized_delivery_state = normalize_delivery_state(delivery_state)
normalized_policy_mode = normalize_policy_mode(policy_mode)
normalized_request_level = validate_request_level_payload(request_level, release_data)
with self._lock:
conn = self._connect()
try:
created = self._insert_request(
conn,
user_id=user_id,
content_type=content_type,
request_level=normalized_request_level,
policy_mode=normalized_policy_mode,
book_data=book_data,
release_data=release_data,
status=normalized_status,
source_hint=source_hint,
note=note,
admin_note=admin_note,
reviewed_by=reviewed_by,
reviewed_at=reviewed_at,
delivery_state=normalized_delivery_state,
delivery_updated_at=delivery_updated_at,
)
conn.commit()
return created
finally:
conn.close()
def create_requests(self, requests: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""Create multiple request rows atomically and return them in input order."""
with self._lock:
conn = self._connect()
try:
created: List[Dict[str, Any]] = []
for request in requests:
created.append(self._insert_request(conn, **request))
conn.commit()
return created
finally:
conn.close()
def get_request(self, request_id: int) -> Optional[Dict[str, Any]]:
"""Get a request row by ID."""
conn = self._connect()
try:
row = conn.execute(
"SELECT * FROM download_requests WHERE id = ?",
(request_id,),
).fetchone()
return self._parse_request_row(row)
finally:
conn.close()
def list_requests(
self,
*,
user_id: Optional[int] = None,
status: Optional[str] = None,
limit: Optional[int] = None,
offset: int = 0,
) -> List[Dict[str, Any]]:
"""List requests with optional user/status filters."""
where_clauses: List[str] = []
params: List[Any] = []
if user_id is not None:
where_clauses.append("user_id = ?")
params.append(user_id)
if status is not None:
where_clauses.append("status = ?")
params.append(normalize_request_status(status))
query = "SELECT * FROM download_requests"
if where_clauses:
query += " WHERE " + " AND ".join(where_clauses)
query += " ORDER BY created_at DESC, id DESC"
if limit is not None:
query += " LIMIT ?"
params.append(int(limit))
if offset:
query += " OFFSET ?"
params.append(offset)
elif offset:
query += " LIMIT -1 OFFSET ?"
params.append(offset)
conn = self._connect()
try:
rows = conn.execute(query, params).fetchall()
results: List[Dict[str, Any]] = []
for row in rows:
parsed = self._parse_request_row(row)
if parsed is not None:
results.append(parsed)
return results
finally:
conn.close()
_ALLOWED_REQUEST_UPDATE_COLUMNS = {
"status",
"source_hint",
"content_type",
"request_level",
"policy_mode",
"book_data",
"release_data",
"note",
"admin_note",
"reviewed_by",
"reviewed_at",
"delivery_state",
"delivery_updated_at",
"last_failure_reason",
}
def update_request(
self,
request_id: int,
expected_current_status: Optional[str] = None,
**kwargs,
) -> Dict[str, Any]:
"""Update request fields and return the updated record."""
if not kwargs:
request = self.get_request(request_id)
if request is None:
raise ValueError(f"Request {request_id} not found")
if expected_current_status is not None:
normalized_expected_status = normalize_request_status(expected_current_status)
if request["status"] != normalized_expected_status:
raise ValueError("Request state changed before update")
return request
for key in kwargs:
if key not in self._ALLOWED_REQUEST_UPDATE_COLUMNS:
raise ValueError(f"Invalid request column: {key}")
with self._lock:
conn = self._connect()
try:
row = conn.execute(
"SELECT * FROM download_requests WHERE id = ?",
(request_id,),
).fetchone()
current = self._parse_request_row(row)
if current is None:
raise ValueError(f"Request {request_id} not found")
if expected_current_status is not None:
normalized_expected_status = normalize_request_status(expected_current_status)
if current["status"] != normalized_expected_status:
raise ValueError("Request state changed before update")
updates = dict(kwargs)
if "status" in updates:
_, normalized_status = validate_status_transition(
current["status"],
updates["status"],
)
updates["status"] = normalized_status
if "policy_mode" in updates:
updates["policy_mode"] = normalize_policy_mode(updates["policy_mode"])
if "delivery_state" in updates:
updates["delivery_state"] = normalize_delivery_state(updates["delivery_state"])
if "delivery_updated_at" in updates:
delivery_updated_at = updates["delivery_updated_at"]
if delivery_updated_at is not None and not isinstance(delivery_updated_at, str):
raise ValueError("delivery_updated_at must be a string when provided")
if "content_type" in updates and not updates["content_type"]:
raise ValueError("content_type is required")
if "request_level" in updates:
updates["request_level"] = normalize_request_level(updates["request_level"])
if "book_data" in updates:
if not isinstance(updates["book_data"], dict):
raise ValueError("book_data must be an object")
updates["book_data"] = self._serialize_json(updates["book_data"], "book_data")
if "release_data" in updates:
if updates["release_data"] is not None and not isinstance(updates["release_data"], dict):
raise ValueError("release_data must be an object when provided")
updates["release_data"] = self._serialize_json(
updates["release_data"],
"release_data",
)
set_clause = ", ".join(f"{column} = ?" for column in updates)
values = list(updates.values()) + [request_id]
conn.execute(
f"UPDATE download_requests SET {set_clause} WHERE id = ?",
values,
)
conn.commit()
updated_row = conn.execute(
"SELECT * FROM download_requests WHERE id = ?",
(request_id,),
).fetchone()
parsed = self._parse_request_row(updated_row)
if parsed is None:
raise ValueError(f"Request {request_id} not found after update")
return parsed
finally:
conn.close()
def reopen_failed_request(
self,
request_id: int,
*,
failure_reason: Optional[str] = None,
) -> Optional[Dict[str, Any]]:
"""Reopen a failed fulfilled request so admins can re-approve it."""
normalized_failure_reason = None
if isinstance(failure_reason, str):
normalized_failure_reason = failure_reason.strip() or None
with self._lock:
conn = self._connect()
try:
current_row = conn.execute(
"SELECT * FROM download_requests WHERE id = ?",
(request_id,),
).fetchone()
current_request = self._parse_request_row(current_row)
if current_request is None:
return None
if current_request.get("status") != RequestStatus.FULFILLED:
return None
current_delivery_state = current_request.get("delivery_state", DELIVERY_STATE_NONE)
# Terminal hook callbacks can run before delivery-state sync persists "error".
# Allow reopening fulfilled requests unless they are already complete.
if current_delivery_state == QueueStatus.COMPLETE:
return None
if (
current_delivery_state not in {QueueStatus.ERROR, QueueStatus.CANCELLED}
and normalized_failure_reason is None
):
return None
conn.execute(
"""
UPDATE download_requests
SET status = 'pending',
delivery_state = 'none',
delivery_updated_at = NULL,
release_data = NULL,
last_failure_reason = ?,
reviewed_by = NULL,
reviewed_at = NULL
WHERE id = ?
""",
(normalized_failure_reason, request_id),
)
updated_row = conn.execute(
"SELECT * FROM download_requests WHERE id = ?",
(request_id,),
).fetchone()
conn.commit()
return self._parse_request_row(updated_row)
finally:
conn.close()
def rollback_request_fulfilment(
self,
request_id: int,
*,
release_data: Optional[Dict[str, Any]],
last_failure_reason: Optional[str] = None,
) -> Dict[str, Any]:
"""Restore a request to pending after fulfilment claimed it but queueing failed."""
with self._lock:
conn = self._connect()
try:
row = conn.execute(
"SELECT * FROM download_requests WHERE id = ?",
(request_id,),
).fetchone()
current = self._parse_request_row(row)
if current is None:
raise ValueError(f"Request {request_id} not found")
conn.execute(
"""
UPDATE download_requests
SET status = 'pending',
release_data = ?,
admin_note = NULL,
reviewed_by = NULL,
reviewed_at = NULL,
delivery_state = 'none',
delivery_updated_at = NULL,
last_failure_reason = ?
WHERE id = ?
""",
(
self._serialize_json(release_data, "release_data"),
last_failure_reason,
request_id,
),
)
updated_row = conn.execute(
"SELECT * FROM download_requests WHERE id = ?",
(request_id,),
).fetchone()
conn.commit()
parsed = self._parse_request_row(updated_row)
if parsed is None:
raise ValueError(f"Request {request_id} not found after rollback")
return parsed
finally:
conn.close()
def count_pending_requests(self) -> int:
"""Count all pending requests."""
conn = self._connect()
try:
row = conn.execute(
"SELECT COUNT(*) AS count FROM download_requests WHERE status = 'pending'"
).fetchone()
return int(row["count"]) if row else 0
finally:
conn.close()
def count_user_pending_requests(self, user_id: int) -> int:
"""Count pending requests for a specific user."""
conn = self._connect()
try:
row = conn.execute(
"SELECT COUNT(*) AS count FROM download_requests WHERE user_id = ? AND status = 'pending'",
(user_id,),
).fetchone()
return int(row["count"]) if row else 0
finally:
conn.close()
+78
View File
@@ -0,0 +1,78 @@
"""Shared helpers for user-overridable settings metadata and payloads."""
from typing import Any
from shelfmark.core.settings_registry import load_config_file
from shelfmark.core.user_db import UserDB
def get_settings_registry():
# Ensure settings modules are loaded before reading registry metadata.
import shelfmark.config.settings # noqa: F401
import shelfmark.config.security # noqa: F401
import shelfmark.config.notifications_settings # noqa: F401
import shelfmark.config.users_settings # noqa: F401
from shelfmark.core import settings_registry
return settings_registry
def get_ordered_user_overridable_fields(tab_name: str) -> list[tuple[str, Any]]:
settings_registry = get_settings_registry()
tab = settings_registry.get_settings_tab(tab_name)
if not tab:
return []
overridable_map = settings_registry.get_user_overridable_fields(tab_name=tab_name)
return [(field.key, field) for field in tab.fields if field.key in overridable_map]
def build_user_preferences_payload(user_db: UserDB, user_id: int, tab_name: str) -> dict[str, Any]:
from shelfmark.core.config import config as app_config
settings_registry = get_settings_registry()
ordered_fields = get_ordered_user_overridable_fields(tab_name)
if not ordered_fields:
tab_label = tab_name.capitalize()
raise ValueError(f"{tab_label} settings tab not found")
tab_config = load_config_file(tab_name)
user_settings = user_db.get_user_settings(user_id)
ordered_keys = [key for key, _ in ordered_fields]
fields_payload: list[dict[str, Any]] = []
global_values: dict[str, Any] = {}
effective: dict[str, dict[str, Any]] = {}
for key, field in ordered_fields:
serialized = settings_registry.serialize_field(field, tab_name, include_value=False)
serialized["fromEnv"] = bool(field.env_supported and settings_registry.is_value_from_env(field))
fields_payload.append(serialized)
global_values[key] = app_config.get(key, field.default)
source = "default"
value = app_config.get(key, field.default, user_id=user_id)
if field.env_supported and settings_registry.is_value_from_env(field):
source = "env_var"
elif key in user_settings and user_settings[key] is not None:
source = "user_override"
value = user_settings[key]
elif key in tab_config:
source = "global_config"
effective[key] = {"value": value, "source": source}
user_overrides = {
key: user_settings[key]
for key in ordered_keys
if key in user_settings and user_settings[key] is not None
}
return {
"tab": tab_name,
"keys": ordered_keys,
"fields": fields_payload,
"globalValues": global_values,
"userOverrides": user_overrides,
"effective": effective,
}
+173 -5
View File
@@ -1,8 +1,104 @@
"""Shared utility functions for the Shelfmark."""
import base64
import importlib
import os
import re
from threading import Lock
from types import ModuleType
from pathlib import Path
from typing import Optional
from urllib.parse import urlparse
def normalize_http_url(
url: Optional[str],
*,
default_scheme: str = "http",
strip_trailing_slash: bool = True,
allow_special: tuple[str, ...] = (),
) -> str:
"""Normalize a configured HTTP URL for requests and links."""
if not isinstance(url, str):
return ""
normalized = url.strip()
if not normalized:
return ""
if (normalized.startswith("\"") and normalized.endswith("\"")) or (
normalized.startswith("'") and normalized.endswith("'")
):
normalized = normalized[1:-1].strip()
if not normalized:
return ""
if allow_special:
special_map = {
value.lower(): value
for value in allow_special
if isinstance(value, str)
}
special_match = special_map.get(normalized.lower())
if special_match is not None:
return special_match
if normalized.startswith(("/", "./", "../")):
return normalized
if "://" not in normalized:
scheme = default_scheme.strip().rstrip(":/")
if scheme:
normalized = f"{scheme}://{normalized}"
if strip_trailing_slash:
normalized = normalized.rstrip("/")
return normalized
_xmlrpc_patch_lock = Lock()
_xmlrpc_patch_applied = False
def get_hardened_xmlrpc_client() -> ModuleType:
"""Return ``xmlrpc.client`` after best-effort defusedxml monkey patching."""
global _xmlrpc_patch_applied
if not _xmlrpc_patch_applied:
with _xmlrpc_patch_lock:
if not _xmlrpc_patch_applied:
try:
from defusedxml.xmlrpc import monkey_patch
monkey_patch()
_xmlrpc_patch_applied = True
except Exception:
# Keep runtime behavior unchanged if defusedxml is unavailable.
_xmlrpc_patch_applied = False
return importlib.import_module("xmlrpc.client")
def normalize_base_path(value: Optional[str]) -> str:
"""Normalize a URL base path for reverse proxy subpath deployments."""
if not isinstance(value, str):
return ""
path = value.strip()
if not path:
return ""
if "://" in path:
parsed = urlparse(path)
path = parsed.path or ""
if not path or path == "/":
return ""
if not path.startswith("/"):
path = "/" + path
return path.rstrip("/")
def is_audiobook(content_type: Optional[str]) -> bool:
@@ -49,21 +145,88 @@ _LEGACY_CONTENT_TYPE_TO_CONFIG_KEY = {
"other": "INGEST_DIR_OTHER",
}
_USER_PLACEHOLDER_PATTERN = re.compile(r"\{user\}", re.IGNORECASE)
_INVALID_USER_PATH_CHARS = re.compile(r'[\\/:*?"<>|]')
def get_destination(is_audiobook: bool = False) -> Path:
def _sanitize_user_for_path(username: str) -> str:
"""Sanitize username for path usage in destination placeholders."""
sanitized = _INVALID_USER_PATH_CHARS.sub("_", username.strip())
return sanitized.strip(" .")
def _resolve_destination_username(
user_id: Optional[int] = None,
username: Optional[str] = None,
) -> str:
explicit = str(username or "").strip()
if explicit:
return explicit
if user_id is None:
return ""
try:
from shelfmark.core.user_db import UserDB
user_db = UserDB(os.path.join(os.environ.get("CONFIG_DIR", "/config"), "users.db"))
user_db.initialize()
user = user_db.get_user(user_id=user_id)
if not user:
return ""
return str(user.get("username") or "").strip()
except Exception:
return ""
def _expand_user_destination_placeholder(
path_value: str,
user_id: Optional[int] = None,
username: Optional[str] = None,
) -> str:
"""Expand `{User}` placeholders in destination paths."""
if not isinstance(path_value, str):
return path_value
if not _USER_PLACEHOLDER_PATTERN.search(path_value):
return path_value
resolved_username = _sanitize_user_for_path(
_resolve_destination_username(user_id=user_id, username=username)
)
return _USER_PLACEHOLDER_PATTERN.sub(resolved_username, path_value)
def get_destination(
is_audiobook: bool = False,
user_id: Optional[int] = None,
username: Optional[str] = None,
) -> Path:
"""Get base destination directory. Audiobooks fall back to main destination."""
from shelfmark.core.config import config
if is_audiobook:
# Audiobook destination with fallback to main destination
audiobook_dest = config.get("DESTINATION_AUDIOBOOK", "")
audiobook_dest = config.get("DESTINATION_AUDIOBOOK", "", user_id=user_id)
if audiobook_dest:
return Path(audiobook_dest)
return Path(
_expand_user_destination_placeholder(
str(audiobook_dest),
user_id=user_id,
username=username,
)
)
# Main destination (also fallback for audiobooks)
# Check new setting first, then legacy INGEST_DIR
destination = config.get("DESTINATION", "") or config.get("INGEST_DIR", "/books")
return Path(destination)
destination = config.get("DESTINATION", "", user_id=user_id) or config.get("INGEST_DIR", "/books")
return Path(
_expand_user_destination_placeholder(
str(destination),
user_id=user_id,
username=username,
)
)
def get_aa_content_type_dir(content_type: Optional[str] = None) -> Optional[Path]:
@@ -122,6 +285,11 @@ def transform_cover_url(cover_url: Optional[str], cache_id: str) -> Optional[str
if not is_covers_cache_enabled():
return cover_url
from shelfmark.core.config import config as app_config
# Encode the original URL and create a proxy URL
encoded_url = base64.urlsafe_b64encode(cover_url.encode()).decode()
base_path = normalize_base_path(app_config.get("URL_BASE", ""))
if base_path:
return f"{base_path}/api/covers/{cache_id}?url={encoded_url}"
return f"/api/covers/{cache_id}?url={encoded_url}"
+22 -232
View File
@@ -3,107 +3,20 @@
import os
import shutil
import zipfile
from dataclasses import dataclass
from pathlib import Path
from typing import List, Optional, Tuple
from shelfmark.core.logger import setup_logger
from shelfmark.core.config import config
from shelfmark.core.naming import parse_naming_template, sanitize_filename
from shelfmark.download.postprocess.policy import (
get_supported_audiobook_formats,
get_supported_formats,
)
from shelfmark.core.utils import is_audiobook as check_audiobook
from shelfmark.download.fs import atomic_write, atomic_move
from shelfmark.download.fs import atomic_write
logger = setup_logger(__name__)
def _get_supported_formats() -> List[str]:
"""Get current supported formats from config singleton."""
formats = config.get("SUPPORTED_FORMATS", ["epub", "mobi", "azw3", "fb2", "djvu", "cbz", "cbr"])
# Handle both list (from MultiSelectField) and comma-separated string (legacy/env)
if isinstance(formats, str):
return [fmt.strip().lower() for fmt in formats.split(",") if fmt.strip()]
return [fmt.lower() for fmt in formats]
def _get_supported_audiobook_formats() -> List[str]:
"""Get current supported audiobook formats from config singleton."""
formats = config.get("SUPPORTED_AUDIOBOOK_FORMATS", ["m4b", "mp3"])
# Handle both list (from MultiSelectField) and comma-separated string (legacy/env)
if isinstance(formats, str):
return [fmt.strip().lower() for fmt in formats.split(",") if fmt.strip()]
return [fmt.lower() for fmt in formats]
def _get_file_organization(is_audiobook: bool) -> str:
"""Get the file organization mode for the content type."""
key = "FILE_ORGANIZATION_AUDIOBOOK" if is_audiobook else "FILE_ORGANIZATION"
mode = config.get(key, "rename")
# Handle legacy settings migration
if mode not in ("none", "rename", "organize"):
legacy_key = "PROCESSING_MODE_AUDIOBOOK" if is_audiobook else "PROCESSING_MODE"
legacy_mode = config.get(legacy_key, "ingest")
if legacy_mode == "library":
return "organize"
if config.get("USE_BOOK_TITLE", True):
return "rename"
return "none"
return mode
def _get_template(is_audiobook: bool, organization_mode: str) -> str:
"""Get the template for the content type and organization mode."""
# Determine the correct key based on content type and organization mode
if is_audiobook:
if organization_mode == "organize":
key = "TEMPLATE_AUDIOBOOK_ORGANIZE"
else:
key = "TEMPLATE_AUDIOBOOK_RENAME"
else:
if organization_mode == "organize":
key = "TEMPLATE_ORGANIZE"
else:
key = "TEMPLATE_RENAME"
template = config.get(key, "")
# Fallback to legacy keys if new keys are empty
if not template:
legacy_key = "TEMPLATE_AUDIOBOOK" if is_audiobook else "TEMPLATE"
template = config.get(legacy_key, "")
if not template:
legacy_key = "LIBRARY_TEMPLATE_AUDIOBOOK" if is_audiobook else "LIBRARY_TEMPLATE"
template = config.get(legacy_key, "")
if not template:
if organization_mode == "organize":
return "{Author}/{Title} ({Year})"
return "{Author} - {Title} ({Year})"
return template
def _build_filename_from_task(task, extension: str, organization_mode: str) -> str:
"""Build a filename from task metadata using the configured template."""
is_audiobook = check_audiobook(task.content_type)
template = _get_template(is_audiobook, organization_mode)
metadata = {
"Author": task.author,
"Title": task.title,
"Subtitle": getattr(task, 'subtitle', None),
"Year": task.year,
"Series": getattr(task, 'series_name', None),
"SeriesPosition": getattr(task, 'series_position', None),
}
filename = parse_naming_template(template, metadata)
if filename:
return f"{sanitize_filename(filename)}.{extension}"
return ""
# Check for rarfile availability at module load
try:
import rarfile
@@ -142,9 +55,9 @@ def _is_supported_file(file_path: Path, content_type: Optional[str] = None) -> b
"""Check if file matches user's supported formats setting based on content type."""
ext = file_path.suffix.lower().lstrip(".")
if check_audiobook(content_type):
supported_formats = _get_supported_audiobook_formats()
supported_formats = get_supported_audiobook_formats()
else:
supported_formats = _get_supported_formats()
supported_formats = get_supported_formats()
return ext in supported_formats
@@ -225,6 +138,21 @@ def extract_archive(
return matched_files, warnings, rejected_files
def extract_archive_raw(
archive_path: Path,
output_dir: Path,
) -> Tuple[List[Path], List[str]]:
"""Extract archive without filtering (returns all extracted files)."""
suffix = archive_path.suffix.lower().lstrip(".")
if suffix == "zip":
return _extract_zip(archive_path, output_dir)
if suffix == "rar":
return _extract_rar(archive_path, output_dir)
raise ArchiveExtractionError(f"Unsupported archive format: {suffix}")
def _extract_files_from_archive(archive, output_dir: Path) -> List[Path]:
"""Extract files from ZipFile or RarFile to output_dir with security checks."""
extracted_files = []
@@ -309,141 +237,3 @@ def _extract_rar(archive_path: Path, output_dir: Path) -> Tuple[List[Path], List
raise ArchiveExtractionError(f"Permission denied: {e}")
@dataclass
class ArchiveResult:
"""Result of archive processing."""
success: bool
final_paths: List[Path]
message: str
error: Optional[str] = None
def process_archive(
archive_path: Path,
temp_dir: Path,
ingest_dir: Path,
archive_id: str,
task: Optional["DownloadTask"] = None,
) -> ArchiveResult:
"""Extract archive, filter to supported formats, move to ingest directory."""
extract_dir = temp_dir / f"extract_{archive_id}"
content_type = task.content_type if task else None
is_audiobook = check_audiobook(content_type)
file_type_label = "audiobook" if is_audiobook else "book"
try:
# Create temp extraction directory
os.makedirs(extract_dir, exist_ok=True)
os.makedirs(ingest_dir, exist_ok=True)
# Extract to temp directory (filters based on content type)
extracted_files, warnings, rejected_files = extract_archive(archive_path, extract_dir, content_type)
if not extracted_files:
# Clean up and return error
shutil.rmtree(extract_dir, ignore_errors=True)
archive_path.unlink(missing_ok=True)
if rejected_files:
# Found files but they weren't in supported formats
rejected_exts = sorted(set(f.suffix.lower() for f in rejected_files))
rejected_list = ", ".join(rejected_exts)
supported_formats = _get_supported_audiobook_formats() if is_audiobook else _get_supported_formats()
logger.warning(
f"Found {len(rejected_files)} {file_type_label}(s) in archive but format not supported. "
f"Rejected: {rejected_list}. Supported: {', '.join(sorted(supported_formats))}"
)
return ArchiveResult(
success=False,
final_paths=[],
message="",
error=f"Found {len(rejected_files)} {file_type_label}(s) but format not supported ({rejected_list}). Enable in Settings > Formats.",
)
return ArchiveResult(
success=False,
final_paths=[],
message="",
error=f"No {file_type_label} files found in archive",
)
for warning in warnings:
logger.debug(warning)
logger.info(f"Extracted {len(extracted_files)} {file_type_label} file(s) from archive")
# Move book files to ingest folder
final_paths = []
# Determine file organization mode
is_audiobook = check_audiobook(task.content_type) if task else False
organization_mode = _get_file_organization(is_audiobook) if task else "none"
for extracted_file in extracted_files:
# For multi-file archives (book packs, series), always preserve original filenames
# since metadata title only applies to the searched book, not the whole pack.
# For single files, respect FILE_ORGANIZATION setting.
if len(extracted_files) == 1 and organization_mode != "none" and task:
# Use the extracted file's actual extension, not the archive's extension
extracted_format = extracted_file.suffix.lower().lstrip('.')
filename = _build_filename_from_task(task, extracted_format, organization_mode)
if not filename:
filename = extracted_file.name
else:
filename = extracted_file.name
dest_path = ingest_dir / filename
final_path = atomic_move(extracted_file, dest_path)
final_paths.append(final_path)
logger.debug(f"Moved to ingest: {final_path.name}")
# Clean up temp extraction directory and archive
shutil.rmtree(extract_dir, ignore_errors=True)
archive_path.unlink(missing_ok=True)
# Build success message with format info
formats = [p.suffix.lstrip(".").upper() for p in final_paths]
if len(formats) == 1:
message = f"Complete ({formats[0]})"
else:
message = f"Complete ({len(formats)} files)"
return ArchiveResult(
success=True,
final_paths=final_paths,
message=message,
)
except PasswordProtectedError:
logger.error(f"Password-protected archive: {archive_path.name}")
shutil.rmtree(extract_dir, ignore_errors=True)
archive_path.unlink(missing_ok=True)
return ArchiveResult(
success=False,
final_paths=[],
message="",
error="Archive is password protected",
)
except CorruptedArchiveError as e:
logger.error(f"Corrupted archive: {e}")
shutil.rmtree(extract_dir, ignore_errors=True)
archive_path.unlink(missing_ok=True)
return ArchiveResult(
success=False,
final_paths=[],
message="",
error=f"Corrupted archive: {e}",
)
except ArchiveExtractionError as e:
logger.error(f"Archive extraction failed: {e}")
shutil.rmtree(extract_dir, ignore_errors=True)
archive_path.unlink(missing_ok=True)
return ArchiveResult(
success=False,
final_paths=[],
message="",
error=f"Extraction failed: {e}",
)
@@ -1,5 +1,5 @@
"""
Download client infrastructure for Prowlarr integration.
Shared download client infrastructure for external release sources.
This module provides:
- DownloadState: Enum of valid download states
@@ -11,13 +11,89 @@ Clients register themselves via the @register_client decorator.
"""
import logging
import os
import random
import time
from abc import ABC, abstractmethod
from dataclasses import dataclass
from enum import Enum
from typing import Dict, List, Optional, Tuple, Type, Union
from functools import wraps
from typing import Callable, Dict, List, Optional, Tuple, Type, TypeVar, Union, cast, Any
import requests
_logger = logging.getLogger(__name__)
# Type variable for generic return type
T = TypeVar('T')
# Exceptions that should trigger a retry
RETRYABLE_EXCEPTIONS = (
requests.exceptions.ConnectionError,
requests.exceptions.Timeout,
requests.exceptions.HTTPError,
)
def with_retry(
max_attempts: int = 3,
base_delay: float = 1.0,
max_delay: float = 10.0,
jitter: float = 0.5,
) -> Callable[[Callable[..., T]], Callable[..., T]]:
"""
Decorator for retrying API calls with exponential backoff.
Args:
max_attempts: Maximum number of attempts (default 3)
base_delay: Initial delay in seconds (default 1.0)
max_delay: Maximum delay cap in seconds (default 10.0)
jitter: Random jitter factor 0-1 to add to delay (default 0.5)
Retries on:
- Connection errors
- Timeouts
- HTTP 5xx server errors
Does NOT retry on:
- HTTP 4xx client errors (bad request, auth failures)
- Other exceptions (programming errors)
"""
def decorator(func: Callable[..., T]) -> Callable[..., T]:
@wraps(func)
def wrapper(*args, **kwargs) -> T:
last_exception = None
for attempt in range(1, max_attempts + 1):
try:
return func(*args, **kwargs)
except requests.exceptions.HTTPError as e:
# Only retry on server errors (5xx), not client errors (4xx)
if e.response is not None and e.response.status_code < 500:
raise
last_exception = e
except RETRYABLE_EXCEPTIONS as e:
last_exception = e
if attempt < max_attempts:
# Calculate delay with exponential backoff
delay = min(base_delay * (2 ** (attempt - 1)), max_delay)
# Add jitter to prevent thundering herd
delay += random.uniform(0, delay * jitter)
_logger.debug(
f"Retry {attempt}/{max_attempts} for {func.__name__} "
f"after {delay:.1f}s (error: {last_exception})"
)
time.sleep(delay)
# All retries exhausted
if last_exception is None:
raise RuntimeError("Retry failed without exception")
raise cast(Exception, last_exception)
return wrapper
return decorator
class DownloadState(Enum):
"""Valid states for a download."""
@@ -97,6 +173,49 @@ class DownloadClient(ABC):
protocol: str
name: str
def _log_error(self, method: str, e: Exception, level: str = "error") -> str:
"""
Log a client error with consistent formatting.
Args:
method: Name of the method that failed (e.g., "get_status")
e: The exception that was raised
level: Log level - "error" or "debug"
Returns:
Formatted error message string (for use in DownloadStatus.error())
"""
error_type = type(e).__name__
msg = f"{self.name} {method} failed ({error_type}): {e}"
if level == "debug":
_logger.debug(msg)
else:
_logger.error(msg)
# Reset connection state if client tracks it (e.g., Deluge)
if hasattr(self, "_connected"):
self._connected = False
return f"{error_type}: {e}"
def _build_path(self, *components: str) -> Optional[str]:
"""
Safely build a file path from components.
Args:
*components: Path components to join (e.g., save_path, name)
Returns:
Normalized path string, or None if any component is empty/None.
"""
# Filter out empty/None components
valid = [c for c in components if c]
if len(valid) != len(components):
return None
# Join and normalize
return os.path.normpath(os.path.join(*valid))
def __init_subclass__(cls, **kwargs):
"""Validate that subclasses define required class attributes."""
super().__init_subclass__(**kwargs)
@@ -139,14 +258,22 @@ class DownloadClient(ABC):
pass
@abstractmethod
def add_download(self, url: str, name: str, category: str = "cwabd") -> str:
"""
Add a download to the client.
def add_download(
self,
url: str,
name: str,
category: Optional[str] = None,
expected_hash: Optional[str] = None,
**kwargs: Any,
) -> str:
"""Add a download to the client.
Args:
url: Download URL (magnet link, .torrent URL, or NZB URL)
name: Display name for the download
category: Category/label for organization
category: Category/label for organization (None = client default)
expected_hash: Optional info_hash hint (torrents only)
Returns:
Client-specific download ID (hash for torrents, ID for NZBGet).
@@ -196,7 +323,9 @@ class DownloadClient(ABC):
"""
pass
def find_existing(self, url: str) -> Optional[Tuple[str, DownloadStatus]]:
def find_existing(
self, url: str, category: Optional[str] = None
) -> Optional[Tuple[str, DownloadStatus]]:
"""
Check if a download for this URL already exists in the client.
@@ -205,6 +334,7 @@ class DownloadClient(ABC):
Args:
url: Download URL (magnet link, .torrent URL, or NZB URL)
category: Category to filter by (usenet clients only)
Returns:
Tuple of (download_id, status) if found, None if not found.
@@ -293,8 +423,9 @@ def get_all_clients() -> Dict[str, List[Type[DownloadClient]]]:
# Import client implementations to trigger registration
# These imports are at the bottom to avoid circular imports
from shelfmark.release_sources.prowlarr.clients import qbittorrent # noqa: F401, E402
from shelfmark.release_sources.prowlarr.clients import nzbget # noqa: F401, E402
from shelfmark.release_sources.prowlarr.clients import sabnzbd # noqa: F401, E402
from shelfmark.release_sources.prowlarr.clients import transmission # noqa: F401, E402
from shelfmark.release_sources.prowlarr.clients import deluge # noqa: F401, E402
from shelfmark.download.clients import qbittorrent # noqa: F401, E402
from shelfmark.download.clients import nzbget # noqa: F401, E402
from shelfmark.download.clients import sabnzbd # noqa: F401, E402
from shelfmark.download.clients import transmission # noqa: F401, E402
from shelfmark.download.clients import deluge # noqa: F401, E402
from shelfmark.download.clients import rtorrent # noqa: F401, E402
+765
View File
@@ -0,0 +1,765 @@
"""Shared download handler for external torrent/usenet clients."""
import shutil
import time
from abc import ABC, abstractmethod
from dataclasses import dataclass
from pathlib import Path
from threading import Event
from typing import Callable, Optional
from shelfmark.core.config import config
from shelfmark.core.logger import setup_logger
from shelfmark.core.models import DownloadTask
from shelfmark.core.utils import is_audiobook
from shelfmark.download.clients import (
DownloadClient,
DownloadState,
get_client,
list_configured_clients,
)
from shelfmark.download.fs import run_blocking_io
from shelfmark.release_sources import DownloadHandler
logger = setup_logger(__name__)
# How often to poll the download client for status (seconds)
POLL_INTERVAL = 2
# How long to wait for completed files to appear (seconds)
COMPLETED_PATH_RETRY_INTERVAL = 5
COMPLETED_PATH_MAX_ATTEMPTS = 12 # 12 attempts * 5s = 60s grace period
@dataclass(frozen=True)
class DownloadRequest:
"""Source-specific download parameters resolved before sending to a client."""
url: str
protocol: str
release_name: str
expected_hash: Optional[str]
seeding_time_limit: Optional[int] = None # minutes
ratio_limit: Optional[float] = None
def _diagnose_path_issue(path: str) -> str:
"""
Analyze a path and return diagnostic hints for common issues.
Args:
path: The path that failed to be accessed
Returns:
A hint string to help users diagnose the issue.
"""
# Detect Windows-style paths (won't work in Linux containers)
if len(path) >= 2 and path[1] == ':':
return (
f"Path '{path}' appears to be a Windows path. "
f"Shelfmark runs in Linux and cannot access Windows paths directly. "
f"Ensure your download client uses Linux-style paths (/path/to/files)."
)
# Detect backslashes (Windows path separators)
if "\\" in path:
return (
f"Path '{path}' contains backslashes. "
f"This may indicate a Windows path or incorrect path escaping. "
f"Linux paths should use forward slashes (/)."
)
# Generic hint for Linux paths
return (
f"Path '{path}' is not accessible from Shelfmark's container. "
f"Ensure both containers have matching volume mounts for this directory, "
f"or configure Remote Path Mappings in Settings > Advanced."
)
class ExternalClientHandler(DownloadHandler, ABC):
"""Shared lifecycle handler for sources that hand off to torrent/usenet clients."""
def __init__(self):
# Track downloads that may need client-side cleanup after Shelfmark completes import.
# task_id -> (client, download_id, protocol)
self._cleanup_refs: dict[str, tuple[DownloadClient, str, str]] = {}
@abstractmethod
def _resolve_download(
self,
task: DownloadTask,
status_callback: Callable[[str, Optional[str]], None],
) -> Optional[DownloadRequest]:
"""Resolve source-specific task metadata into a client download request."""
def _on_download_complete(self, task: DownloadTask) -> None:
"""Hook called after successful completion; override for source cleanup."""
return
def _get_client(self, protocol: str) -> Optional[DownloadClient]:
"""Resolve the active client for a protocol."""
return get_client(protocol)
def _list_configured_clients(self) -> list[str]:
"""List protocols with configured clients."""
return list_configured_clients()
def _poll_interval(self) -> float:
"""Polling interval for status checks (seconds)."""
return POLL_INTERVAL
def _completed_path_retry_interval(self) -> float:
"""Retry interval while waiting for completed files (seconds)."""
return COMPLETED_PATH_RETRY_INTERVAL
def _completed_path_max_attempts(self) -> int:
"""Maximum attempts when waiting for completed files."""
return COMPLETED_PATH_MAX_ATTEMPTS
def _get_category_for_task(self, client: DownloadClient, task: DownloadTask) -> Optional[str]:
"""Get audiobook category if configured and applicable, else None for default."""
if not is_audiobook(task.content_type):
return None
# Client-specific audiobook category config keys
audiobook_keys = {
"qbittorrent": "QBITTORRENT_CATEGORY_AUDIOBOOK",
"transmission": "TRANSMISSION_CATEGORY_AUDIOBOOK",
"deluge": "DELUGE_CATEGORY_AUDIOBOOK",
"nzbget": "NZBGET_CATEGORY_AUDIOBOOK",
"sabnzbd": "SABNZBD_CATEGORY_AUDIOBOOK",
}
audiobook_key = audiobook_keys.get(client.name)
return config.get(audiobook_key, "") or None if audiobook_key else None
def post_process_cleanup(self, task: DownloadTask, success: bool) -> None:
if not success:
self._cleanup_refs.pop(task.task_id, None)
return
client_ref = self._cleanup_refs.pop(task.task_id, None)
if client_ref is None:
return
client, download_id, protocol = client_ref
if protocol == "usenet":
# "Move" means copy into ingest then let the usenet client delete its own files.
if config.get("PROWLARR_USENET_ACTION", "move") != "move":
return
try:
self._delete_local_download_data(client, download_id)
self._remove_usenet_download(client, download_id, delete_files=True, archive=True)
except Exception as e:
logger.warning(
f"Failed to cleanup usenet download {download_id} in {getattr(client, 'name', 'client')}: {e}"
)
elif protocol == "torrent":
if config.get("PROWLARR_TORRENT_ACTION", "keep") != "remove":
return
try:
client.remove(download_id, delete_files=False)
except Exception as e:
logger.warning(
f"Failed to remove torrent {download_id} from {getattr(client, 'name', 'client')}: {e}"
)
def _remove_usenet_download(
self,
client: DownloadClient,
download_id: str,
*,
delete_files: bool,
archive: bool = True,
) -> None:
"""Remove a usenet download with SABnzbd-specific archive handling."""
if getattr(client, "name", "") == "sabnzbd":
client.remove(download_id, delete_files=delete_files, archive=archive)
else:
client.remove(download_id, delete_files=delete_files)
def _delete_local_download_data(self, client: DownloadClient, download_id: str) -> None:
"""Best-effort local deletion of client download data."""
try:
raw_path = client.get_download_path(download_id)
except Exception as e:
logger.debug(f"Failed to resolve download path for {client.name} {download_id}: {e}")
return
if not raw_path:
logger.debug(f"No download path available for {client.name} {download_id}")
return
from shelfmark.core.path_mappings import (
get_client_host_identifier,
parse_remote_path_mappings,
remap_remote_to_local_with_match,
)
source_path_obj = Path(raw_path)
host = get_client_host_identifier(client) or ""
mapping_value = config.get("PROWLARR_REMOTE_PATH_MAPPINGS", [])
mappings = parse_remote_path_mappings(mapping_value)
remapped, matched_mapping = remap_remote_to_local_with_match(
mappings=mappings,
host=host,
remote_path=source_path_obj,
)
delete_path = remapped if matched_mapping else source_path_obj
if str(delete_path) in ("", "/"):
logger.warning(f"Refusing to delete unsafe path for {client.name} {download_id}: {delete_path}")
return
if not run_blocking_io(delete_path.exists):
logger.debug(f"Local download path does not exist for cleanup: {delete_path}")
return
try:
if run_blocking_io(delete_path.is_dir):
run_blocking_io(shutil.rmtree, delete_path)
else:
run_blocking_io(delete_path.unlink)
logger.info(f"Deleted local download data for {client.name} {download_id}: {delete_path}")
except Exception as e:
logger.warning(f"Failed to delete local download data for {client.name} {download_id}: {e}")
def _safe_remove_download(self, client, download_id: str, protocol: str, reason: str) -> None:
"""Best-effort removal of a failed/cancelled download from the client.
Safety policy:
- torrents: never remove or delete client data (avoid breaking seeding)
- usenet: keep legacy behavior (delete client files on removal)
"""
if protocol != "usenet":
logger.info(
"Skipping download client cleanup for protocol=%s after %s (client=%s id=%s)",
protocol,
reason,
getattr(client, "name", "client"),
download_id,
)
return
try:
# Permanent delete for failed usenet downloads (SABnzbd archive=0).
self._delete_local_download_data(client, download_id)
self._remove_usenet_download(client, download_id, delete_files=True, archive=False)
except Exception as e:
logger.warning(
f"Failed to remove download {download_id} from {client.name} after {reason}: {e}"
)
def _handle_cancelled_download(
self,
client: DownloadClient,
download_id: str,
protocol: str,
status_callback: Callable[[str, Optional[str]], None],
) -> None:
if protocol == "usenet":
logger.info(f"Download cancelled, removing from {client.name}: {download_id}")
try:
self._delete_local_download_data(client, download_id)
self._remove_usenet_download(client, download_id, delete_files=True, archive=True)
except Exception as e:
logger.warning(
f"Failed to remove download {download_id} from {client.name} after cancellation: {e}"
)
else:
logger.info(
f"Download cancelled for protocol={protocol}; leaving in {client.name}: {download_id}"
)
status_callback("cancelled", "Cancelled")
def _resolve_download_path_once(
self,
client: DownloadClient,
download_id: str,
*,
log_details: bool,
) -> tuple[Optional[Path], Optional[str]]:
"""Resolve and validate the completed download path once."""
try:
raw_path = client.get_download_path(download_id)
except Exception as e:
message = (
f"Could not locate completed download in {client.name} (path not returned). "
f"Check volume mappings and category settings."
)
if log_details:
logger.error(
f"Failed to resolve download path for {client.name} {download_id}: {e}"
)
else:
logger.debug(
f"Failed to resolve download path for {client.name} {download_id}: {e}"
)
return None, message
if not raw_path:
message = (
f"Could not locate completed download in {client.name} (path not returned). "
f"Check volume mappings and category settings."
)
if log_details:
logger.error(f"Download client returned empty path for {client.name} {download_id}")
else:
logger.debug(f"Download client returned empty path for {client.name} {download_id}")
return None, message
from shelfmark.core.path_mappings import (
get_client_host_identifier,
parse_remote_path_mappings,
remap_remote_to_local_with_match,
)
source_path_obj = Path(raw_path)
host = get_client_host_identifier(client) or ""
mapping_value = config.get("PROWLARR_REMOTE_PATH_MAPPINGS", [])
mappings = parse_remote_path_mappings(mapping_value)
if log_details:
logger.debug(
"Attempting path remap: client=%s, host=%s, path=%s, mappings=%s",
client.name,
host,
source_path_obj,
[(m.host, m.remote_path, m.local_path) for m in mappings],
)
remapped, matched_mapping = remap_remote_to_local_with_match(
mappings=mappings,
host=host,
remote_path=source_path_obj,
)
if log_details:
remapped_exists = run_blocking_io(remapped.exists)
logger.debug(
"Remap result: %s -> %s (exists=%s, changed=%s, matched=%s)",
source_path_obj,
remapped,
remapped_exists,
remapped != source_path_obj,
matched_mapping,
)
if matched_mapping:
if run_blocking_io(remapped.exists):
logger.info(
"Remapped download path for %s (%s): %s -> %s",
client.name,
download_id,
source_path_obj,
remapped,
)
return remapped, None
message = (
f"Remapped path '{remapped}' does not exist. "
f"Check your Docker volume mounts match the Local Path in Settings > Advanced > Remote Path Mappings."
)
if log_details:
logger.error(
f"Download path does not exist after remapping: {raw_path} -> {remapped}. "
f"Client: {client.name}, ID: {download_id}."
)
else:
logger.debug(
f"Download path does not exist after remapping: {raw_path} -> {remapped}. "
f"Client: {client.name}, ID: {download_id}."
)
return None, message
if mappings:
if run_blocking_io(source_path_obj.exists):
logger.info(
"No remote path mapping matched for %s (%s); using client path: %s",
client.name,
download_id,
source_path_obj,
)
return source_path_obj, None
hint = _diagnose_path_issue(raw_path)
message = f"{hint} No remote path mapping matched for client '{client.name}'."
if log_details:
logger.error(
f"Download path does not exist and no remote path mapping matched for {client.name} "
f"({download_id}): {raw_path}. {hint}"
)
else:
logger.debug(
f"Download path does not exist and no remote path mapping matched for {client.name} "
f"({download_id}): {raw_path}. {hint}"
)
return None, message
if not run_blocking_io(source_path_obj.exists):
hint = _diagnose_path_issue(raw_path)
message = hint
if log_details:
logger.error(
f"Download path does not exist: {raw_path}. "
f"Client: {client.name}, ID: {download_id}. {hint}"
)
else:
logger.debug(
f"Download path does not exist: {raw_path}. "
f"Client: {client.name}, ID: {download_id}. {hint}"
)
return None, message
return source_path_obj, None
def _wait_for_completed_path(
self,
client: DownloadClient,
download_id: str,
*,
cancel_flag: Optional[Event],
status_callback: Callable[[str, Optional[str]], None],
) -> tuple[Optional[Path], Optional[str]]:
"""Wait briefly for completed files to appear on disk."""
last_error: Optional[str] = None
max_attempts = self._completed_path_max_attempts()
retry_interval = self._completed_path_retry_interval()
for attempt in range(1, max_attempts + 1):
if cancel_flag and cancel_flag.is_set():
return None, last_error
log_details = attempt == max_attempts
resolved_path, error = self._resolve_download_path_once(
client,
download_id,
log_details=log_details,
)
if resolved_path:
return resolved_path, None
last_error = error
if attempt < max_attempts:
status_callback("locating", "Waiting for completed files...")
logger.debug(
"Completed files not available yet for %s (%s) (attempt %d/%d)",
client.name,
download_id,
attempt,
max_attempts,
)
if cancel_flag:
if cancel_flag.wait(timeout=retry_interval):
return None, last_error
else:
time.sleep(retry_interval)
return None, last_error
def _build_progress_message(self, status) -> str:
"""Build a progress message from download status."""
msg = f"{status.progress:.0f}%"
if status.download_speed and status.download_speed > 0:
speed_mb = status.download_speed / 1024 / 1024
msg += f" ({speed_mb:.1f} MB/s)"
if status.eta and status.eta > 0:
if status.eta < 60:
msg += f" - {status.eta}s left"
elif status.eta < 3600:
msg += f" - {status.eta // 60}m left"
else:
msg += f" - {status.eta // 3600}h {(status.eta % 3600) // 60}m left"
return msg
def download(
self,
task: DownloadTask,
cancel_flag: Event,
progress_callback: Callable[[float], None],
status_callback: Callable[[str, Optional[str]], None],
) -> Optional[str]:
"""Execute download via configured torrent/usenet client. Returns file path or None."""
try:
if cancel_flag.is_set():
status_callback("cancelled", "Cancelled")
return None
request = self._resolve_download(task, status_callback)
if not request:
return None
client = self._get_client(request.protocol)
if not client:
configured = self._list_configured_clients()
if not configured:
status_callback(
"error",
"No download clients configured. Configure qBittorrent or NZBGet in settings.",
)
else:
status_callback("error", f"No {request.protocol} client configured")
return None
# Check if this download already exists in the client
status_callback("resolving", f"Checking {client.name}")
category = self._get_category_for_task(client, task)
existing = client.find_existing(request.url, category=category)
if existing:
download_id, existing_status = existing
logger.info(f"Found existing download in {client.name}: {download_id}")
# If already complete, skip straight to file handling
if existing_status.complete:
logger.info("Existing download is complete, copying file directly")
status_callback("resolving", "Found existing download, copying to library")
source_path_obj, path_error = self._wait_for_completed_path(
client=client,
download_id=download_id,
cancel_flag=cancel_flag,
status_callback=status_callback,
)
if not source_path_obj:
if cancel_flag.is_set():
return None
status_callback(
"error",
path_error
or f"Could not locate existing download in {client.name}. Check that the file still exists.",
)
return None
result = self._handle_completed_file(
source_path=source_path_obj,
protocol=request.protocol,
task=task,
status_callback=status_callback,
)
if result:
self._on_download_complete(task)
self._cleanup_refs[task.task_id] = (client, download_id, request.protocol)
return result
# Existing but still downloading - join the progress polling
logger.info("Existing download in progress, joining poll loop")
status_callback("downloading", "Resuming existing download")
else:
# No existing download - add new
status_callback("resolving", f"Sending to {client.name}")
try:
download_id = client.add_download(
url=request.url,
name=request.release_name,
category=category,
expected_hash=request.expected_hash,
seeding_time_limit=request.seeding_time_limit,
ratio_limit=request.ratio_limit,
)
except Exception as e:
logger.error(f"Failed to add to {client.name}: {e}")
status_callback("error", f"Failed to add to {client.name}: {e}")
return None
logger.info(f"Added to {client.name}: {download_id} for '{request.release_name}'")
# Poll for progress
return self._poll_and_complete(
client=client,
download_id=download_id,
protocol=request.protocol,
task=task,
cancel_flag=cancel_flag,
progress_callback=progress_callback,
status_callback=status_callback,
)
except Exception as e:
logger.error(f"External client download error: {e}")
status_callback("error", str(e))
return None
def _poll_and_complete(
self,
client: DownloadClient,
download_id: str,
protocol: str,
task: DownloadTask,
cancel_flag: Event,
progress_callback: Callable[[float], None],
status_callback: Callable[[str, Optional[str]], None],
) -> Optional[str]:
"""Poll the download client for progress and handle completion."""
poll_interval = self._poll_interval()
# Track consecutive "not found" errors - torrents may take time to appear in client
not_found_count = 0
max_not_found_retries = 15 # 15 retries * poll interval ~= 30s grace period
try:
logger.debug(f"Starting poll for {download_id} (content_type={task.content_type})")
while not cancel_flag.is_set():
status = client.get_status(download_id)
progress_callback(status.progress)
# Check for completion
if status.complete:
if status.state == DownloadState.ERROR:
logger.error(f"Download {download_id} completed with error: {status.message}")
status_callback("error", status.message or "Download failed")
self._safe_remove_download(client, download_id, protocol, "completion error")
return None
# Download complete - break to handle file
logger.debug(f"Download {download_id} complete, file_path={status.file_path}")
break
# Check for error state
if status.state == DownloadState.ERROR:
message = (status.message or "").strip()
message_lower = message.lower()
# Only treat *actual* "not found" as retryable.
# qBittorrent auth/network/API failures should surface immediately (more actionable)
# and must not be confused with "torrent missing".
retryable_not_found = any(
token in message_lower
for token in (
"torrent not found",
"not found in qbittorrent",
"download not found",
)
)
non_retryable = any(
token in message_lower
for token in (
"authentication failed",
"cannot connect",
"timed out",
"api request failed",
)
)
if retryable_not_found and not non_retryable:
not_found_count += 1
if not_found_count < max_not_found_retries:
logger.debug(
f"Download {download_id} not yet visible in client "
f"(attempt {not_found_count}/{max_not_found_retries})"
)
status_callback("resolving", "Waiting for download client...")
if cancel_flag.wait(timeout=poll_interval):
break
continue
logger.error(
f"Download {download_id} not found after {max_not_found_retries} attempts"
)
else:
# Fail fast on actionable errors (auth, connectivity, API issues)
logger.error(f"Download {download_id} error state: {status.message}")
status_callback("error", status.message or "Download failed")
self._safe_remove_download(client, download_id, protocol, "download error")
return None
# Reset not-found counter on successful status check
not_found_count = 0
# Build status message - use client message if provided, else build progress
msg = status.message or self._build_progress_message(status)
if status.state == DownloadState.PROCESSING:
# Post-processing (e.g., SABnzbd verifying/extracting)
status_callback("resolving", msg)
else:
status_callback("downloading", msg)
# Wait for next poll (interruptible by cancel)
if cancel_flag.wait(timeout=poll_interval):
break
# Handle cancellation
if cancel_flag.is_set():
self._handle_cancelled_download(client, download_id, protocol, status_callback)
return None
# Handle completed file (wait briefly for files to appear)
source_path_obj, path_error = self._wait_for_completed_path(
client=client,
download_id=download_id,
cancel_flag=cancel_flag,
status_callback=status_callback,
)
if not source_path_obj:
if cancel_flag.is_set():
self._handle_cancelled_download(client, download_id, protocol, status_callback)
return None
status_callback(
"error",
path_error
or f"Could not locate completed download in {client.name} (path not returned). Check volume mappings and category settings.",
)
return None
result = self._handle_completed_file(
source_path=source_path_obj,
protocol=protocol,
task=task,
status_callback=status_callback,
)
# Clean up on success
if result:
self._on_download_complete(task)
self._cleanup_refs[task.task_id] = (client, download_id, protocol)
return result
except Exception as e:
logger.error(f"Error during download polling: {e}")
status_callback("error", str(e))
self._safe_remove_download(client, download_id, protocol, "polling exception")
return None
def _handle_completed_file(
self,
source_path: Path,
protocol: str,
task: DownloadTask,
status_callback: Callable[[str, Optional[str]], None],
) -> Optional[str]:
"""Handle a completed download and return its path.
For external download clients (torrents/usenet), staging large payloads into TMP_DIR
is expensive (and can duplicate multi-GB files). Instead, return the client's
completed path and let the orchestrator perform any required transfer (copy/move/
hardlink) directly from that source.
Torrents also set ``task.original_download_path`` so the orchestrator can detect
seeding data and enable hardlinking when configured.
"""
try:
if protocol == "torrent":
task.original_download_path = str(source_path)
logger.debug(f"Download complete, returning original path: {source_path}")
return str(source_path)
except Exception as e:
logger.error(f"Failed to finalize completed download at {source_path}: {e}")
status_callback("error", f"Failed to finalize completed download: {e}")
return None
def cancel(self, task_id: str) -> bool:
"""Default cancellation (primary cancellation happens via cancel_flag)."""
logger.debug(f"Cancel requested for external client task: {task_id}")
return True
+411
View File
@@ -0,0 +1,411 @@
"""Deluge download client for Prowlarr integration.
This implementation talks to Deluge via the Web UI JSON-RPC API (``/json``).
Why Web UI API instead of daemon RPC (port 58846)?
- Matches the approach used by common automation apps
- Avoids requiring Deluge daemon ``auth`` file credentials (username/password)
Requirements:
- ``deluge-web`` must be enabled and reachable from Shelfmark
- Deluge Web UI must be connected (or connectable) to a Deluge daemon
"""
import base64
from typing import Any, Optional, Tuple
from urllib.parse import urlparse
import requests
from shelfmark.core.config import config
from shelfmark.download.network import get_ssl_verify
from shelfmark.core.logger import setup_logger
from shelfmark.core.utils import normalize_http_url
from shelfmark.download.clients import (
DownloadClient,
DownloadStatus,
register_client,
)
from shelfmark.download.clients.torrent_utils import (
extract_torrent_info,
)
logger = setup_logger(__name__)
class DelugeRpcError(RuntimeError):
def __init__(self, message: str, code: int | None = None):
super().__init__(message)
self.code = code
def _get_error_message(error: Any) -> Tuple[str, int | None]:
if isinstance(error, dict):
return str(error.get("message") or error), error.get("code")
return str(error), None
@register_client("torrent")
class DelugeClient(DownloadClient):
"""Deluge download client using Deluge Web UI JSON-RPC."""
protocol = "torrent"
name = "deluge"
def __init__(self):
raw_host = str(config.get("DELUGE_HOST", "localhost") or "")
raw_port = str(config.get("DELUGE_PORT", "8112") or "8112")
password = str(config.get("DELUGE_PASSWORD", "") or "")
if not raw_host:
raise ValueError("DELUGE_HOST is required")
if not password:
raise ValueError("DELUGE_PASSWORD is required")
scheme = "http"
base_path = ""
# Allow DELUGE_HOST to be either a hostname OR a full URL
# (useful when Deluge is behind a reverse proxy path).
raw_host = normalize_http_url(raw_host, strip_trailing_slash=False) if raw_host else ""
if not raw_host:
raise ValueError("DELUGE_HOST is invalid")
host = raw_host
port = int(raw_port)
if raw_host.startswith(("http://", "https://")):
parsed = urlparse(raw_host)
scheme = parsed.scheme or "http"
host = parsed.hostname or "localhost"
if parsed.port is not None:
port = parsed.port
base_path = (parsed.path or "").rstrip("/")
else:
# Allow "host:port" in DELUGE_HOST for convenience.
if ":" in raw_host and raw_host.count(":") == 1:
host_part, port_part = raw_host.split(":", 1)
if host_part and port_part.isdigit():
host = host_part
port = int(port_part)
self._rpc_url = f"{scheme}://{host}:{port}{base_path}/json"
self._password = password
self._session = requests.Session()
self._authenticated = False
self._connected = False
self._rpc_id = 0
self._category = str(config.get("DELUGE_CATEGORY", "books") or "books")
self._download_dir = str(config.get("DELUGE_DOWNLOAD_DIR", "") or "")
def _next_rpc_id(self) -> int:
self._rpc_id += 1
return self._rpc_id
def _rpc_call(self, method: str, *params: Any, timeout: int = 15) -> Any:
payload = {
"id": self._next_rpc_id(),
"method": method,
"params": list(params),
}
response = self._session.post(self._rpc_url, json=payload, timeout=timeout, verify=get_ssl_verify(self._rpc_url))
response.raise_for_status()
data = response.json()
if data.get("error"):
message, code = _get_error_message(data["error"])
raise DelugeRpcError(message, code)
return data.get("result")
def _login(self) -> None:
result = self._rpc_call("auth.login", self._password)
if result is not True:
raise DelugeRpcError("Deluge Web UI authentication failed")
self._authenticated = True
def _select_daemon_host_id(self, hosts: list) -> str:
# Hosts returned by web.get_hosts look like:
# [[host_id, host, port, status], ...]
preferred_hosts = {"127.0.0.1", "localhost"}
for entry in hosts:
if isinstance(entry, list) and len(entry) >= 2 and entry[1] in preferred_hosts:
return str(entry[0])
for entry in hosts:
if isinstance(entry, list) and len(entry) >= 4 and str(entry[3]).lower() == "online":
return str(entry[0])
return str(hosts[0][0])
def _ensure_connected(self) -> None:
if not self._authenticated:
self._login()
if self._connected:
return
if self._rpc_call("web.connected") is True:
self._connected = True
return
hosts = self._rpc_call("web.get_hosts") or []
if not hosts:
raise DelugeRpcError(
"Deluge Web UI isn't connected to Deluge core (no hosts configured). "
"Add/connect a daemon in Deluge Web UI → Connection Manager."
)
host_id = self._select_daemon_host_id(hosts)
self._rpc_call("web.connect", host_id)
if self._rpc_call("web.connected") is not True:
raise DelugeRpcError(
"Deluge Web UI couldn't connect to Deluge core. "
"Check daemon status in Deluge Web UI → Connection Manager."
)
self._connected = True
def _get_daemon_version(self) -> Any:
"""Fetch daemon version, preferring daemon.get_version when available."""
try:
methods = self._rpc_call("system.listMethods")
if isinstance(methods, list) and "daemon.get_version" in methods:
return self._rpc_call("daemon.get_version")
except Exception:
# Fall back to daemon.info to preserve existing behavior.
pass
return self._rpc_call("daemon.info")
def _try_set_label(self, torrent_id: str, label: str) -> None:
"""Best-effort label assignment (requires Deluge Label plugin)."""
if not label:
return
try:
# label.add will error if the plugin is unavailable or the label exists.
try:
self._rpc_call("label.add", label)
except Exception:
pass
self._rpc_call("label.set_torrent", torrent_id, label)
except Exception as e:
logger.debug(f"Could not set Deluge label '{label}' for {torrent_id}: {e}")
@staticmethod
def is_configured() -> bool:
client = config.get("PROWLARR_TORRENT_CLIENT", "")
host = config.get("DELUGE_HOST", "")
password = config.get("DELUGE_PASSWORD", "")
return client == "deluge" and bool(host) and bool(password)
def test_connection(self) -> Tuple[bool, str]:
try:
self._ensure_connected()
version = self._get_daemon_version()
return True, f"Connected to Deluge {version}"
except Exception as e:
self._authenticated = False
self._connected = False
return False, f"Connection failed: {str(e)}"
def add_download(
self,
url: str,
name: str,
category: Optional[str] = None,
expected_hash: Optional[str] = None,
**kwargs,
) -> str:
try:
self._ensure_connected()
category_value = str(category or self._category)
torrent_info = extract_torrent_info(url, expected_hash=expected_hash)
if not torrent_info.is_magnet and not torrent_info.torrent_data:
raise Exception("Failed to fetch torrent file")
options: dict[str, Any] = {}
if self._download_dir:
options["download_location"] = self._download_dir
# Per-torrent seeding limits from indexer
seeding_time_limit = kwargs.get("seeding_time_limit")
if seeding_time_limit is not None:
options["seed_time_limit"] = int(seeding_time_limit)
ratio_limit = kwargs.get("ratio_limit")
if ratio_limit is not None:
options["stop_at_ratio"] = float(ratio_limit)
options["stop_at_ratio_enabled"] = True
if torrent_info.is_magnet:
magnet_url = torrent_info.magnet_url or url
torrent_id = self._rpc_call("core.add_torrent_magnet", magnet_url, options)
else:
torrent_data = torrent_info.torrent_data
if torrent_data is None:
raise Exception("Failed to fetch torrent file")
torrent_data_bytes: bytes = torrent_data
filedump = base64.b64encode(torrent_data_bytes).decode("ascii")
torrent_id = self._rpc_call(
"core.add_torrent_file",
f"{name}.torrent",
filedump,
options,
)
if not torrent_id:
raise Exception("Deluge returned no torrent ID")
torrent_id = str(torrent_id).lower()
self._try_set_label(torrent_id, category_value)
logger.info(f"Added torrent to Deluge: {torrent_id}")
return torrent_id
except Exception as e:
self._authenticated = False
self._connected = False
logger.error(f"Deluge add failed: {e}")
raise
def get_status(self, download_id: str) -> DownloadStatus:
try:
self._ensure_connected()
status = self._rpc_call(
"core.get_torrent_status",
download_id,
["state", "progress", "download_payload_rate", "eta", "save_path", "name"],
)
if not status:
return DownloadStatus.error("Torrent not found")
# Deluge states: Downloading, Seeding, Paused, Checking, Queued, Error, Moving
state_map = {
"Downloading": ("downloading", None),
"Seeding": ("seeding", "Seeding"),
"Paused": ("paused", "Paused"),
"Checking": ("checking", "Checking files"),
"Queued": ("queued", "Queued"),
"Error": ("error", "Error"),
"Moving": ("processing", "Moving files"),
"Allocating": ("downloading", "Allocating space"),
}
deluge_state = status.get("state", "Unknown")
state, message = state_map.get(str(deluge_state), ("unknown", str(deluge_state)))
progress = float(status.get("progress", 0))
# Don't mark complete while files are being moved
complete = progress >= 100 and deluge_state != "Moving"
if complete:
message = "Complete"
eta = status.get("eta")
if eta is not None:
try:
eta = int(eta)
except Exception:
eta = None
if eta is not None and (eta < 0 or eta > 604800):
eta = None
file_path = None
if complete:
# Output path is save_path + torrent name
file_path = self._build_path(
str(status.get("save_path", "")),
str(status.get("name", "")),
)
return DownloadStatus(
progress=progress,
state="complete" if complete else state,
message=message,
complete=complete,
file_path=file_path,
download_speed=status.get("download_payload_rate"),
eta=eta,
)
except Exception as e:
return DownloadStatus.error(self._log_error("get_status", e))
def remove(self, download_id: str, delete_files: bool = False) -> bool:
try:
self._ensure_connected()
result = self._rpc_call("core.remove_torrent", download_id, delete_files)
if result:
logger.info(
f"Removed torrent from Deluge: {download_id}"
+ (" (with files)" if delete_files else "")
)
return True
return False
except Exception as e:
self._log_error("remove", e)
return False
def get_download_path(self, download_id: str) -> Optional[str]:
try:
self._ensure_connected()
status = self._rpc_call(
"core.get_torrent_status",
download_id,
["save_path", "name"],
)
if status:
return self._build_path(
str(status.get("save_path", "")),
str(status.get("name", "")),
)
return None
except Exception as e:
self._log_error("get_download_path", e, level="debug")
return None
def find_existing(
self, url: str, category: Optional[str] = None
) -> Optional[Tuple[str, DownloadStatus]]:
try:
self._ensure_connected()
torrent_info = extract_torrent_info(url)
if not torrent_info.info_hash:
return None
status = self._rpc_call(
"core.get_torrent_status",
torrent_info.info_hash,
["state"],
)
if status:
full_status = self.get_status(torrent_info.info_hash)
return (torrent_info.info_hash, full_status)
return None
except Exception as e:
self._authenticated = False
self._connected = False
logger.debug(f"Error checking for existing torrent: {e}")
return None
@@ -11,10 +11,13 @@ import requests
from shelfmark.core.config import config
from shelfmark.core.logger import setup_logger
from shelfmark.release_sources.prowlarr.clients import (
from shelfmark.core.utils import normalize_http_url
from shelfmark.download.network import get_ssl_verify
from shelfmark.download.clients import (
DownloadClient,
DownloadStatus,
register_client,
with_retry,
)
logger = setup_logger(__name__)
@@ -29,11 +32,13 @@ class NZBGetClient(DownloadClient):
def __init__(self):
"""Initialize NZBGet client with settings from config."""
url = config.get("NZBGET_URL", "")
if not url:
raw_url = config.get("NZBGET_URL", "")
if not raw_url:
raise ValueError("NZBGET_URL is required")
self.url = url.rstrip("/")
self.url = normalize_http_url(raw_url)
if not self.url:
raise ValueError("NZBGET_URL is invalid")
self.username = config.get("NZBGET_USERNAME", "nzbget")
self.password = config.get("NZBGET_PASSWORD", "")
self._category = config.get("NZBGET_CATEGORY", "Books")
@@ -42,10 +47,11 @@ class NZBGetClient(DownloadClient):
def is_configured() -> bool:
"""Check if NZBGet is configured and selected as the usenet client."""
client = config.get("PROWLARR_USENET_CLIENT", "")
url = config.get("NZBGET_URL", "")
url = normalize_http_url(config.get("NZBGET_URL", ""))
return client == "nzbget" and bool(url)
def _rpc_call(self, method: str, params: list = None) -> Any:
@with_retry()
def _rpc_call(self, method: str, params: Optional[list] = None) -> Any:
"""
Make a JSON-RPC call to NZBGet.
@@ -57,7 +63,7 @@ class NZBGetClient(DownloadClient):
Result from NZBGet.
Raises:
Exception: If RPC call fails.
Exception: If RPC call fails after retries.
"""
rpc_url = f"{self.url}/jsonrpc"
@@ -74,6 +80,7 @@ class NZBGetClient(DownloadClient):
headers={"Content-Type": "application/json"},
auth=(self.username, self.password),
timeout=30,
verify=get_ssl_verify(rpc_url),
)
response.raise_for_status()
@@ -96,7 +103,14 @@ class NZBGetClient(DownloadClient):
except Exception as e:
return False, f"Connection failed: {str(e)}"
def add_download(self, url: str, name: str, category: str = None) -> str:
def add_download(
self,
url: str,
name: str,
category: Optional[str] = None,
expected_hash: Optional[str] = None,
**kwargs,
) -> str:
"""
Add NZB by URL.
@@ -107,6 +121,7 @@ class NZBGetClient(DownloadClient):
url: NZB URL (can be Prowlarr proxy URL)
name: Display name for the download
category: Category for organization (uses configured default if not specified)
expected_hash: Optional info_hash hint (unused)
Returns:
NZBGet download ID (NZBID).
@@ -122,7 +137,7 @@ class NZBGetClient(DownloadClient):
try:
# Fetch NZB content from the URL (handles Prowlarr proxy redirects)
logger.debug(f"Fetching NZB from: {url}")
response = requests.get(url, timeout=30)
response = requests.get(url, timeout=30, verify=get_ssl_verify(url))
response.raise_for_status()
nzb_content = base64.b64encode(response.content).decode('ascii')
@@ -226,7 +241,19 @@ class NZBGetClient(DownloadClient):
for item in history:
if item.get("NZBID") == nzb_id:
status = item.get("Status", "")
dest_dir = item.get("DestDir", "")
# Prefer FinalDir (post-processing result) over DestDir (original)
final_dir = item.get("FinalDir", "") or None
dest_dir = item.get("DestDir", "") or None
file_path = final_dir or dest_dir # Use FinalDir if available
# Normalize for consistent downstream use.
if isinstance(file_path, str) and file_path:
import os
file_path = os.path.normpath(file_path)
else:
file_path = None
if "SUCCESS" in status:
return DownloadStatus(
@@ -234,7 +261,7 @@ class NZBGetClient(DownloadClient):
state="complete",
message="Complete",
complete=True,
file_path=dest_dir,
file_path=file_path,
)
else:
return DownloadStatus(
@@ -242,41 +269,54 @@ class NZBGetClient(DownloadClient):
state="error",
message=f"Download failed: {status}",
complete=True,
file_path=None,
file_path=file_path,
)
# Not found in queue or history
return DownloadStatus.error("Download not found")
except Exception as e:
error_type = type(e).__name__
logger.error(f"NZBGet get_status failed ({error_type}): {e}")
return DownloadStatus.error(f"{error_type}: {e}")
return DownloadStatus.error(self._log_error("get_status", e))
def remove(self, download_id: str, delete_files: bool = False) -> bool:
"""
Remove a download from NZBGet.
"""Remove a download from NZBGet.
NZBGet can remove items from either the active queue (Group* commands) or from
history (History* commands). Completed downloads are typically in history.
Args:
download_id: NZBGet NZBID
delete_files: Whether to permanently delete (vs move to history)
delete_files: Whether to permanently delete downloaded files
Returns:
True if successful.
"""
try:
nzb_id = int(download_id)
# editqueue params: Command (str), Param (str), IDs (int[])
# GroupFinalDelete = permanent removal, GroupDelete = move to history
command = "GroupFinalDelete" if delete_files else "GroupDelete"
result = self._rpc_call("editqueue", [command, "", [nzb_id]])
if result:
logger.info(f"Removed NZB from NZBGet: {download_id}")
return bool(result)
except Exception as e:
error_type = type(e).__name__
logger.error(f"NZBGet remove failed ({error_type}): {e}")
except (TypeError, ValueError) as e:
self._log_error("remove", e)
return False
if delete_files:
# Keep HistoryDelete as a fallback for
# older NZBGet versions where HistoryFinalDelete may not exist.
commands = ["GroupFinalDelete", "HistoryFinalDelete", "HistoryDelete"]
else:
commands = ["GroupDelete", "HistoryDelete"]
last_error: Optional[Exception] = None
for command in commands:
try:
result = self._rpc_call("editqueue", [command, 0, "", nzb_id])
if result:
logger.info(f"Removed NZB from NZBGet ({command}): {download_id}")
return True
except Exception as e:
last_error = e
if last_error is not None:
self._log_error("remove", last_error)
return False
def get_download_path(self, download_id: str) -> Optional[str]:
"""
Get the path where NZB files are located.
+646
View File
@@ -0,0 +1,646 @@
"""qBittorrent download client for Prowlarr integration."""
import time
from types import SimpleNamespace
from typing import Optional, Tuple
from shelfmark.core.config import config
from shelfmark.core.logger import setup_logger
from shelfmark.core.utils import normalize_http_url
from shelfmark.download.network import get_ssl_verify
from shelfmark.download.clients import (
DownloadClient,
DownloadStatus,
register_client,
)
from shelfmark.download.clients.torrent_utils import (
extract_torrent_info,
)
logger = setup_logger(__name__)
def _hashes_match(hash1: str, hash2: str) -> bool:
"""Compare hashes, handling Amarr's 40-char zero-padded hashes vs 32-char ed2k hashes."""
h1, h2 = hash1.lower(), hash2.lower()
if h1 == h2:
return True
if len(h1) == 40 and len(h2) == 32 and h1.endswith("00000000"):
return h1[:32] == h2
if len(h2) == 40 and len(h1) == 32 and h2.endswith("00000000"):
return h2[:32] == h1
return False
def _normalize_tags(raw_tags: object) -> list[str]:
"""Normalize tag input to a clean, de-duplicated list of strings."""
if raw_tags is None:
return []
if isinstance(raw_tags, str):
parts = [part.strip() for part in raw_tags.split(",")]
elif isinstance(raw_tags, (list, tuple, set)):
parts = []
for item in raw_tags:
if item is None:
continue
parts.append(str(item).strip())
else:
parts = [str(raw_tags).strip()] if raw_tags else []
tags: list[str] = []
seen = set()
for part in parts:
if not part:
continue
if part in seen:
continue
seen.add(part)
tags.append(part)
return tags
def _normalize_add_result(raw_result: object) -> str:
"""Normalize qBittorrent add responses to a comparable string."""
if raw_result is None:
return ""
if isinstance(raw_result, bytes):
return raw_result.decode("utf-8", errors="replace").strip()
return str(raw_result).strip()
def _is_explicit_add_failure(raw_result: object) -> bool:
"""Detect add responses that clearly indicate failure."""
normalized = _normalize_add_result(raw_result).rstrip(".").lower()
return normalized in {"fail", "fails", "error", "errors"}
@register_client("torrent")
class QBittorrentClient(DownloadClient):
"""qBittorrent download client."""
def _is_torrent_loaded(self, torrent_hash: str) -> tuple[bool, Optional[str]]:
"""Check whether qBittorrent has registered a torrent yet.
Uses `/api/v2/torrents/properties?hash=<hash>`.
Returns:
(loaded, error_message)
Notes:
A false result with no error means "not loaded yet".
"""
import requests
url = f"{self._base_url}/api/v2/torrents/properties"
params = {"hash": torrent_hash}
try:
self._client.auth_log_in()
response = self._client._session.get(url, params=params, timeout=10)
# Re-authenticate and retry once on 403
if response.status_code == 403:
logger.debug("qBittorrent returned 403 for properties; re-authenticating and retrying")
self._client.auth_log_in()
response = self._client._session.get(url, params=params, timeout=10)
if response.status_code == 403:
return False, "qBittorrent authentication failed (HTTP 403)"
# qBittorrent returns 404/409-ish responses depending on version when missing.
if response.status_code == 404:
return False, None
response.raise_for_status()
return True, None
except requests.exceptions.HTTPError as e:
status = getattr(getattr(e, "response", None), "status_code", None)
if status == 404:
return False, None
if status:
return False, f"qBittorrent API request failed (HTTP {status})"
return False, "qBittorrent API request failed"
except requests.exceptions.ConnectionError:
return False, f"Cannot connect to qBittorrent at {self._base_url}"
except requests.exceptions.Timeout:
return False, f"qBittorrent request timed out at {self._base_url}"
except Exception as e:
return False, f"qBittorrent API error: {type(e).__name__}: {e}"
protocol = "torrent"
name = "qbittorrent"
def __init__(self):
"""Initialize qBittorrent client with settings from config."""
# Lazy import to avoid dependency issues if not using torrents
from qbittorrentapi import Client
raw_url = config.get("QBITTORRENT_URL", "")
if not raw_url:
raise ValueError("QBITTORRENT_URL is required")
# We use `_base_url` for direct HTTP calls, so it must be a fully-qualified URL.
self._base_url = normalize_http_url(raw_url)
if not self._base_url:
raise ValueError("QBITTORRENT_URL is invalid")
# qbittorrent-api accepts either a full URL or host:port; prefer the normalized URL
# for consistency.
self._client = Client(
host=self._base_url,
username=config.get("QBITTORRENT_USERNAME", ""),
password=config.get("QBITTORRENT_PASSWORD", ""),
VERIFY_WEBUI_CERTIFICATE=get_ssl_verify(self._base_url),
)
self._category = config.get("QBITTORRENT_CATEGORY", "books")
self._download_dir = config.get("QBITTORRENT_DOWNLOAD_DIR", "")
self._tags = _normalize_tags(config.get("QBITTORRENT_TAG", []))
def _get_torrents_info(
self, torrent_hash: Optional[str] = None
) -> tuple[list[SimpleNamespace], Optional[str]]:
"""Get torrent info using GET.
Behaviors:
- Retry once on HTTP 403 by re-authenticating.
- Keep "API/auth/connect" errors distinct from "torrent missing".
- If a hash-specific query returns empty, fall back to listing by category
and matching locally.
Returns:
(torrents, error_message)
"""
import requests
url = f"{self._base_url}/api/v2/torrents/info"
def do_request(params: dict[str, str]) -> requests.Response:
# Ensure session is authenticated before using it directly
self._client.auth_log_in()
return self._client._session.get(url, params=params, timeout=10)
def parse_response(
response: requests.Response,
*,
request_params: dict[str, str],
) -> tuple[list[SimpleNamespace], Optional[str]]:
if response.status_code == 403:
logger.debug("qBittorrent returned 403; re-authenticating and retrying")
self._client.auth_log_in()
response = self._client._session.get(url, params=request_params, timeout=10)
if response.status_code == 403:
logger.warning("qBittorrent authentication failed (HTTP 403)")
return [], "qBittorrent authentication failed (HTTP 403)"
response.raise_for_status()
torrents = response.json()
return [SimpleNamespace(**t) for t in torrents], None
try:
primary_params: dict[str, str] = {}
if torrent_hash:
primary_params["hashes"] = torrent_hash
response = do_request(primary_params)
torrents, error = parse_response(response, request_params=primary_params)
if error:
return [], error
if torrent_hash and not torrents:
# Fallback 1: list by configured category
category_params: dict[str, str] = {}
if self._category:
category_params["category"] = self._category
category_response = do_request(category_params)
category_torrents, category_error = parse_response(
category_response, request_params=category_params
)
if category_error:
return [], category_error
if category_torrents:
return category_torrents, None
# Fallback 2: list everything (handles per-task categories like audiobooks)
all_response = do_request({})
all_torrents, all_error = parse_response(all_response, request_params={})
if all_error:
return [], all_error
return all_torrents, None
return torrents, None
except requests.exceptions.HTTPError as e:
status = getattr(getattr(e, "response", None), "status_code", None)
if status:
logger.warning(f"qBittorrent API error (HTTP {status}): {e}")
return [], f"qBittorrent API request failed (HTTP {status})"
logger.warning(f"qBittorrent API error: {e}")
return [], "qBittorrent API request failed"
except requests.exceptions.ConnectionError:
logger.warning(f"Cannot connect to qBittorrent at {self._base_url}")
return [], f"Cannot connect to qBittorrent at {self._base_url}"
except requests.exceptions.Timeout:
logger.warning(f"qBittorrent request timed out at {self._base_url}")
return [], f"qBittorrent request timed out at {self._base_url}"
except Exception as e:
logger.debug(f"Failed to get torrents info: {e}")
# requests raises InvalidSchema when the base URL doesn't include http(s)
if type(e).__name__ == "InvalidSchema":
return (
[],
"qBittorrent URL is invalid (missing http:// or https://). "
f"Configured: {self._base_url}",
)
return [], f"qBittorrent API error: {type(e).__name__}: {e}"
@staticmethod
def is_configured() -> bool:
"""Check if qBittorrent is configured and selected as the torrent client."""
client = config.get("PROWLARR_TORRENT_CLIENT", "")
url = normalize_http_url(config.get("QBITTORRENT_URL", ""))
return client == "qbittorrent" and bool(url)
def test_connection(self) -> Tuple[bool, str]:
"""Test connection to qBittorrent."""
try:
self._client.auth_log_in()
api_version = self._client.app.web_api_version
return True, f"Connected to qBittorrent (API v{api_version})"
except Exception as e:
return False, f"Connection failed: {str(e)}"
def add_download(
self,
url: str,
name: str,
category: str | None = None,
expected_hash: str | None = None,
**kwargs,
) -> str:
"""
Add torrent by URL (magnet or .torrent).
Args:
url: Magnet link or .torrent URL
name: Display name for the torrent
category: Category for organization (uses configured default if not specified)
expected_hash: Optional info_hash hint (from Prowlarr)
Returns:
Torrent hash (info_hash).
Raises:
Exception: If adding fails.
"""
try:
# Use configured category if not explicitly provided
category = category or self._category
tags = self._tags
# Ensure category exists (may already exist, which is fine)
if category:
try:
self._client.torrents_create_category(name=category)
except Exception as e:
# Conflict409Error means category exists - that's expected
# Log other errors but continue since download may still work
if "Conflict" not in type(e).__name__ and "409" not in str(e):
logger.debug(
f"Could not create category '{category}': {type(e).__name__}: {e}"
)
torrent_info = extract_torrent_info(url, expected_hash=expected_hash)
expected_hash = torrent_info.info_hash
torrent_data = torrent_info.torrent_data
# Add the torrent - use file content if we have it, otherwise URL
add_kwargs = {
"rename": name,
}
if category:
add_kwargs["category"] = category
if self._download_dir:
add_kwargs["save_path"] = self._download_dir
if tags:
add_kwargs["tags"] = ",".join(tags)
# Per-torrent seeding limits from indexer
seeding_time_limit = kwargs.get("seeding_time_limit")
if seeding_time_limit is not None:
add_kwargs["seeding_time_limit"] = int(seeding_time_limit)
ratio_limit = kwargs.get("ratio_limit")
if ratio_limit is not None:
add_kwargs["ratio_limit"] = float(ratio_limit)
if torrent_data:
result = self._client.torrents_add(
torrent_files=torrent_data,
**add_kwargs,
)
else:
# Use magnet URL if available, otherwise original URL
add_url = torrent_info.magnet_url or url
result = self._client.torrents_add(
urls=add_url,
**add_kwargs,
)
result_text = _normalize_add_result(result)
logger.debug(f"qBittorrent add result: {result_text}")
if not expected_hash:
raise Exception("Could not determine torrent hash from URL")
if _is_explicit_add_failure(result):
raise Exception(f"Failed to add torrent: {result_text}")
# Some qBittorrent-compatible clients return HTTP 200 with an empty body
# instead of qBittorrent's literal "Ok." response. Prefer verifying that
# the torrent becomes visible over trusting the response body alone.
for _ in range(10):
loaded, error = self._is_torrent_loaded(expected_hash)
if error:
logger.debug(f"qBittorrent add_download: {error}")
if loaded:
logger.info(f"Added torrent: {expected_hash}")
return expected_hash.lower()
time.sleep(0.5)
logger.warning(
"Torrent add was not confirmed within the visibility grace period "
f"(response={result_text or '<empty>'}), returning expected hash"
)
return expected_hash
except Exception as e:
logger.error(f"qBittorrent add failed: {e}")
raise
def get_status(self, download_id: str) -> DownloadStatus:
"""
Get torrent status by hash.
Args:
download_id: Torrent info_hash
Returns:
Current download status.
"""
try:
torrents, error = self._get_torrents_info(download_id)
if error:
return DownloadStatus.error(error)
torrent = next(
(
t
for t in torrents
if isinstance(getattr(t, "hash", None), str)
and _hashes_match(getattr(t, "hash"), download_id)
),
None,
)
if not torrent:
return DownloadStatus.error("Torrent not found in qBittorrent")
# Map qBittorrent states to our states and user-friendly messages
state_info = {
"downloading": ("downloading", None), # None = use default progress message
"stalledDL": ("downloading", "Stalled"),
"metaDL": ("downloading", "Fetching metadata"),
"forcedDL": ("downloading", None),
"allocating": ("downloading", "Allocating space"),
"uploading": ("seeding", "Seeding"),
"stalledUP": ("seeding", "Seeding (stalled)"),
"forcedUP": ("seeding", "Seeding"),
"pausedDL": ("paused", "Paused"),
"pausedUP": ("paused", "Paused"),
"queuedDL": ("queued", "Queued"),
"queuedUP": ("queued", "Queued"),
"checkingDL": ("checking", "Checking files"),
"checkingUP": ("checking", "Checking files"),
"checkingResumeData": ("checking", "Checking resume data"),
"moving": ("processing", "Moving files"),
"error": ("error", "Error"),
"missingFiles": ("error", "Missing files"),
"unknown": ("unknown", "Unknown state"),
}
torrent_state = getattr(torrent, "state", "unknown")
state, message = state_info.get(torrent_state, ("unknown", str(torrent_state)))
torrent_progress = getattr(torrent, "progress", 0.0)
# Don't mark complete while files are being moved to final location
# (qBittorrent moves files from incomplete → complete folder)
complete = torrent_progress >= 1.0 and torrent_state != "moving"
# For active downloads without a special message, leave message as None
# so the handler can build the progress message
if complete:
message = "Complete"
torrent_eta = getattr(torrent, "eta", 0)
eta = torrent_eta if isinstance(torrent_eta, int) and 0 < torrent_eta < 604800 else None
# Get file path for completed downloads
file_path = None
if complete:
file_path = self._resolve_completed_download_path(torrent)
torrent_speed = getattr(torrent, "dlspeed", None)
torrent_speed = torrent_speed if isinstance(torrent_speed, int) else None
return DownloadStatus(
progress=float(torrent_progress) * 100,
state="complete" if complete else state,
message=message,
complete=complete,
file_path=file_path,
download_speed=torrent_speed,
eta=eta,
)
except Exception as e:
return DownloadStatus.error(self._log_error("get_status", e))
def remove(self, download_id: str, delete_files: bool = False) -> bool:
"""
Remove a torrent from qBittorrent.
Args:
download_id: Torrent info_hash
delete_files: Whether to also delete files
Returns:
True if successful.
"""
try:
self._client.torrents_delete(
torrent_hashes=download_id, delete_files=delete_files
)
logger.info(
f"Removed torrent from qBittorrent: {download_id}"
+ (" (with files)" if delete_files else "")
)
return True
except Exception as e:
self._log_error("remove", e)
return False
def get_download_path(self, download_id: str) -> Optional[str]:
"""Get the path where torrent files are located.
Prefer `content_path` when available.
When `content_path` is missing (commonly with qBittorrent-like emulators such
as Amarr), derive the path using:
- `/api/v2/torrents/properties?hash=<hash>` for `save_path`
- `/api/v2/torrents/files?hash=<hash>` for the first file name
- join `save_path` with the torrent's top-level directory
"""
import os
try:
torrents, error = self._get_torrents_info(download_id)
if error:
logger.debug(f"qBittorrent get_download_path: {error}")
return None
torrent = next(
(
t
for t in torrents
if isinstance(getattr(t, "hash", None), str)
and _hashes_match(getattr(t, "hash"), download_id)
),
None,
)
if not torrent:
return None
return self._resolve_completed_download_path(torrent)
except Exception as e:
self._log_error("get_download_path", e, level="debug")
return None
def _resolve_completed_download_path(self, torrent: SimpleNamespace) -> Optional[str]:
"""Resolve the completed path for a torrent.
Centralizes the logic shared by `get_status()` and `get_download_path()`:
- accept `content_path` only when it's not equal to `save_path`
- otherwise derive via properties+files
- finally fall back to `save_path + name`
"""
# Prefer content_path, but treat content_path == save_path as invalid.
content_path = getattr(torrent, "content_path", "")
save_path = getattr(torrent, "save_path", "")
if content_path and (not save_path or str(content_path) != str(save_path)):
return str(content_path)
download_id = getattr(torrent, "hash", "")
if isinstance(download_id, str) and download_id:
derived = self._derive_download_path_from_files(download_id)
if derived:
return derived
# Legacy fallback: save_path + name (for older clients/emulators)
return self._build_path(
getattr(torrent, "save_path", ""),
getattr(torrent, "name", ""),
)
def _derive_download_path_from_files(self, download_id: str) -> Optional[str]:
"""Derive completed download path using `/torrents/properties` + `/torrents/files`.
This mirrors how common automation apps derive the path when
`content_path` isn't provided.
"""
import os
import requests
def get_with_auth(url: str, params: dict[str, str]) -> requests.Response:
self._client.auth_log_in()
resp = self._client._session.get(url, params=params, timeout=10)
if resp.status_code == 403:
logger.debug("qBittorrent returned 403; re-authenticating and retrying")
self._client.auth_log_in()
resp = self._client._session.get(url, params=params, timeout=10)
return resp
try:
properties_url = f"{self._base_url}/api/v2/torrents/properties"
files_url = f"{self._base_url}/api/v2/torrents/files"
props_resp = get_with_auth(properties_url, {"hash": download_id})
if props_resp.status_code == 404:
return None
props_resp.raise_for_status()
props = props_resp.json() if isinstance(props_resp.json(), dict) else {}
save_path = props.get("save_path") or props.get("savePath") or ""
if not isinstance(save_path, str) or not save_path:
return None
files_resp = get_with_auth(files_url, {"hash": download_id})
if files_resp.status_code == 404:
return None
files_resp.raise_for_status()
files = files_resp.json() if isinstance(files_resp.json(), list) else []
if not files:
return None
first_name = files[0].get("name") if isinstance(files[0], dict) else None
if not isinstance(first_name, str) or not first_name:
return None
# Get the first path segment (qBittorrent returns '/' even on Windows).
first_name_norm = first_name.replace("\\", "/")
top_level = first_name_norm.split("/", 1)[0]
if not top_level:
return None
return os.path.normpath(os.path.join(save_path, top_level))
except Exception as e:
logger.debug(f"qBittorrent could not derive path from files: {type(e).__name__}: {e}")
return None
def find_existing(
self, url: str, category: Optional[str] = None
) -> Optional[Tuple[str, DownloadStatus]]:
"""Check if a torrent for this URL already exists in qBittorrent."""
try:
torrent_info = extract_torrent_info(url)
if not torrent_info.info_hash:
return None
torrents, error = self._get_torrents_info(torrent_info.info_hash)
if error:
logger.debug(f"qBittorrent find_existing: {error}")
return None
torrent = next(
(
t
for t in torrents
if isinstance(getattr(t, "hash", None), str)
and _hashes_match(getattr(t, "hash"), torrent_info.info_hash)
),
None,
)
if torrent and isinstance(getattr(torrent, "hash", None), str):
torrent_hash = getattr(torrent, "hash")
return (torrent_hash.lower(), self.get_status(torrent_hash.lower()))
return None
except Exception as e:
logger.debug(f"Error checking for existing torrent: {e}")
return None
+342
View File
@@ -0,0 +1,342 @@
"""
rTorrent download client for Prowlarr integration.
Uses xmlrpc to communicate with rTorrent's RPC interface.
"""
import ssl
from typing import Any, Optional, Tuple
from urllib.parse import urlparse
from shelfmark.core.config import config
from shelfmark.core.logger import setup_logger
from shelfmark.core.utils import normalize_http_url, get_hardened_xmlrpc_client
from shelfmark.download.network import get_ssl_verify
from shelfmark.download.clients import (
DownloadClient,
DownloadStatus,
register_client,
)
from shelfmark.download.clients.torrent_utils import (
extract_torrent_info,
)
logger = setup_logger(__name__)
def _create_rtorrent_server_proxy(url: str) -> Any:
"""Create an XML-RPC ServerProxy honoring certificate validation mode."""
xmlrpc_client = get_hardened_xmlrpc_client()
verify = get_ssl_verify(url)
if url.startswith("https://") and not verify:
ssl_context = ssl.create_default_context()
ssl_context.check_hostname = False
ssl_context.verify_mode = ssl.CERT_NONE
transport = xmlrpc_client.SafeTransport(context=ssl_context)
return xmlrpc_client.ServerProxy(url, transport=transport)
return xmlrpc_client.ServerProxy(url)
@register_client("torrent")
class RTorrentClient(DownloadClient):
"""rTorrent download client using xmlrpc."""
protocol = "torrent"
name = "rtorrent"
def __init__(self):
"""Initialize rTorrent client with settings from config."""
raw_url = config.get("RTORRENT_URL", "")
if not raw_url:
raise ValueError("RTORRENT_URL is required")
self._base_url = normalize_http_url(raw_url)
if not self._base_url:
raise ValueError("RTORRENT_URL is invalid")
username = config.get("RTORRENT_USERNAME", "")
password = config.get("RTORRENT_PASSWORD", "")
if username and password:
parsed = urlparse(self._base_url)
self._base_url = (
f"{parsed.scheme}://{username}:{password}@{parsed.netloc}{parsed.path}"
)
self._rpc = _create_rtorrent_server_proxy(self._base_url)
self._download_dir = config.get("RTORRENT_DOWNLOAD_DIR", "")
self._label = config.get("RTORRENT_LABEL", "")
@staticmethod
def is_configured() -> bool:
"""Check if rTorrent is configured and selected as the torrent client."""
client = config.get("PROWLARR_TORRENT_CLIENT", "")
url = normalize_http_url(config.get("RTORRENT_URL", ""))
return client == "rtorrent" and bool(url)
def test_connection(self) -> Tuple[bool, str]:
"""Test connection to rTorrent."""
try:
version = self._rpc.system.client_version()
return True, f"Connected to rTorrent {version}"
except Exception as e:
return False, f"Connection failed: {str(e)}"
def add_download(
self,
url: str,
name: str,
category: Optional[str] = None,
expected_hash: Optional[str] = None,
**kwargs,
) -> str:
"""
Add torrent by URL (magnet or .torrent).
Args:
url: Magnet link or .torrent URL
name: Display name for the torrent
category: Category for organization (uses configured label if not specified)
expected_hash: Optional info_hash hint (from Prowlarr)
Returns:
Torrent hash (info_hash).
Raises:
Exception: If adding fails.
"""
try:
torrent_info = extract_torrent_info(url, expected_hash=expected_hash)
commands = []
label = category or self._label
if label:
logger.debug(f"Setting rTorrent label: {label}")
commands.append(f"d.custom1.set={label}")
download_dir = self._download_dir or self._get_download_dir()
if download_dir:
logger.debug(f"Setting rTorrent download directory: {download_dir}")
commands.append(f"d.directory.set={download_dir}")
if torrent_info.torrent_data:
logger.debug(f"Adding torrent data directly to rTorrent for: {name} with commands: {commands} with data size: {len(torrent_info.torrent_data)}")
self._rpc.load.raw_start(
"", torrent_info.torrent_data, ";".join(commands)
)
else:
logger.debug(f"Adding torrent URL to rTorrent for: {name} with commands: {commands} with URL: {url}")
add_url = torrent_info.magnet_url or url
self._rpc.load.start("", add_url, ";".join(commands))
torrent_hash = torrent_info.info_hash or expected_hash
if not torrent_hash:
raise Exception("Could not determine torrent hash from URL")
logger.debug(f"Added torrent to rTorrent: {torrent_hash}")
return torrent_hash
except Exception as e:
logger.error(f"rTorrent add failed: {e}")
raise
def get_status(self, download_id: str) -> DownloadStatus:
"""
Get torrent status by hash.
Args:
download_id: Torrent info_hash
Returns:
Current download status.
"""
try:
# rtorrent is somehow case sensitive and requires uppercase hashes for look
download_id = download_id.upper()
all_torrents = self._rpc.d.multicall2(
"",
"",
"d.hash=",
"d.state=",
"d.completed_bytes=",
"d.size_bytes=",
"d.down.rate=",
"d.up.rate=",
"d.custom1=",
"d.complete=",
)
torrent_list = [t for t in all_torrents if t and t[0] == download_id]
logger.debug(f"Fetched torrent status from rTorrent for: {download_id} - {torrent_list}")
if not torrent_list:
logger.warning(f"Torrent not found in rTorrent: {download_id}")
return DownloadStatus.error("Torrent not found")
torrent = torrent_list[0]
if not torrent:
logger.warning(f"Torrent data is empty for: {download_id}")
return DownloadStatus.error("Torrent not found")
logger.debug(f"Torrent data for {download_id}: {torrent}")
(
torrent_hash,
state,
bytes_downloaded,
bytes_total,
down_rate,
up_rate,
custom_category,
complete,
) = torrent
try:
state = int(state)
except Exception:
state = 0
complete = bool(complete)
if bytes_total > 0:
progress = (bytes_downloaded / bytes_total) * 100
else:
progress = 0
bytes_left = max(0, bytes_total - bytes_downloaded)
state_map = {
0: ("paused", "Paused"),
1: ("downloading", "Downloading"),
2: ("downloading", "Downloading"),
3: ("downloading", "Downloading"),
4: ("seeding", "Seeding"),
}
state_str, message = state_map.get(state, ("unknown", "Unknown state"))
if complete and not message:
message = "Complete"
eta = None
if down_rate > 0 and bytes_left > 0:
eta_seconds = bytes_left / down_rate
if eta_seconds < 604800:
eta = int(eta_seconds)
file_path = None
if complete:
file_path = self._get_torrent_path(download_id)
return DownloadStatus(
progress=min(100, progress),
state="complete" if complete else state_str,
message=message,
complete=complete,
file_path=file_path,
download_speed=down_rate if down_rate > 0 else None,
eta=eta,
)
except Exception as e:
error_type = type(e).__name__
logger.error(f"rTorrent get_status failed ({error_type}): {e}")
return DownloadStatus.error(f"{error_type}: {e}")
def remove(self, download_id: str, delete_files: bool = False) -> bool:
"""
Remove a torrent from rTorrent.
Args:
download_id: Torrent info_hash
delete_files: Whether to also delete files
Returns:
True if successful.
"""
try:
if delete_files:
self._rpc.d.delete_tied(download_id)
self._rpc.d.erase(download_id)
else:
self._rpc.d.stop(download_id)
self._rpc.d.erase(download_id)
logger.info(
f"Removed torrent from rTorrent: {download_id}"
+ (" (with files)" if delete_files else "")
)
return True
except Exception as e:
error_type = type(e).__name__
logger.error(f"rTorrent remove failed ({error_type}): {e}")
return False
def get_download_path(self, download_id: str) -> Optional[str]:
"""
Get the path where torrent files are located.
Args:
download_id: Torrent info_hash
Returns:
Content path (file or directory), or None.
"""
try:
return self._get_torrent_path(download_id)
except Exception as e:
error_type = type(e).__name__
logger.debug(f"rTorrent get_download_path failed ({error_type}): {e}")
return None
def find_existing(
self, url: str, category: Optional[str] = None
) -> Optional[Tuple[str, DownloadStatus]]:
"""Check if a torrent for this URL already exists in rTorrent."""
try:
torrent_info = extract_torrent_info(url)
if not torrent_info.info_hash:
return None
try:
status = self.get_status(torrent_info.info_hash)
if status.state != DownloadStatus.error("").state:
return (torrent_info.info_hash, status)
except Exception:
pass
return None
except Exception as e:
logger.debug(f"Error checking for existing torrent: {e}")
return None
def _get_download_dir(self) -> str:
"""Get the download directory from rTorrent config."""
try:
download_dir = self._rpc.directory.default()
return download_dir
except Exception:
return "/downloads"
def _get_torrent_path(self, download_id: str) -> Optional[str]:
"""Get the file path of a torrent by hash.
Uses `d.base_path` for the item output path. In the xmlrpc interface
this corresponds to `d.get_base_path()`.
"""
try:
# rTorrent is case sensitive for hashes; use uppercase as in get_status()
download_hash = download_id.upper()
all_torrents = self._rpc.d.multicall2(
"",
"",
"d.hash=",
"d.base_path=",
)
details = [t[1:] for t in all_torrents if t and t[0] == download_hash]
if not details:
return None
path = details[0][0]
return path if path else None
except Exception:
return None
@@ -5,15 +5,19 @@ Uses SABnzbd's REST API directly via requests (no external dependency).
"""
from typing import Any, Optional, Tuple
from urllib.parse import urlparse
import requests
from shelfmark.core.config import config
from shelfmark.core.logger import setup_logger
from shelfmark.release_sources.prowlarr.clients import (
from shelfmark.core.utils import normalize_http_url
from shelfmark.download.network import get_ssl_verify
from shelfmark.download.clients import (
DownloadClient,
DownloadStatus,
register_client,
with_retry,
)
logger = setup_logger(__name__)
@@ -66,32 +70,62 @@ def _parse_speed(slot: dict) -> Optional[int]:
class SABnzbdClient(DownloadClient):
"""SABnzbd download client using REST API."""
@staticmethod
def _resolve_completed_storage_path(storage: str, title: str) -> str:
"""Normalize SABnzbd's `storage` into a stable "job root" folder.
Walks up parent directories looking for a directory named exactly like the
job `title`.
This helps when SABnzbd reports a nested path (e.g. sorting/post-processing)
but we want the root folder for the completed job.
"""
from pathlib import Path
storage = storage or ""
title = (title or "").strip()
if not storage or not title:
return storage
# SAB returns absolute paths; don't require existence on disk.
path = Path(storage)
best_match: Path | None = None
for parent in [path, *path.parents]:
if parent.name == title:
best_match = parent
return str(best_match) if best_match is not None else storage
protocol = "usenet"
name = "sabnzbd"
def __init__(self):
"""Initialize SABnzbd client with settings from config."""
url = config.get("SABNZBD_URL", "")
if not url:
raw_url = config.get("SABNZBD_URL", "")
if not raw_url:
raise ValueError("SABNZBD_URL is required")
api_key = config.get("SABNZBD_API_KEY", "")
if not api_key:
raise ValueError("SABNZBD_API_KEY is required")
self.url = url.rstrip("/")
self.url = normalize_http_url(raw_url)
if not self.url:
raise ValueError("SABNZBD_URL is invalid")
self.api_key = api_key
self._category = config.get("SABNZBD_CATEGORY", "cwabd")
self._category = config.get("SABNZBD_CATEGORY", "books")
@staticmethod
def is_configured() -> bool:
"""Check if SABnzbd is configured and selected as the usenet client."""
client = config.get("PROWLARR_USENET_CLIENT", "")
url = config.get("SABNZBD_URL", "")
url = normalize_http_url(config.get("SABNZBD_URL", ""))
api_key = config.get("SABNZBD_API_KEY", "")
return client == "sabnzbd" and bool(url) and bool(api_key)
def _api_call(self, mode: str, params: dict = None) -> Any:
@with_retry()
def _api_call(self, mode: str, params: Optional[dict] = None) -> Any:
"""
Make an API call to SABnzbd.
@@ -103,7 +137,7 @@ class SABnzbdClient(DownloadClient):
JSON response from SABnzbd.
Raises:
Exception: If API call fails.
Exception: If API call fails after retries.
"""
api_url = f"{self.url}/api"
@@ -115,7 +149,7 @@ class SABnzbdClient(DownloadClient):
if params:
request_params.update(params)
response = requests.get(api_url, params=request_params, timeout=30)
response = requests.get(api_url, params=request_params, timeout=30, verify=get_ssl_verify(api_url))
response.raise_for_status()
result = response.json()
@@ -127,6 +161,101 @@ class SABnzbdClient(DownloadClient):
return result
def _api_post_file(self, nzb_content: bytes, filename: str, nzb_name: str, category: str) -> Any:
"""
Upload an NZB file to SABnzbd using addfile.
Returns:
JSON response from SABnzbd.
"""
api_url = f"{self.url}/api"
request_params = {
"apikey": self.api_key,
"mode": "addfile",
"output": "json",
"nzbname": nzb_name,
"cat": category,
}
files = {"name": (filename, nzb_content, "application/x-nzb")}
response = requests.post(api_url, params=request_params, files=files, timeout=30, verify=get_ssl_verify(api_url))
response.raise_for_status()
result = response.json()
if isinstance(result, dict) and result.get("status") is False:
error = result.get("error", "Unknown error")
raise Exception(f"SABnzbd error: {error}")
return result
def _fetch_nzb_content(self, url: str) -> bytes:
"""Fetch NZB content, including Prowlarr auth headers when appropriate."""
headers = self._get_prowlarr_headers(url)
response = requests.get(url, timeout=30, headers=headers, verify=get_ssl_verify(url))
response.raise_for_status()
return response.content
def _get_prowlarr_headers(self, url: str) -> dict:
# TODO: Move this source-specific Prowlarr auth handling into a source hook.
api_key = str(config.get("PROWLARR_API_KEY", "") or "").strip()
if not api_key:
return {}
prowlarr_url = normalize_http_url(config.get("PROWLARR_URL", ""))
if not prowlarr_url:
return {}
try:
target = urlparse(url)
base = urlparse(prowlarr_url)
except ValueError:
return {}
if target.hostname and base.hostname and target.hostname.lower() == base.hostname.lower():
return {"X-Api-Key": api_key}
return {}
@staticmethod
def _build_nzb_filename(name: str, url: str) -> str:
base_name = (name or "download").strip() or "download"
parsed = urlparse(url)
path = parsed.path or ""
lower_path = path.lower()
if lower_path.endswith(".nzb.gz"):
suffix = ".nzb.gz"
elif lower_path.endswith(".nzb"):
suffix = ".nzb"
else:
suffix = ""
lower_base = base_name.lower()
if suffix and lower_base.endswith(suffix):
return base_name
if suffix == ".nzb.gz" and lower_base.endswith(".nzb"):
return f"{base_name}.gz"
if suffix:
return f"{base_name}{suffix}"
if lower_base.endswith((".nzb", ".nzb.gz")):
return base_name
return f"{base_name}.nzb"
@staticmethod
def _extract_nzo_id(result: Any) -> str:
if not isinstance(result, dict):
raise Exception("SABnzbd returned invalid response")
nzo_ids = result.get("nzo_ids") or result.get("nzo_id")
if isinstance(nzo_ids, list) and nzo_ids:
return str(nzo_ids[0])
if isinstance(nzo_ids, str) and nzo_ids:
return nzo_ids
if isinstance(nzo_ids, int):
return str(nzo_ids)
raise Exception("SABnzbd returned no nzo_id")
def test_connection(self) -> Tuple[bool, str]:
"""Test connection to SABnzbd."""
try:
@@ -140,7 +269,14 @@ class SABnzbdClient(DownloadClient):
except Exception as e:
return False, f"Connection failed: {str(e)}"
def add_download(self, url: str, name: str, category: str = None) -> str:
def add_download(
self,
url: str,
name: str,
category: Optional[str] = None,
expected_hash: Optional[str] = None,
**kwargs,
) -> str:
"""
Add NZB by URL.
@@ -148,6 +284,7 @@ class SABnzbdClient(DownloadClient):
url: NZB URL (can be Prowlarr proxy URL)
name: Display name for the download
category: Category for organization (uses configured default if not specified)
expected_hash: Optional info_hash hint (unused)
Returns:
SABnzbd nzo_id.
@@ -160,7 +297,16 @@ class SABnzbdClient(DownloadClient):
try:
logger.debug(f"Adding NZB to SABnzbd: {name}")
nzb_filename = self._build_nzb_filename(name, url)
nzb_content = self._fetch_nzb_content(url)
result = self._api_post_file(nzb_content, nzb_filename, name, category)
nzo_id = self._extract_nzo_id(result)
logger.info(f"Added NZB to SABnzbd: {nzo_id}")
return nzo_id
except Exception as e:
logger.warning(f"SABnzbd addfile failed, falling back to addurl: {e}")
try:
result = self._api_call(
"addurl",
{
@@ -169,15 +315,9 @@ class SABnzbdClient(DownloadClient):
"cat": category,
},
)
# SABnzbd returns {"status": True, "nzo_ids": ["SABnzbd_nzo_xxx"]}
nzo_ids = result.get("nzo_ids", [])
if nzo_ids:
nzo_id = nzo_ids[0]
logger.info(f"Added NZB to SABnzbd: {nzo_id}")
return nzo_id
raise Exception("SABnzbd returned no nzo_id")
nzo_id = self._extract_nzo_id(result)
logger.info(f"Added NZB to SABnzbd via addurl: {nzo_id}")
return nzo_id
except Exception as e:
logger.error(f"SABnzbd add failed: {e}")
raise
@@ -241,24 +381,31 @@ class SABnzbdClient(DownloadClient):
if slot.get("nzo_id") == download_id:
status_text = slot.get("status", "").upper()
storage = slot.get("storage", "")
if storage is None:
storage = ""
logger.debug(f"SABnzbd history: {download_id} status={status_text} storage='{storage}'")
if status_text == "COMPLETED":
title = slot.get("name") or slot.get("nzb_name") or ""
resolved_storage = self._resolve_completed_storage_path(storage, title)
return DownloadStatus(
progress=100,
state="complete",
message="Complete",
complete=True,
file_path=storage,
file_path=resolved_storage,
)
elif status_text == "FAILED":
fail_message = slot.get("fail_message", "Download failed")
title = slot.get("name") or slot.get("nzb_name") or ""
resolved_storage = self._resolve_completed_storage_path(storage, title)
return DownloadStatus(
progress=100,
state="error",
message=fail_message,
complete=True,
file_path=None,
file_path=resolved_storage,
)
else:
# Post-processing states: Queued, QuickCheck, Verifying,
@@ -276,9 +423,7 @@ class SABnzbdClient(DownloadClient):
logger.warning(f"SABnzbd: download {download_id} not found in queue or history")
return DownloadStatus.error("Download not found")
except Exception as e:
error_type = type(e).__name__
logger.error(f"SABnzbd get_status failed ({error_type}): {e}")
return DownloadStatus.error(f"{error_type}: {e}")
return DownloadStatus.error(self._log_error("get_status", e))
def remove(self, download_id: str, delete_files: bool = False, archive: bool = True) -> bool:
"""
@@ -292,8 +437,9 @@ class SABnzbdClient(DownloadClient):
Returns:
True if successful.
"""
# First try to remove from queue. If it isn't there (common for completed jobs),
# fall back to history removal instead of failing fast on a SABnzbd error response.
try:
# First try to remove from queue
result = self._api_call(
"queue",
{
@@ -306,8 +452,11 @@ class SABnzbdClient(DownloadClient):
if result.get("status"):
logger.info(f"Removed NZB from SABnzbd queue: {download_id}")
return True
except Exception as e:
logger.debug(f"SABnzbd queue delete skipped for {download_id}: {e}")
# If not in queue, try to remove from history
# If not in queue (or queue delete failed), try to remove from history.
try:
result = self._api_call(
"history",
{
@@ -322,13 +471,12 @@ class SABnzbdClient(DownloadClient):
action = "archived" if archive else "removed"
logger.info(f"NZB {action} from SABnzbd history: {download_id}")
return True
return False
except Exception as e:
error_type = type(e).__name__
logger.error(f"SABnzbd remove failed ({error_type}): {e}")
self._log_error("remove", e)
return False
return False
def get_download_path(self, download_id: str) -> Optional[str]:
"""
Get the path where NZB files are located.
@@ -342,7 +490,9 @@ class SABnzbdClient(DownloadClient):
status = self.get_status(download_id)
return status.file_path
def find_existing(self, url: str) -> Optional[Tuple[str, DownloadStatus]]:
def find_existing(
self, url: str, category: Optional[str] = None
) -> Optional[Tuple[str, DownloadStatus]]:
"""
Check if an NZB for this URL already exists in SABnzbd.
@@ -353,6 +503,7 @@ class SABnzbdClient(DownloadClient):
Args:
url: NZB URL
category: Category to filter by (defaults to configured category)
Returns:
Tuple of (nzo_id, status) if found, None if not found.
@@ -378,10 +529,15 @@ class SABnzbdClient(DownloadClient):
if not filename:
return None
# Search queue
# Use provided category or fall back to configured default
search_category = category or self._category
# Search queue (SABnzbd uses "cat" field for category in queue)
queue_result = self._api_call("queue")
queue = queue_result.get("queue", {})
for slot in queue.get("slots", []):
if slot.get("cat", "") != search_category:
continue
slot_name = slot.get("filename", "")
if filename.lower() in slot_name.lower():
nzo_id = slot.get("nzo_id")
@@ -390,10 +546,12 @@ class SABnzbdClient(DownloadClient):
logger.debug(f"Found existing NZB in SABnzbd queue: {nzo_id}")
return (nzo_id, status)
# Search history
# Search history (SABnzbd uses "category" field in history)
history_result = self._api_call("history", {"limit": 100})
history = history_result.get("history", {})
for slot in history.get("slots", []):
if slot.get("category", "") != search_category:
continue
slot_name = slot.get("name", "")
if filename.lower() in slot_name.lower():
nzo_id = slot.get("nzo_id")
+740
View File
@@ -0,0 +1,740 @@
"""Shared download client settings registration."""
from contextlib import contextmanager
from typing import Any, Dict, Optional
from shelfmark.core.settings_registry import (
register_settings,
HeadingField,
TextField,
PasswordField,
ActionButton,
SelectField,
TagListField,
)
from shelfmark.core.utils import normalize_http_url, get_hardened_xmlrpc_client
from shelfmark.download.network import get_ssl_verify
# ==================== Test Connection Callbacks ====================
@contextmanager
def _transmission_session_verify_override(url: str):
"""Ensure transmission-rpc constructor uses the configured TLS verify mode."""
verify = get_ssl_verify(url)
if verify:
yield
return
try:
import transmission_rpc.client as transmission_rpc_client
except Exception:
yield
return
original_session_factory = transmission_rpc_client.requests.Session
def _session_factory(*args: Any, **kwargs: Any) -> Any:
session = original_session_factory(*args, **kwargs)
session.verify = False
return session
transmission_rpc_client.requests.Session = _session_factory
try:
yield
finally:
transmission_rpc_client.requests.Session = original_session_factory
def _test_qbittorrent_connection(current_values: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
"""Test the qBittorrent connection using current form values."""
from shelfmark.core.config import config
current_values = current_values or {}
raw_url = current_values.get("QBITTORRENT_URL") or config.get("QBITTORRENT_URL", "")
username = current_values.get("QBITTORRENT_USERNAME") or config.get("QBITTORRENT_USERNAME", "")
password = current_values.get("QBITTORRENT_PASSWORD") or config.get("QBITTORRENT_PASSWORD", "")
if not raw_url:
return {"success": False, "message": "qBittorrent URL is required"}
try:
from qbittorrentapi import Client
url = normalize_http_url(raw_url)
if not url:
return {"success": False, "message": "qBittorrent URL is invalid"}
client = Client(host=url, username=username, password=password, VERIFY_WEBUI_CERTIFICATE=get_ssl_verify(url))
client.auth_log_in()
api_version = client.app.web_api_version
return {"success": True, "message": f"Connected to qBittorrent (API v{api_version})"}
except ImportError:
return {"success": False, "message": "qbittorrent-api package not installed"}
except Exception as e:
return {"success": False, "message": f"Connection failed: {str(e)}"}
def _test_transmission_connection(current_values: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
"""Test the Transmission connection using current form values."""
from shelfmark.core.config import config
from shelfmark.download.clients.torrent_utils import (
parse_transmission_url,
)
current_values = current_values or {}
raw_url = current_values.get("TRANSMISSION_URL") or config.get("TRANSMISSION_URL", "")
username = current_values.get("TRANSMISSION_USERNAME") or config.get("TRANSMISSION_USERNAME", "")
password = current_values.get("TRANSMISSION_PASSWORD") or config.get("TRANSMISSION_PASSWORD", "")
if not raw_url:
return {"success": False, "message": "Transmission URL is required"}
url = normalize_http_url(raw_url)
if not url:
return {"success": False, "message": "Transmission URL is invalid"}
try:
from transmission_rpc import Client
# Parse URL to extract host, port, and path
protocol, host, port, path = parse_transmission_url(url)
client_kwargs = {
"host": host,
"port": port,
"path": path,
"username": username if username else None,
"password": password if password else None,
"protocol": protocol,
}
try:
with _transmission_session_verify_override(url):
client = Client(**client_kwargs)
except TypeError as e:
if "protocol" not in str(e):
raise
client_kwargs.pop("protocol", None)
with _transmission_session_verify_override(url):
client = Client(**client_kwargs)
if protocol == "https" and hasattr(client, "protocol"):
try:
setattr(client, "protocol", protocol)
except Exception:
pass
# Keep session verify aligned for subsequent calls beyond constructor bootstrap.
http_session = getattr(client, "_http_session", None)
if http_session is not None:
http_session.verify = get_ssl_verify(url)
session = client.get_session()
version = session.version
return {"success": True, "message": f"Connected to Transmission {version}"}
except ImportError:
return {"success": False, "message": "transmission-rpc package not installed"}
except Exception as e:
return {"success": False, "message": f"Connection failed: {str(e)}"}
def _test_deluge_connection(current_values: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
"""Test Deluge Web UI JSON-RPC connection using current form values."""
from urllib.parse import urlparse
import requests
from shelfmark.core.config import config
current_values = current_values or {}
raw_host = current_values.get("DELUGE_HOST") or config.get("DELUGE_HOST", "localhost")
raw_port = current_values.get("DELUGE_PORT") or config.get("DELUGE_PORT", "8112")
password = current_values.get("DELUGE_PASSWORD") or config.get("DELUGE_PASSWORD", "")
if not raw_host:
return {"success": False, "message": "Deluge host is required"}
if not password:
return {"success": False, "message": "Deluge password is required"}
raw_host = str(raw_host)
raw_host = normalize_http_url(raw_host, strip_trailing_slash=False) if raw_host else ""
if not raw_host:
return {"success": False, "message": "Deluge host is invalid"}
raw_port = str(raw_port or "8112")
scheme = "http"
base_path = ""
host = raw_host
port = int(raw_port) if raw_port.isdigit() else 8112
# Allow DELUGE_HOST to be a full URL (e.g. http://deluge:8112)
if raw_host.startswith(("http://", "https://")):
parsed = urlparse(raw_host)
scheme = parsed.scheme or "http"
host = parsed.hostname or "localhost"
if parsed.port is not None:
port = parsed.port
base_path = (parsed.path or "").rstrip("/")
else:
# Allow "host:port" in DELUGE_HOST for convenience.
if ":" in raw_host and raw_host.count(":") == 1:
host_part, port_part = raw_host.split(":", 1)
if host_part and port_part.isdigit():
host = host_part
port = int(port_part)
rpc_url = f"{scheme}://{host}:{port}{base_path}/json"
def rpc_call(session: requests.Session, rpc_id: int, method: str, *params: Any) -> Any:
payload = {"id": rpc_id, "method": method, "params": list(params)}
resp = session.post(rpc_url, json=payload, timeout=15, verify=get_ssl_verify(rpc_url))
resp.raise_for_status()
data = resp.json()
if data.get("error"):
error = data["error"]
if isinstance(error, dict):
raise Exception(error.get("message") or str(error))
raise Exception(str(error))
return data.get("result")
def get_daemon_version(session: requests.Session, rpc_id: int) -> Any:
try:
methods = rpc_call(session, rpc_id, "system.listMethods")
if isinstance(methods, list) and "daemon.get_version" in methods:
return rpc_call(session, rpc_id + 1, "daemon.get_version")
except Exception:
# Fall back to daemon.info to preserve existing behavior.
pass
return rpc_call(session, rpc_id + 1, "daemon.info")
try:
session = requests.Session()
if rpc_call(session, 1, "auth.login", password) is not True:
return {"success": False, "message": "Deluge Web UI authentication failed"}
if rpc_call(session, 2, "web.connected") is not True:
hosts = rpc_call(session, 3, "web.get_hosts") or []
if not hosts:
return {
"success": False,
"message": "Deluge Web UI isn't connected to Deluge core (no hosts configured). Add/connect a daemon in Deluge Web UI → Connection Manager.",
}
host_id = hosts[0][0]
for entry in hosts:
if isinstance(entry, list) and len(entry) >= 2 and entry[1] in {"127.0.0.1", "localhost"}:
host_id = entry[0]
break
rpc_call(session, 4, "web.connect", host_id)
if rpc_call(session, 5, "web.connected") is not True:
return {
"success": False,
"message": "Deluge Web UI couldn't connect to Deluge core. Check Deluge Web UI → Connection Manager.",
}
version = get_daemon_version(session, 6)
return {"success": True, "message": f"Connected to Deluge {version}"}
except requests.exceptions.ConnectionError:
return {"success": False, "message": "Could not connect to Deluge Web UI"}
except requests.exceptions.Timeout:
return {"success": False, "message": "Connection timed out"}
except Exception as e:
return {"success": False, "message": f"Connection failed: {str(e)}"}
def _test_rtorrent_connection(current_values: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
"""Test the rTorrent connection using current form values."""
from shelfmark.core.config import config
import ssl
from urllib.parse import urlparse
current_values = current_values or {}
raw_url = current_values.get("RTORRENT_URL") or config.get("RTORRENT_URL", "")
username = current_values.get("RTORRENT_USERNAME") or config.get("RTORRENT_USERNAME", "")
password = current_values.get("RTORRENT_PASSWORD") or config.get("RTORRENT_PASSWORD", "")
if not raw_url:
return {"success": False, "message": "rTorrent URL is required"}
url = normalize_http_url(raw_url)
if not url:
return {"success": False, "message": "rTorrent URL is invalid"}
try:
xmlrpc_client = get_hardened_xmlrpc_client()
# Add HTTP auth to URL if credentials provided
if username and password:
parsed = urlparse(url)
url = f"{parsed.scheme}://{username}:{password}@{parsed.netloc}{parsed.path}"
rpc_url = url.rstrip("/")
verify = get_ssl_verify(rpc_url)
if rpc_url.startswith("https://") and not verify:
ssl_context = ssl.create_default_context()
ssl_context.check_hostname = False
ssl_context.verify_mode = ssl.CERT_NONE
rpc = xmlrpc_client.ServerProxy(
rpc_url,
transport=xmlrpc_client.SafeTransport(context=ssl_context),
)
else:
rpc = xmlrpc_client.ServerProxy(rpc_url)
version = rpc.system.client_version()
return {"success": True, "message": f"Connected to rTorrent {version}"}
except Exception as e:
return {"success": False, "message": f"Connection failed: {str(e)}"}
def _test_nzbget_connection(current_values: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
"""Test the NZBGet connection using current form values."""
import requests
from shelfmark.core.config import config
current_values = current_values or {}
raw_url = current_values.get("NZBGET_URL") or config.get("NZBGET_URL", "")
username = current_values.get("NZBGET_USERNAME") or config.get("NZBGET_USERNAME", "nzbget")
password = current_values.get("NZBGET_PASSWORD") or config.get("NZBGET_PASSWORD", "")
if not raw_url:
return {"success": False, "message": "NZBGet URL is required"}
url = normalize_http_url(raw_url)
if not url:
return {"success": False, "message": "NZBGet URL is invalid"}
try:
rpc_url = f"{url.rstrip('/')}/jsonrpc"
payload = {"jsonrpc": "2.0", "method": "status", "params": [], "id": 1}
response = requests.post(rpc_url, json=payload, auth=(username, password), timeout=30, verify=get_ssl_verify(rpc_url))
response.raise_for_status()
result = response.json()
if "error" in result and result["error"]:
raise Exception(result["error"].get("message", "RPC error"))
version = result.get("result", {}).get("Version", "unknown")
return {"success": True, "message": f"Connected to NZBGet {version}"}
except requests.exceptions.ConnectionError:
return {"success": False, "message": "Could not connect to NZBGet"}
except requests.exceptions.Timeout:
return {"success": False, "message": "Connection timed out"}
except Exception as e:
return {"success": False, "message": f"Connection failed: {str(e)}"}
def _test_sabnzbd_connection(current_values: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
"""Test the SABnzbd connection using current form values."""
import requests
from shelfmark.core.config import config
current_values = current_values or {}
raw_url = current_values.get("SABNZBD_URL") or config.get("SABNZBD_URL", "")
api_key = current_values.get("SABNZBD_API_KEY") or config.get("SABNZBD_API_KEY", "")
if not raw_url:
return {"success": False, "message": "SABnzbd URL is required"}
url = normalize_http_url(raw_url)
if not url:
return {"success": False, "message": "SABnzbd URL is invalid"}
if not api_key:
return {"success": False, "message": "API key is required"}
try:
api_url = f"{url.rstrip('/')}/api"
params = {"apikey": api_key, "mode": "version", "output": "json"}
response = requests.get(api_url, params=params, timeout=30, verify=get_ssl_verify(api_url))
response.raise_for_status()
result = response.json()
version = result.get("version", "unknown")
return {"success": True, "message": f"Connected to SABnzbd {version}"}
except requests.exceptions.ConnectionError:
return {"success": False, "message": "Could not connect to SABnzbd"}
except requests.exceptions.Timeout:
return {"success": False, "message": "Connection timed out"}
except Exception as e:
return {"success": False, "message": f"Connection failed: {str(e)}"}
# ==================== Download Clients Tab ====================
@register_settings(
name="prowlarr_clients",
display_name="Download Clients",
icon="cog",
order=110,
)
def prowlarr_clients_settings():
"""Download client settings shared by external release sources."""
return [
# --- Torrent Client Selection ---
HeadingField(
key="torrent_heading",
title="Torrent Client",
description="Select and configure a torrent client for downloading torrent releases.",
),
SelectField(
key="PROWLARR_TORRENT_CLIENT",
label="Torrent Client",
description="Choose which torrent client to use",
options=[
{"value": "", "label": "None"},
{"value": "qbittorrent", "label": "qBittorrent"},
{"value": "transmission", "label": "Transmission"},
{"value": "deluge", "label": "Deluge"},
{"value": "rtorrent", "label": "rTorrent"},
],
default="",
),
# --- qBittorrent Settings ---
TextField(
key="QBITTORRENT_URL",
label="qBittorrent URL",
description="Web UI URL of your qBittorrent instance",
placeholder="http://qbittorrent:8080",
show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "qbittorrent"},
),
TextField(
key="QBITTORRENT_USERNAME",
label="Username",
description="qBittorrent Web UI username",
placeholder="admin",
show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "qbittorrent"},
),
PasswordField(
key="QBITTORRENT_PASSWORD",
label="Password",
description="qBittorrent Web UI password",
show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "qbittorrent"},
),
ActionButton(
key="test_qbittorrent",
label="Test Connection",
description="Verify your qBittorrent configuration",
style="primary",
callback=_test_qbittorrent_connection,
show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "qbittorrent"},
),
TextField(
key="QBITTORRENT_CATEGORY",
label="Book Category",
description="Category to assign to book downloads in qBittorrent",
placeholder="books",
default="books",
show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "qbittorrent"},
),
TextField(
key="QBITTORRENT_CATEGORY_AUDIOBOOK",
label="Audiobook Category",
description="Category for audiobook downloads. Leave empty to use the book category.",
placeholder="",
default="",
show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "qbittorrent"},
),
TextField(
key="QBITTORRENT_DOWNLOAD_DIR",
label="Download Directory",
description="Server-side directory where torrents are downloaded (optional, uses qBittorrent default if not specified)",
placeholder="/downloads",
show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "qbittorrent"},
),
TagListField(
key="QBITTORRENT_TAG",
label="Tags",
description="Tag(s) to assign to qBittorrent downloads. Leave empty for no tags.",
placeholder="",
default=[],
normalize_urls=False,
show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "qbittorrent"},
),
# --- Transmission Settings ---
TextField(
key="TRANSMISSION_URL",
label="Transmission URL",
description="URL of your Transmission instance (use https:// for TLS)",
placeholder="http://transmission:9091",
show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "transmission"},
),
TextField(
key="TRANSMISSION_USERNAME",
label="Username",
description="Transmission RPC username (if authentication enabled)",
show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "transmission"},
),
PasswordField(
key="TRANSMISSION_PASSWORD",
label="Password",
description="Transmission RPC password",
show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "transmission"},
),
ActionButton(
key="test_transmission",
label="Test Connection",
description="Verify your Transmission configuration",
style="primary",
callback=_test_transmission_connection,
show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "transmission"},
),
TextField(
key="TRANSMISSION_CATEGORY",
label="Book Label",
description="Label to assign to book downloads in Transmission",
placeholder="books",
default="books",
show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "transmission"},
),
TextField(
key="TRANSMISSION_CATEGORY_AUDIOBOOK",
label="Audiobook Label",
description="Label for audiobook downloads. Leave empty to use the book label.",
placeholder="",
default="",
show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "transmission"},
),
TextField(
key="TRANSMISSION_DOWNLOAD_DIR",
label="Download Directory",
description="Server-side directory where torrents are downloaded (optional, uses Transmission default if not specified)",
placeholder="/downloads",
show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "transmission"},
),
# --- Deluge Settings ---
TextField(
key="DELUGE_HOST",
label="Deluge Web UI Host/URL",
description="Hostname/IP or full URL of your Deluge Web UI (deluge-web)",
placeholder="http://deluge:8112",
default="localhost",
show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "deluge"},
),
TextField(
key="DELUGE_PORT",
label="Deluge Web UI Port",
description="Deluge Web UI port (default: 8112)",
placeholder="8112",
default="8112",
show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "deluge"},
),
PasswordField(
key="DELUGE_PASSWORD",
label="Password",
description="Deluge Web UI password (default: deluge)",
show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "deluge"},
),
ActionButton(
key="test_deluge",
label="Test Connection",
description="Verify your Deluge configuration",
style="primary",
callback=_test_deluge_connection,
show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "deluge"},
),
TextField(
key="DELUGE_CATEGORY",
label="Book Label",
description="Label to assign to book downloads in Deluge",
placeholder="books",
default="books",
show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "deluge"},
),
TextField(
key="DELUGE_CATEGORY_AUDIOBOOK",
label="Audiobook Label",
description="Label for audiobook downloads. Leave empty to use the book label.",
placeholder="",
default="",
show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "deluge"},
),
TextField(
key="DELUGE_DOWNLOAD_DIR",
label="Download Directory",
description="Server-side directory where torrents are downloaded (optional, uses Deluge default if not specified)",
placeholder="/downloads",
show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "deluge"},
),
# --- rTorrent Settings ---
TextField(
key="RTORRENT_URL",
label="rTorrent URL",
description="XML-RPC URL of your rTorrent instance",
placeholder="http://rtorrent:6881/RPC2",
show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "rtorrent"},
),
TextField(
key="RTORRENT_USERNAME",
label="Username",
description="HTTP Basic auth username (if authentication enabled)",
show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "rtorrent"},
),
PasswordField(
key="RTORRENT_PASSWORD",
label="Password",
description="HTTP Basic auth password",
show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "rtorrent"},
),
ActionButton(
key="test_rtorrent",
label="Test Connection",
description="Verify your rTorrent configuration",
style="primary",
callback=_test_rtorrent_connection,
show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "rtorrent"},
),
TextField(
key="RTORRENT_LABEL",
label="Book Label",
description="Label to assign to book downloads in rTorrent",
placeholder="cwabd",
default="cwabd",
show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "rtorrent"},
),
TextField(
key="RTORRENT_DOWNLOAD_DIR",
label="Download Directory",
description="Server-side directory where torrents are downloaded (optional, uses rTorrent default if not specified)",
placeholder="/downloads",
show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "rtorrent"},
),
# Note: Torrent client download path must be mounted identically in both containers.
SelectField(
key="PROWLARR_TORRENT_ACTION",
label="Torrent Completion Action",
description="Remove deletes the torrent from your client immediately after import (stops seeding, files are kept); Keep leaves it in the client to continue seeding",
options=[
{"value": "keep", "label": "Keep"},
{"value": "remove", "label": "Remove"},
],
default="keep",
show_when={"field": "PROWLARR_TORRENT_CLIENT", "notEmpty": True},
),
# --- Usenet Client Selection ---
HeadingField(
key="usenet_heading",
title="Usenet Client",
description="Select and configure a usenet client for downloading NZB releases.",
),
SelectField(
key="PROWLARR_USENET_CLIENT",
label="Usenet Client",
description="Choose which usenet client to use",
options=[
{"value": "", "label": "None"},
{"value": "nzbget", "label": "NZBGet"},
{"value": "sabnzbd", "label": "SABnzbd"},
],
default="",
),
# --- NZBGet Settings ---
TextField(
key="NZBGET_URL",
label="NZBGet URL",
description="URL of your NZBGet instance",
placeholder="http://nzbget:6789",
show_when={"field": "PROWLARR_USENET_CLIENT", "value": "nzbget"},
),
TextField(
key="NZBGET_USERNAME",
label="Username",
description="NZBGet control username",
placeholder="nzbget",
default="nzbget",
show_when={"field": "PROWLARR_USENET_CLIENT", "value": "nzbget"},
),
PasswordField(
key="NZBGET_PASSWORD",
label="Password",
description="NZBGet control password",
show_when={"field": "PROWLARR_USENET_CLIENT", "value": "nzbget"},
),
ActionButton(
key="test_nzbget",
label="Test Connection",
description="Verify your NZBGet configuration",
style="primary",
callback=_test_nzbget_connection,
show_when={"field": "PROWLARR_USENET_CLIENT", "value": "nzbget"},
),
TextField(
key="NZBGET_CATEGORY",
label="Book Category",
description="Category to assign to book downloads in NZBGet",
placeholder="Books",
default="Books",
show_when={"field": "PROWLARR_USENET_CLIENT", "value": "nzbget"},
),
TextField(
key="NZBGET_CATEGORY_AUDIOBOOK",
label="Audiobook Category",
description="Category for audiobook downloads. Leave empty to use the book category.",
placeholder="",
default="",
show_when={"field": "PROWLARR_USENET_CLIENT", "value": "nzbget"},
),
# --- SABnzbd Settings ---
TextField(
key="SABNZBD_URL",
label="SABnzbd URL",
description="URL of your SABnzbd instance",
placeholder="http://sabnzbd:8080",
show_when={"field": "PROWLARR_USENET_CLIENT", "value": "sabnzbd"},
),
PasswordField(
key="SABNZBD_API_KEY",
label="API Key",
description="Found in SABnzbd: Config > General > API Key",
show_when={"field": "PROWLARR_USENET_CLIENT", "value": "sabnzbd"},
),
ActionButton(
key="test_sabnzbd",
label="Test Connection",
description="Verify your SABnzbd configuration",
style="primary",
callback=_test_sabnzbd_connection,
show_when={"field": "PROWLARR_USENET_CLIENT", "value": "sabnzbd"},
),
TextField(
key="SABNZBD_CATEGORY",
label="Book Category",
description="Category to assign to book downloads in SABnzbd",
placeholder="books",
default="books",
show_when={"field": "PROWLARR_USENET_CLIENT", "value": "sabnzbd"},
),
TextField(
key="SABNZBD_CATEGORY_AUDIOBOOK",
label="Audiobook Category",
description="Category for audiobook downloads. Leave empty to use the book category.",
placeholder="",
default="",
show_when={"field": "PROWLARR_USENET_CLIENT", "value": "sabnzbd"},
),
# Note: Usenet client download path must be mounted identically in both containers.
SelectField(
key="PROWLARR_USENET_ACTION",
label="NZB Completion Action",
description="Move deletes the job from your usenet client after import; Copy keeps it in the client",
options=[
{"value": "move", "label": "Move"},
{"value": "copy", "label": "Copy"},
],
default="move",
show_when={"field": "PROWLARR_USENET_CLIENT", "notEmpty": True},
),
]
@@ -5,11 +5,13 @@ import hashlib
import re
from dataclasses import dataclass
from typing import Optional, Tuple
from urllib.parse import parse_qs, urlparse
from urllib.parse import parse_qs, urljoin, urlparse
import requests
from shelfmark.core.config import config
from shelfmark.core.logger import setup_logger
from shelfmark.download.network import get_ssl_verify
logger = setup_logger(__name__)
@@ -30,39 +32,79 @@ class TorrentInfo:
magnet_url: Optional[str] = None
"""The actual magnet URL, if available."""
def with_info_hash(self, info_hash: Optional[str]) -> "TorrentInfo":
"""Return a copy with the info_hash replaced when provided."""
if info_hash:
return TorrentInfo(
info_hash=info_hash,
torrent_data=self.torrent_data,
is_magnet=self.is_magnet,
magnet_url=self.magnet_url,
)
return self
def extract_torrent_info(url: str, fetch_torrent: bool = True) -> TorrentInfo:
"""Extract info_hash from magnet link or .torrent URL."""
def extract_torrent_info(
url: str,
fetch_torrent: bool = True,
expected_hash: Optional[str] = None,
) -> TorrentInfo:
"""Extract info_hash from magnet link or .torrent URL.
Notes:
When the URL points at Prowlarr's proxied download endpoint, it typically
requires the `X-Api-Key` header. If `PROWLARR_API_KEY` is configured,
include it for the torrent fetch request.
Redirects to magnet links are handled explicitly so we can extract a
hash from the magnet when available.
"""
is_magnet = url.startswith("magnet:")
# Try to extract hash from magnet URL
if is_magnet:
info_hash = extract_hash_from_magnet(url)
if not info_hash and expected_hash:
info_hash = expected_hash
return TorrentInfo(info_hash=info_hash, torrent_data=None, is_magnet=True, magnet_url=url)
# Not a magnet - try to fetch and parse the .torrent file
if not fetch_torrent:
return TorrentInfo(info_hash=None, torrent_data=None, is_magnet=False)
return TorrentInfo(info_hash=expected_hash, torrent_data=None, is_magnet=False)
headers: dict[str, str] = {"Accept": "application/x-bittorrent"}
# TODO: Move this source-specific Prowlarr auth handling into a source hook.
api_key = str(config.get("PROWLARR_API_KEY", "") or "").strip()
if api_key:
headers["X-Api-Key"] = api_key
def resolve_url(current: str, location: str) -> str:
if not location:
return current
# Support relative redirect locations
return urljoin(current, location)
try:
logger.debug(f"Fetching torrent file from: {url[:80]}...")
# Use allow_redirects=False to handle magnet link redirects manually
# Some indexers redirect download URLs to magnet links
resp = requests.get(url, timeout=30, allow_redirects=False)
resp = requests.get(url, timeout=30, allow_redirects=False, headers=headers, verify=get_ssl_verify(url))
# Check if this is a redirect to a magnet link
if resp.status_code in (301, 302, 303, 307, 308):
redirect_url = resp.headers.get("Location", "")
redirect_url = resolve_url(url, resp.headers.get("Location", ""))
if redirect_url.startswith("magnet:"):
logger.debug(f"Download URL redirected to magnet link")
logger.debug("Download URL redirected to magnet link")
info_hash = extract_hash_from_magnet(redirect_url)
if not info_hash and expected_hash:
info_hash = expected_hash
return TorrentInfo(
info_hash=info_hash, torrent_data=None, is_magnet=True, magnet_url=redirect_url
)
# Not a magnet redirect, follow it manually
logger.debug(f"Following redirect to: {redirect_url[:80]}...")
resp = requests.get(redirect_url, timeout=30)
resp = requests.get(redirect_url, timeout=30, headers=headers, verify=get_ssl_verify(redirect_url))
resp.raise_for_status()
torrent_data = resp.content
@@ -75,13 +117,15 @@ def extract_torrent_info(url: str, fetch_torrent: bool = True) -> TorrentInfo:
if text_content.startswith("magnet:"):
logger.debug("Download URL returned magnet link as response body")
info_hash = extract_hash_from_magnet(text_content)
if not info_hash and expected_hash:
info_hash = expected_hash
return TorrentInfo(
info_hash=info_hash, torrent_data=None, is_magnet=True, magnet_url=text_content
)
except Exception:
pass # Not text, continue with torrent parsing
info_hash = extract_info_hash_from_torrent(torrent_data)
info_hash = extract_info_hash_from_torrent(torrent_data) or expected_hash
if info_hash:
logger.debug(f"Extracted hash from torrent file: {info_hash}")
else:
@@ -89,12 +133,15 @@ def extract_torrent_info(url: str, fetch_torrent: bool = True) -> TorrentInfo:
return TorrentInfo(info_hash=info_hash, torrent_data=torrent_data, is_magnet=False)
except Exception as e:
logger.debug(f"Could not fetch torrent file: {e}")
return TorrentInfo(info_hash=None, torrent_data=None, is_magnet=False)
return TorrentInfo(info_hash=expected_hash, torrent_data=None, is_magnet=False)
def parse_transmission_url(url: str) -> Tuple[str, int, str]:
"""Parse Transmission URL into (host, port, path)."""
def parse_transmission_url(url: str) -> Tuple[str, str, int, str]:
"""Parse Transmission URL into (protocol, host, port, path)."""
parsed = urlparse(url)
protocol = (parsed.scheme or "http").lower()
if protocol not in ("http", "https"):
protocol = "http"
host = parsed.hostname or "localhost"
port = parsed.port or 9091
path = parsed.path or "/transmission/rpc"
@@ -103,7 +150,7 @@ def parse_transmission_url(url: str) -> Tuple[str, int, str]:
if not path.endswith("/rpc"):
path = path.rstrip("/") + "/transmission/rpc"
return host, port, path
return protocol, host, port, path
def bencode_decode(data: bytes) -> tuple:
@@ -181,7 +228,10 @@ def extract_info_hash_from_torrent(torrent_data: bytes) -> Optional[str]:
return None
info_bencoded = bencode_encode(decoded[b'info'])
return hashlib.sha1(info_bencoded).hexdigest().lower()
info_dict = decoded[b'info']
if isinstance(info_dict, dict) and b'pieces' in info_dict:
return hashlib.sha1(info_bencoded).hexdigest().lower()
return hashlib.sha256(info_bencoded).hexdigest().lower()
except Exception as e:
logger.debug(f"Failed to parse torrent file: {e}")
return None
@@ -195,7 +245,42 @@ def extract_hash_from_magnet(magnet_url: str) -> Optional[str]:
parsed = urlparse(magnet_url)
params = parse_qs(parsed.query)
for xt in params.get("xt", []):
def extract_btmh(value: str) -> Optional[str]:
raw_value = value.strip()
if not raw_value:
return None
data: Optional[bytes] = None
if re.fullmatch(r"[a-fA-F0-9]+", raw_value):
if len(raw_value) % 2 != 0:
return None
try:
data = bytes.fromhex(raw_value)
except ValueError:
return None
else:
padded = raw_value.upper() + "=" * (-len(raw_value) % 8)
try:
data = base64.b32decode(padded, casefold=True)
except Exception:
return None
if not data:
return None
if len(data) >= 34 and data[0] == 0x12 and data[1] == 0x20:
digest = data[2:34]
if len(digest) == 32:
return digest.hex().lower()
if len(data) == 32:
return data.hex().lower()
return None
xt_values = params.get("xt", [])
for xt in xt_values:
# Format: urn:btih:<hash> (32 or 40 chars)
match = re.match(r"urn:btih:([a-fA-F0-9]{40}|[a-zA-Z0-9]{32})", xt)
if match:
@@ -215,4 +300,11 @@ def extract_hash_from_magnet(magnet_url: str) -> Optional[str]:
# Fallback: return as-is
return hash_value.lower()
for xt in xt_values:
if xt.startswith("urn:btmh:"):
btmh_value = xt[len("urn:btmh:"):]
btmh_hash = extract_btmh(btmh_value)
if btmh_hash:
return btmh_hash
return None

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